o: simplify path resolution, remove auto-session/agent

- Remove auto-resolution of session/agent (was slow, unexpected)
- Path classification determines required context level immediately
- Errors are fast and actionable with clear fix instructions
- Root-level paths work without any context
- o env now just prints export statements without requiring context
This commit is contained in:
Levi Neely 2026-08-04 16:57:01 +02:00
parent 214242ba44
commit 05517c384a
1 changed files with 213 additions and 175 deletions

View File

@ -1,106 +1,155 @@
#!/bin/bash
# o — unified CLI for the ollie 9P namespace.
#
# Path resolution:
# Paths are resolved based on context:
# - Absolute paths (starting with session/, agents, models, etc.) → used as-is
# - Relative paths with $session set but no $agent → session/$session/<path>
# - Relative paths with both $session and $agent → session/$session/agent/$agent/<path>
# - No context set → path used as-is (root-relative)
#
# Subcommands:
# read <file> Read a file from the active session/agent
# read <path> Read a file
# write <path> [data] Write to a file (args or stdin)
# readloop <path> Read in a loop (re-reads after each return)
# ls [path] List namespace entries
# ctl <command> Send a control command (stop, compact, kill, ...)
# prompt Interactive REPL for submitting prompts
# tail Tail chat output from the active agent
# prompt Interactive REPL for submitting prompts (requires agent)
# tail Tail chat output (requires agent)
# tui Launch tmux TUI (requires agent)
# env Print resolved session/agent environment
# stop Interrupt running agent (requires agent)
# kill Kill session (requires session)
#
# Environment:
# session Session name (set via 'eval $(o env)')
# agent Agent name
#
# If session/agent are not set and there is exactly one session, it is
# selected automatically. If multiple exist, lists them and exits.
set -euo pipefail
# ── Resolve session and agent ────────────────────────────────────
# ── Path classification ─────────────────────────────────────────
resolve_session() {
# Root-level entries (no context needed)
ROOT_ENTRIES="session elevate agents backends models help ctl eventwait complete generate route aliases tools"
# Session-level files (exist directly under session/{name}/)
SESSION_FILES="env paused connected ctl name id elevate"
# Agent-level files (exist under session/{name}/agent/{name}/)
AGENT_FILES="prompt prompt.prev fifo.in fifo.out chat connection state statewait log offset plan cfg ctl cwd usage cost ctxsz models systemprompt context tail tools name id proc"
is_root_path() {
local path="$1"
local first="${path%%/*}"
for entry in $ROOT_ENTRIES; do
[[ "$first" == "$entry" ]] && return 0
done
return 1
}
is_session_file() {
local name="$1"
for f in $SESSION_FILES; do
[[ "$name" == "$f" ]] && return 0
done
[[ "$name" == "agent" || "$name" == agent/* ]] && return 0
return 1
}
is_agent_file() {
local name="$1"
local base="${name%%/*}"
for f in $AGENT_FILES; do
[[ "$base" == "$f" ]] && return 0
done
return 1
}
# ── Path resolution with validation ─────────────────────────────
# Resolve a path based on current context, with meaningful errors.
# Returns 0 on success (path on stdout), 1 on error (message on stderr).
resolve_path() {
local path="$1"
# Root-level paths used as-is
if is_root_path "$path"; then
echo "$path"
return 0
fi
# Get context
local s="${session:-}"
if [[ -n "$s" ]]; then
echo "$s"
return
local a="${agent:-}"
# Determine what level this path requires
local base="${path%%/*}"
# Check if it's an agent-level file
if is_agent_file "$base"; then
if [[ -z "$s" ]]; then
echo "error: '$path' requires session context" >&2
echo " export session=<name> or use full path: session/<name>/agent/<agent>/$path" >&2
return 1
fi
if [[ -z "$a" ]]; then
echo "error: '$path' requires agent context" >&2
echo " export agent=<name> or use full path: session/$s/agent/<name>/$path" >&2
return 1
fi
echo "session/$s/agent/$a/$path"
return 0
fi
# Count sessions from idx
local idx
idx=$(ollie-9p read session/idx 2>/dev/null || true)
if [[ -z "$idx" ]]; then
return
# Check if it's a session-level file/dir
if is_session_file "$base"; then
if [[ -z "$s" ]]; then
echo "error: '$path' requires session context" >&2
echo " export session=<name> or use full path: session/<name>/$path" >&2
return 1
fi
echo "session/$s/$path"
return 0
fi
local names
names=$(echo "$idx" | cut -f1 | sort -u)
local count
count=$(echo "$names" | wc -l)
if [[ $count -eq 1 ]]; then
echo "$names"
# Unknown relative path — try with whatever context we have
if [[ -n "$s" && -n "$a" ]]; then
echo "session/$s/agent/$a/$path"
elif [[ -n "$s" ]]; then
echo "session/$s/$path"
else
echo "error: multiple sessions active, set one:" >&2
echo "$names" | sed 's/^/ /' >&2
echo "" >&2
echo " export session=<name> OR eval \$(o env <name>)" >&2
exit 1
echo "$path"
fi
return 0
}
resolve_agent() {
local s="$1"
local a="${agent:-}"
if [[ -n "$a" ]]; then
echo "$a"
return
fi
if [[ -z "$s" ]]; then
return
fi
# List agents in session
local agents
agents=$(ollie-9p ls "session/$s/agent" 2>/dev/null | grep -v '^new$' || true)
if [[ -z "$agents" ]]; then
return
fi
local count
count=$(echo "$agents" | wc -l)
if [[ $count -eq 1 ]]; then
echo "$agents"
else
echo "error: multiple agents in session '$s', set one:" >&2
echo "$agents" | sed 's/^/ /' >&2
echo "" >&2
echo " export agent=<name> OR eval \$(o env $s <agent>)" >&2
exit 1
fi
}
# ── Context requirements for specific commands ──────────────────
require_session() {
local s
s=$(resolve_session)
local s="${session:-}"
if [[ -z "$s" ]]; then
echo "error: no session active (start one, or export session=<name>)" >&2
exit 1
echo "error: no session set" >&2
echo " export session=<name>" >&2
return 1
fi
echo "$s"
}
require_agent() {
local s="$1"
local a
a=$(resolve_agent "$s")
local a="${agent:-}"
if [[ -z "$a" ]]; then
echo "error: no agent set (export agent=<name>)" >&2
exit 1
echo "error: no agent set" >&2
echo " export agent=<name>" >&2
return 1
fi
echo "$a"
}
agent_path() {
local s a
s=$(require_session)
a=$(require_agent "$s")
s=$(require_session) || return 1
a=$(require_agent "$s") || return 1
echo "session/$s/agent/$a"
}
@ -108,64 +157,62 @@ agent_path() {
cmd_read() {
if [[ $# -lt 1 ]]; then
echo "usage: o read <file> [session [agent]]" >&2
echo "usage: o read <path>" >&2
exit 1
fi
local file="$1"; shift
[[ $# -ge 1 ]] && export session="${1}" && shift
[[ $# -ge 1 ]] && export agent="${1}" && shift
local path
path=$(agent_path)
ollie-9p read "$path/$file"
path=$(resolve_path "$1") || exit 1
ollie-9p read "$path"
}
cmd_write() {
if [[ $# -lt 1 ]]; then
echo "usage: o write <file> [data...]" >&2
echo " writes stdin (or args) to session/\$s/agent/\$a/<file>" >&2
echo "usage: o write <path> [data...]" >&2
exit 1
fi
local file="$1"; shift
local path
path=$(agent_path)
path=$(resolve_path "$file") || exit 1
if [[ $# -gt 0 ]]; then
printf '%s\n' "$*" | ollie-9p write "$path/$file"
printf '%s\n' "$*" | ollie-9p write "$path"
else
ollie-9p write "$path/$file"
ollie-9p write "$path"
fi
}
cmd_ls() {
local s
s=$(resolve_session) || true
if [[ $# -ge 1 ]]; then
local target="$1"; shift
if [[ "$target" == session/* || "$target" == elevate/* ]]; then
ollie-9p ls "$target"
elif [[ -n "$s" ]]; then
local a
a=$(resolve_agent "$s") || true
if [[ -n "$a" ]]; then
ollie-9p ls "session/$s/agent/$a/$target"
else
ollie-9p ls "session/$s/$target"
fi
else
ollie-9p ls "$target"
fi
elif [[ -n "$s" ]]; then
local a
a=$(resolve_agent "$s") || true
if [[ -n "$a" ]]; then
ollie-9p ls "session/$s/agent/$a"
else
ollie-9p ls "session/$s"
fi
local path
path=$(resolve_path "$1") || exit 1
ollie-9p ls "$path"
else
ollie-9p ls session
# No arg: list based on context level
local s="${session:-}"
local a="${agent:-}"
if [[ -n "$s" && -n "$a" ]]; then
ollie-9p ls "session/$s/agent/$a"
elif [[ -n "$s" ]]; then
ollie-9p ls "session/$s"
else
ollie-9p ls ""
fi
fi
}
cmd_readloop() {
if [[ $# -lt 1 ]]; then
echo "usage: o readloop <path>" >&2
exit 1
fi
local file="$1"; shift
local path
path=$(resolve_path "$file") || exit 1
while true; do
ollie-9p read "$path" || break
done
}
cmd_ctl() {
if [[ $# -lt 1 ]]; then
echo "usage: o ctl <command> [args...]" >&2
@ -173,15 +220,16 @@ cmd_ctl() {
exit 1
fi
local path
path=$(agent_path)
path=$(agent_path) || exit 1
echo "$*" | ollie-9p write "$path/ctl"
}
cmd_prompt() {
local path s a
path=$(agent_path)
s=$(require_session)
a=$(require_agent "$s")
local s a path
s=$(require_session) || exit 1
a=$(require_agent "$s") || exit 1
path="session/$s/agent/$a"
echo "[$s/$a]"
echo "Enter prompts (blank line sends, Ctrl+D exits)."
echo "Slash commands: /stop /compact /kill /clear /model /backend /cwd /help"
@ -196,7 +244,6 @@ cmd_prompt() {
prompt_str=" "
fi
if ! IFS= read -e -r -p "$prompt_str" line; then
# EOF — send remaining buffer if any
if [[ -n "$buf" ]]; then
history -s "$buf"
printf '%s\n' "$buf" | ollie-9p write "$path/prompt"
@ -204,7 +251,6 @@ cmd_prompt() {
printf "\n"
break
fi
# Slash commands (only when buffer is empty — first line)
if [[ -z "$buf" && "$line" == /* ]]; then
history -s "$line"
local cmd="${line#/}"
@ -230,7 +276,6 @@ cmd_prompt() {
continue
fi
if [[ -z "$line" && -n "$buf" ]]; then
# Blank line sends accumulated buffer
history -s "$buf"
printf '%s\n' "$buf" | ollie-9p write "$path/prompt"
buf=""
@ -248,88 +293,68 @@ cmd_prompt() {
cmd_tail() {
local path
path=$(agent_path)
path=$(agent_path) || exit 1
exec ollie-9p read "$path/chat"
}
cmd_readloop() {
if [[ $# -lt 1 ]]; then
echo "usage: o readloop <file>" >&2
echo " read in a loop — re-reads after each return" >&2
exit 1
fi
local file="$1"; shift
local path
path=$(agent_path)
while true; do
ollie-9p read "$path/$file" || break
done
}
cmd_env() {
# Accept optional positional args to override
[[ $# -ge 1 ]] && export session="${1}" && shift
[[ $# -ge 1 ]] && export agent="${1}" && shift
local s a
s=$(require_session)
a=$(require_agent "$s")
echo "export session=$s"
echo "export agent=$a"
# Accept optional positional args to set context
if [[ $# -ge 1 ]]; then
echo "export session=$1"
shift
elif [[ -n "${session:-}" ]]; then
echo "export session=$session"
fi
if [[ $# -ge 1 ]]; then
echo "export agent=$1"
elif [[ -n "${agent:-}" ]]; then
echo "export agent=$agent"
fi
}
cmd_stop() {
local path
path=$(agent_path)
path=$(agent_path) || exit 1
echo "stop" | ollie-9p write "$path/ctl"
}
cmd_kill() {
local s
s=$(require_session)
s=$(require_session) || exit 1
echo "kill" | ollie-9p write "session/$s/ctl"
}
cmd_tui() {
local s a
s=$(require_session)
a=$(require_agent "$s")
s=$(require_session) || exit 1
a=$(require_agent "$s") || exit 1
local tmux_session="o-${s}"
# Subcommand: o tui exit
if [[ "${1:-}" == "exit" ]]; then
tmux kill-session -t "$tmux_session" 2>/dev/null && echo "[killed $tmux_session]" || echo "no tui session: $tmux_session"
return
fi
# Kill existing tmux session if present
tmux kill-session -t "$tmux_session" 2>/dev/null || true
# Create new detached tmux session
# Pane 0: chat tail (left, large)
tmux new-session -d -s "$tmux_session" -c "$HOME" -x 200 -y 50 \
-e "session=$s" -e "agent=$a"
tmux send-keys -t "$tmux_session" "o tail" Enter
# Pane 1: state monitor (top-right)
tmux split-window -t "$tmux_session" -h -l 40 \
-e "session=$s" -e "agent=$a"
tmux send-keys -t "$tmux_session" "o readloop statewait" Enter
# Pane 2: spare shell (bottom-right, below state)
tmux split-window -t "$tmux_session" -v \
-e "session=$s" -e "agent=$a"
# Pane 3: prompt REPL (bottom, full width)
tmux split-window -t "$tmux_session:0.0" -v -l 8 \
-e "session=$s" -e "agent=$a"
tmux send-keys -t "$tmux_session" "o prompt" Enter
# Focus the prompt pane
tmux select-pane -t "$tmux_session:0.3"
# Attach
exec tmux attach -t "$tmux_session"
}
@ -337,27 +362,40 @@ cmd_help() {
cat <<'EOF'
usage: o <command> [args...]
commands:
read <file> Read a file from the active agent's namespace
write <file> [data] Write to a file (args or stdin)
readloop <file> Read in a loop (re-reads after each return)
ls [path] List namespace entries (defaults to agent dir)
ctl <cmd> Send control command (stop, compact, kill, ...)
stop Interrupt the running agent
kill Kill the active session
prompt Interactive multi-line prompt REPL
tail Stream chat output
tui Launch tmux layout (chat + prompt + state + shell)
tui exit Kill the tmux TUI session
env [session] [agent] Print session/agent env vars (eval-able)
help Show this help
Path resolution:
- Root paths (session/*, agents, models, etc.) → used as-is
- Agent files (chat, state, prompt, etc.) → require $session + $agent
- Session files (env, ctl, name, etc.) → require $session
environment:
session Active session name
agent Active agent name
Commands:
read <path> Read a file
write <path> [data] Write to a file (args or stdin)
readloop <path> Read in a loop (re-reads after return)
ls [path] List namespace entries
If only one session/agent exists, it is auto-selected.
If multiple exist, you must choose: eval $(o env <session> [agent])
ctl <cmd> Send control command to agent
stop Interrupt running agent
kill Kill active session
prompt Interactive multi-line prompt REPL
tail Stream chat output
tui Launch tmux layout
tui exit Kill tmux TUI session
env [session] [agent] Print export statements for context
help Show this help
Environment:
session Session name
agent Agent name
Examples:
o ls # list at current context level
o ls session # list all sessions
o read models # read root-level file
export session=foo agent=bar # set context
o read chat # read agent's chat
o readloop statewait # blocks until agent state changes
EOF
}
@ -370,17 +408,17 @@ fi
subcmd="$1"; shift
case "$subcmd" in
read) cmd_read "$@" ;;
write) cmd_write "$@" ;;
read) cmd_read "$@" ;;
write) cmd_write "$@" ;;
readloop) cmd_readloop "$@" ;;
ls) cmd_ls "$@" ;;
ctl) cmd_ctl "$@" ;;
stop) cmd_stop "$@" ;;
kill) cmd_kill "$@" ;;
prompt) cmd_prompt "$@" ;;
tail) cmd_tail "$@" ;;
tui) cmd_tui "$@" ;;
env) cmd_env "$@" ;;
ls) cmd_ls "$@" ;;
ctl) cmd_ctl "$@" ;;
stop) cmd_stop "$@" ;;
kill) cmd_kill "$@" ;;
prompt) cmd_prompt "$@" ;;
tail) cmd_tail "$@" ;;
tui) cmd_tui "$@" ;;
env) cmd_env "$@" ;;
help|-h|--help) cmd_help ;;
*)
echo "unknown command: $subcmd" >&2