#!/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/ # - Relative paths with both $session and $agent → session/$session/agent/$agent/ # - No context set → path used as-is (root-relative) # # Subcommands: # read Read a file (-l to loop) # write [data] Write to a file (args or stdin) # ls [path] List namespace entries # ctl Send a control command (stop, compact, kill, ...) # prompt Interactive REPL for submitting prompts (requires agent) # chatstream Stream chat output (requires agent) # tui Launch tmux TUI (requires agent) # env Print resolved session/agent environment # # Environment: # session Session name (set via 'eval $(o env)') # agent Agent name set -euo pipefail # ── Path classification ───────────────────────────────────────── # Root-level entries (no context needed) ROOT_ENTRIES="session bypass 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 bypass" # Agent-level files (exist under session/{name}/agent/{name}/) AGENT_FILES="prompt fifo chat chat.raw statewait log plan cfg ctl stats 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:-}" 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= or use full path: session//agent//$path" >&2 return 1 fi if [[ -z "$a" ]]; then echo "error: '$path' requires agent context" >&2 echo " export agent= or use full path: session/$s/agent//$path" >&2 return 1 fi echo "session/$s/agent/$a/$path" return 0 fi # 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= or use full path: session//$path" >&2 return 1 fi echo "session/$s/$path" return 0 fi # 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 "$path" fi return 0 } # ── Context requirements for specific commands ────────────────── # Resolve session and agent from positional args or env vars. # Usage: resolve_context "$@" → returns (s, a) by modifying caller's vars. # Pass "$@" from the calling command; it shifts positional args. resolve_context() { local _s _a _s="${1:-${session:-}}" if [[ -n "${1+set}" ]]; then shift _a="${1:-${agent:-}}" else _a="${agent:-}" fi echo "$_s|$_a" } require_session() { local s="${session:-}" if [[ -z "$s" ]]; then echo "error: no session set" >&2 echo " export session=" >&2 return 1 fi echo "$s" } require_agent() { local s="$1" local a="${agent:-}" if [[ -z "$a" ]]; then echo "error: no agent set" >&2 echo " export agent=" >&2 return 1 fi echo "$a" } # ── Subcommands ────────────────────────────────────────────────── cmd_read() { local loop=false while [[ "${1:-}" == -* ]]; do case "$1" in -l|--loop) loop=true; shift ;; *) break ;; esac done if [[ $# -lt 1 ]]; then echo "usage: o read [-l] " >&2 exit 1 fi local path path=$(resolve_path "$1") || exit 1 if $loop; then while true; do ollie-9p read "$path" || break done else ollie-9p read "$path" fi } cmd_write() { if [[ $# -lt 1 ]]; then echo "usage: o write [data...]" >&2 exit 1 fi local file="$1"; shift local path path=$(resolve_path "$file") || exit 1 if [[ $# -gt 0 ]]; then printf '%s\n' "$*" | ollie-9p write "$path" else ollie-9p write "$path" fi } cmd_ls() { if [[ $# -ge 1 ]]; then local path path=$(resolve_path "$1") || exit 1 ollie-9p ls "$path" else # 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_generate() { # One-shot generation via /generate (no session/agent needed) # Usage: o generate "prompt" # echo "prompt" | o generate local prompt="" if [[ $# -gt 0 ]]; then prompt="$*" else prompt=$(cat) fi [[ -z "$prompt" ]] && { echo "usage: o generate " >&2; exit 1; } printf '%s' "$prompt" | ollie-9p rdwr generate } cmd_complete() { # Code completion via /complete (no session/agent needed) # Usage: o complete '{"file":"...","prefix":"...","suffix":"..."}' # echo '{"file":"..."}' | o complete local json="" if [[ $# -gt 0 ]]; then json="$*" else json=$(cat) fi [[ -z "$json" ]] && { echo "usage: o complete " >&2; exit 1; } printf '%s' "$json" | ollie-9p rdwr complete } cmd_route() { # Task routing via /route (no session/agent needed) # Usage: o route "task description" # echo "task" | o route local task="" if [[ $# -gt 0 ]]; then task="$*" else task=$(cat) fi [[ -z "$task" ]] && { echo "usage: o route " >&2; exit 1; } printf '%s' "$task" | ollie-9p rdwr route } cmd_ctl() { if [[ $# -lt 1 ]]; then echo "usage: o ctl [args...]" >&2 echo " Agent: stop, compact, clear, inject, agent, model, cwd, name" >&2 echo " Session: kill, pause, resume" >&2 exit 1 fi local s="${session:-}" local a="${agent:-}" if [[ -z "$s" ]]; then echo "error: no session set (export session=)" >&2 exit 1 fi # Session-level commands (no agent needed) case "$1" in kill|pause|resume) echo "$*" | ollie-9p write "session/$s/ctl" return ;; esac # Agent-level commands (rdwr: write command, read response) if [[ -z "$a" ]]; then echo "error: no agent set (export agent=)" >&2 exit 1 fi printf '%s' "$*" | ollie-9p rdwr "session/$s/agent/$a/ctl" } cmd_prompt() { local ctx s a ctx=$(resolve_context "$@") || exit 1 s="${ctx%%|*}" a="${ctx##*|}" if [[ -z "$s" || -z "$a" ]]; then echo "error: session and agent are required" >&2 echo " usage: o prompt " >&2 echo " or: export session= agent=" >&2 exit 1 fi local path="session/$s/agent/$a" echo "[$s/$a]" echo "Type . on a blank line to send, Ctrl+D to exit." echo "/ prefix runs ctl: /stop, /compact, /model X, /inject X" echo "!q exits the TUI." echo "" local buf="" line while true; do local prompt_str if [[ -z "$buf" ]]; then prompt_str=$'\033[1m>\033[0m ' else prompt_str=" " fi printf '%s' "$prompt_str" if ! IFS= read -r line; then if [[ -n "$buf" ]]; then history -s "$buf" printf '%s\n' "$buf" | ollie-9p write "$path/prompt" fi printf "\n" break fi # Drain any additional lines that arrived quickly (multi-line paste) while IFS= read -r -t 0.1 extra_line; do line="$line"$'\n'"$extra_line" done if [[ -z "$buf" && "$line" == /* ]]; then history -s "$line" o ctl "${line#/}" || true continue fi if [[ -z "$buf" && "$line" == !* ]]; then history -s "$line" local bang="${line#!}" case "$bang" in q|quit) tmux kill-session -t "o-${s}" 2>/dev/null; exit 0 ;; esac continue fi if [[ "$line" == "." ]]; then # Send prompt on "." alone if [[ -n "$buf" ]]; then history -s "$buf" printf '%s\n' "$buf" | ollie-9p write "$path/prompt" buf="" fi continue fi if [[ -z "$line" && -n "$buf" ]]; then # Empty line during multi-line input: append newline buf="$buf"$'\n' elif [[ -z "$line" ]]; then continue else if [[ -n "$buf" ]]; then buf="$buf"$'\n'"$line" else buf="$line" fi fi done } cmd_log() { local lines=1000 local ctx s a # Parse -n flag for line count while [[ $# -gt 0 && "$1" == -* ]]; do case "$1" in -n) shift; lines="${1:-1000}"; shift ;; -n*) lines="${1#-n}"; shift ;; *) break ;; esac done ctx=$(resolve_context "$@") || exit 1 s="${ctx%%|*}" a="${ctx##*|}" if [[ -z "$s" || -z "$a" ]]; then echo "error: session and agent are required" >&2 echo " usage: o log [-n lines] [session] [agent]" >&2 echo " or: export session= agent=" >&2 exit 1 fi ollie-9p read "session/$s/agent/$a/log" | tail -n "$lines" } cmd_env() { # 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_tui() { local tmux_session="${session:-}" local s a # Check for subcommands first if [[ "${1:-}" == "exit" ]]; then if [[ -z "$tmux_session" ]]; then echo "error: no session context" >&2 exit 1 fi tmux kill-session -t "o-${tmux_session}" 2>/dev/null && echo "[killed o-${tmux_session}]" || echo "no tui session: o-${tmux_session}" return fi # Accept optional positional args: o tui [session] [agent] local ctx ctx=$(resolve_context "$@") s="${ctx%%|*}" a="${ctx##*|}" if [[ -z "$s" ]]; then echo "error: no session set" >&2 echo " usage: o tui [agent]" >&2 echo " or: export session=" >&2 exit 1 fi if [[ -z "$a" ]]; then echo "error: no agent set" >&2 echo " usage: o tui " >&2 echo " or: export agent=" >&2 exit 1 fi tmux_session="o-${s}" tmux kill-session -t "$tmux_session" 2>/dev/null || true # Layout: # ┌──────────────────────────────────┐ # │ chat │ # │ │ # ├──────────────────────────────────┤ # │ prompt (20%) │ # └──────────────────────────────────┘ # State shown in tmux status-right. # Pane 0: chat (top, 80%) - show last 1000 lines of log, then tail chat tmux -CC new-session -d -s "$tmux_session" -c "$HOME" \ -e "session=$s" -e "agent=$a" tmux send-keys -t "$tmux_session" "o log -n 1000 | grep -v '\\[\\[\\[.*\\]\\]\\]' | grep -v '^\`\`\`'; o read chat" Enter # Pane 1: prompt REPL (bottom, 20% height) tmux split-window -t "$tmux_session" -v -l 20% \ -e "session=$s" -e "agent=$a" tmux send-keys -t "$tmux_session" "clear; o prompt" Enter # Show agent state in tmux status bar (live updates via statewait) tmux set-option -t "$tmux_session" status-right "starting... | %H:%M" tmux set-option -t "$tmux_session" status-right-length 64 # Background: loops on statewait, updates status bar instantly on each change while true; do ollie-9p read "session/$s/agent/$a/statewait" 2>/dev/null | while read -r state; do bg="" case "$state" in idle) bg="colour70" ;; # green calling*) bg="colour214" ;; # orange thinking) bg="colour33" ;; # blue paused) bg="colour245" ;; # gray *) bg="default" ;; esac tmux set-option -t "$tmux_session" status-right-style "fg=default,bg=$bg" 2>/dev/null tmux set-option -t "$tmux_session" status-right "$state | %H:%M" 2>/dev/null done done & local bg_pid=$! trap "kill $bg_pid 2>/dev/null" EXIT # Focus the prompt pane tmux select-pane -t "$tmux_session:0.1" exec tmux attach -t "$tmux_session" } cmd_help() { cat <<'EOF' usage: o [args...] Path resolution: - Root paths (session/*, agents, models, etc.) → used as-is - Agent files (chat, prompt, cfg, etc.) → require $session + $agent - Session files (env, ctl, name, etc.) → require $session Commands: read [-l] Read a file (-l: loop, re-read after each return) write [data] Write to a file (args or stdin) ls [path] List namespace entries generate One-shot generation (no session needed) complete Code completion (no session needed) route Route task to best model (no session needed) ctl [s] [a] Send control command (session or agent level) prompt Interactive multi-line prompt REPL log [-n N] Show last N lines of agent log (default 1000) 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 o read -l statewait # block-loop on state changes o read chat # stream chat (filtered text) o read chat.raw # stream chat (full markup) o generate "explain monads" # one-shot, no session o ctl stop # stop agent (uses $session/$agent) o ctl kill mysession # kill session by name export session=foo agent=bar # set context EOF } # ── Dispatch ───────────────────────────────────────────────────── if [[ $# -lt 1 ]]; then cmd_help exit 0 fi subcmd="$1"; shift case "$subcmd" in read) cmd_read "$@" ;; write) cmd_write "$@" ;; ls) cmd_ls "$@" ;; generate) cmd_generate "$@" ;; complete) cmd_complete "$@" ;; route) cmd_route "$@" ;; ctl) cmd_ctl "$@" ;; prompt) cmd_prompt "$@" ;; log) cmd_log "$@" ;; tui) cmd_tui "$@" ;; env) cmd_env "$@" ;; help|-h|--help) cmd_help ;; *) echo "unknown command: $subcmd" >&2 echo "run 'o help' for usage" >&2 exit 1 ;; esac