#!/bin/bash # o — unified CLI for the ollie 9P namespace. # # Context is determined by the first argument: # o Root level # o Session level # o / Agent level # o : Agent level (colon separator) # # Slash is the primary separator. Agent names may contain colons. # Use colon separator only when session names contain slashes. # # Subcommands: # read Read a file (-l to loop) # write [data] Write to a file (args or stdin) # rdwr [data] Write then read (request-response files) # ls [path] List namespace entries # new [cwd] Create session (if needed) and agent (agent-level) # ctl Send a control command # prompt Interactive REPL for submitting prompts (agent-level) # tui Launch tmux TUI (agent-level) # generate One-shot generation (root-level only) # env Print export statements for context # # Examples: # o ls List root (sessions, models, etc.) # o myproj ls List session contents # o myproj/coding new Create session+agent (cwd=$PWD) # o myproj/coding prompt Interactive prompt # o myproj/coding read chat Read chat output set -euo pipefail # ── Terminal capability ────────────────────────────────────────── USE_COLOR=true if [[ "${TERM:-dumb}" == "dumb" ]] || [[ ! -t 1 ]]; then USE_COLOR=false fi # ── Globals set by dispatch ───────────────────────────────────── PREFIX="" # Path prefix based on context CTX_SESSION="" # Session name if in session/agent context CTX_AGENT="" # Agent name if in agent context # ── Known commands (to distinguish from context) ───────────────── COMMANDS="read write rdwr ls new ctl prompt log tui env generate overview bypass approve deny help" is_command() { local arg="$1" for cmd in $COMMANDS; do [[ "$arg" == "$cmd" ]] && return 0 done [[ "$arg" == "-h" || "$arg" == "--help" ]] && return 0 return 1 } # ── Path resolution helper ────────────────────────────────────── # Resolves a path: if it starts with /, treat as root-level (strip /) # Otherwise, prepend PREFIX. resolve_path() { local p="$1" if [[ "$p" == /* ]]; then # Absolute path - strip leading / and use as root-relative echo "${p#/}" else # Relative path - prepend PREFIX echo "${PREFIX}${PREFIX:+/}$p" fi } # ── 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") 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") if [[ $# -gt 0 ]]; then printf '%s\n' "$*" | ollie-9p write "$path" else ollie-9p write "$path" fi } cmd_rdwr() { if [[ $# -lt 1 ]]; then echo "usage: o rdwr [data...]" >&2 exit 1 fi local file="$1"; shift local path path=$(resolve_path "$file") if [[ $# -gt 0 ]]; then printf '%s\n' "$*" | ollie-9p rdwr "$path" else ollie-9p rdwr "$path" fi } cmd_ls() { local path="${PREFIX}" if [[ $# -ge 1 ]]; then path=$(resolve_path "$1") fi ollie-9p ls "$path" } cmd_generate() { # One-shot generation via /generate (root-level only) if [[ -n "$PREFIX" ]]; then echo "error: generate is a root-level command" >&2 echo " usage: o generate " >&2 exit 1 fi 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_ctl() { if [[ $# -lt 1 ]]; then if [[ -z "$PREFIX" ]]; then echo "usage: o ctl (or o / ctl )" >&2 exit 1 fi # The ctl file is self-describing: an empty request returns the # valid commands with descriptions. echo "usage: o ctl [args...]" >&2 printf '' | ollie-9p rdwr "${PREFIX}/ctl" >&2 exit 1 fi if [[ -z "$PREFIX" ]]; then echo "error: ctl requires session or agent context" >&2 echo " usage: o ctl " >&2 echo " or: o / ctl " >&2 exit 1 fi if [[ -n "$CTX_AGENT" ]]; then # Agent-level: rdwr to get response printf '%s' "$*" | ollie-9p rdwr "${PREFIX}/ctl" else # Session-level: write only echo "$*" | ollie-9p write "${PREFIX}/ctl" fi } cmd_prompt() { if [[ -z "$CTX_AGENT" ]]; then echo "error: prompt requires agent context" >&2 echo " usage: o / prompt" >&2 exit 1 fi # If stdin is not a terminal, read it all and send as a single prompt if [[ ! -t 0 ]]; then cat | ollie-9p write "${PREFIX}/prompt" return fi # Interactive REPL mode echo "[$CTX_SESSION/$CTX_AGENT]" echo "Type . on a blank line to send, Ctrl+D to exit." echo "/ prefix runs ctl: /stop, /compact, /model X, /i X (type /help for all)" echo "!q exits the TUI." echo "" local buf="" line while true; do local prompt_str if [[ -z "$buf" ]]; then if $USE_COLOR; then prompt_str=$'\033[1m>\033[0m ' else prompt_str='> ' fi 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 "${PREFIX}/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" cmd_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-${CTX_SESSION}" 2>/dev/null; exit 0 ;; esac continue fi if [[ "$line" == "." ]]; then if [[ -n "$buf" ]]; then history -s "$buf" printf '%s\n' "$buf" | ollie-9p write "${PREFIX}/prompt" buf="" fi continue fi if [[ -z "$line" && -n "$buf" ]]; then 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 while [[ $# -gt 0 && "$1" == -* ]]; do case "$1" in -n) shift; lines="${1:-1000}"; shift ;; -n*) lines="${1#-n}"; shift ;; *) break ;; esac done if [[ -z "$CTX_AGENT" ]]; then echo "error: log requires agent context" >&2 echo " usage: o / log [-n N]" >&2 exit 1 fi ollie-9p read "${PREFIX}/log" | tail -n "$lines" } cmd_bypass() { if [[ -z "$CTX_SESSION" ]]; then echo "error: bypass requires session context" >&2 echo " usage: o bypass" >&2 exit 1 fi local bypass_path="session/$CTX_SESSION/bypass" local pending pending=$(ollie-9p read "$bypass_path" 2>/dev/null || true) if [[ -z "$pending" ]]; then echo "no pending bypass request" return 0 fi local id cmd cwd agent id=$(echo "$pending" | jq -r '.id' 2>/dev/null) cmd=$(echo "$pending" | jq -r '.cmd' 2>/dev/null) cwd=$(echo "$pending" | jq -r '.cwd' 2>/dev/null) agent=$(echo "$pending" | jq -r '.env.OLLIE_UNAME // "unknown"' 2>/dev/null) echo "id: $id" echo "agent: $agent" echo "cwd: $cwd" echo "cmd: $cmd" } cmd_approve() { if [[ -z "$CTX_SESSION" ]]; then echo "error: approve requires session context" >&2 echo " usage: o approve [id]" >&2 exit 1 fi local req_id="${1:-}" local bypass_path="session/$CTX_SESSION/bypass" local pending pending=$(ollie-9p read "$bypass_path" 2>/dev/null || true) if [[ -z "$pending" ]]; then echo "no pending bypass request" return 0 fi local id id=$(echo "$pending" | jq -r '.id' 2>/dev/null) if [[ -z "$id" || "$id" == "null" ]]; then echo "error: could not parse bypass request" >&2 exit 1 fi # If request ID specified, verify it matches if [[ -n "$req_id" && "$req_id" != "$id" ]]; then echo "error: request id mismatch (pending: $id, requested: $req_id)" >&2 exit 1 fi local cmd cwd cmd=$(echo "$pending" | jq -r '.cmd' 2>/dev/null) cwd=$(echo "$pending" | jq -r '.cwd' 2>/dev/null) echo "[$id] approving: $cmd" echo " cwd: $cwd" printf '%s approve' "$id" | ollie-9p write "$bypass_path" echo "approved" } cmd_deny() { if [[ -z "$CTX_SESSION" ]]; then echo "error: deny requires session context" >&2 echo " usage: o deny [id]" >&2 exit 1 fi local req_id="${1:-}" local bypass_path="session/$CTX_SESSION/bypass" local pending pending=$(ollie-9p read "$bypass_path" 2>/dev/null || true) if [[ -z "$pending" ]]; then echo "no pending bypass request" return 0 fi local id id=$(echo "$pending" | jq -r '.id' 2>/dev/null) if [[ -z "$id" || "$id" == "null" ]]; then echo "error: could not parse bypass request" >&2 exit 1 fi # If request ID specified, verify it matches if [[ -n "$req_id" && "$req_id" != "$id" ]]; then echo "error: request id mismatch (pending: $id, requested: $req_id)" >&2 exit 1 fi local cmd cwd cmd=$(echo "$pending" | jq -r '.cmd' 2>/dev/null) cwd=$(echo "$pending" | jq -r '.cwd' 2>/dev/null) echo "[$id] denying: $cmd" echo " cwd: $cwd" printf '%s deny' "$id" | ollie-9p write "$bypass_path" echo "denied" } cmd_new() { if [[ -z "$CTX_AGENT" ]]; then echo "usage: o / new [cwd]" >&2 exit 1 fi local agent_cwd="${1:-$PWD}" # session/new is get-or-create: creating an existing session is a no-op # success, so real failures now surface instead of being suppressed. printf 'name=%s\ncwd=%s\n' "$CTX_SESSION" "$agent_cwd" | ollie-9p write "session/new" # Create the agent printf 'name=%s\ncwd=%s\n' "$CTX_AGENT" "$agent_cwd" | ollie-9p write "session/$CTX_SESSION/agent/new" echo "created: $CTX_SESSION/$CTX_AGENT" } cmd_tui() { if [[ -z "$CTX_AGENT" ]]; then echo "error: tui requires agent context" >&2 echo " usage: o / tui" >&2 exit 1 fi local s="$CTX_SESSION" local a="$CTX_AGENT" # Sanitize session and agent names for tmux (replace / and : with -) local safe_session="${s//\//-}" safe_session="${safe_session//:/-}" local safe_agent="${a//\//-}" safe_agent="${safe_agent//:/-}" local tmux_session="o-${safe_session}-${safe_agent}" # Check for subcommands 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 tmux kill-session -t "${tmux_session}" 2>/dev/null || true # Layout: chat (top 80%), prompt (bottom 20%) # Use slash separator - session names don't contain slashes, agent names might contain colons local ctx="$s/$a" tmux new-session -d -s "$tmux_session" -c "$HOME" tmux set-option -t "$tmux_session" mouse on sleep 0.3 # Wait for shell to initialize tmux send-keys -t "$tmux_session" "o '$ctx' log -n 1000; o '$ctx' read chat" Enter tmux split-window -t "$tmux_session" -v -l 20% sleep 0.2 tmux send-keys -t "$tmux_session" "o '$ctx' prompt" Enter tmux set-option -t "$tmux_session" status-right "starting... | %H:%M" tmux set-option -t "$tmux_session" status-right-length 64 # Subscribe to state events for this agent only echo "session.${s}.agent.${a}.state" | ollie-9p rdwrs event 2>/dev/null | while read -r topic state; do bg="" case "$state" in idle) bg="colour70" ;; calling*) bg="colour214" ;; thinking) bg="colour33" ;; paused) bg="colour245" ;; *) bg="default" ;; esac tmux set-option -t "$tmux_session" status-right-style "fg=default,bg=$bg" 2>/dev/null status=$(ollie-9p read "${PREFIX}/status" 2>/dev/null | head -n1) tmux set-option -t "$tmux_session" status-right "${status:-$state} | %H:%M" 2>/dev/null done & local bg_pid=$! trap "kill $bg_pid 2>/dev/null" EXIT tmux select-pane -t "${tmux_session}:0.1" exec tmux attach -t "$tmux_session" } cmd_env() { # Print export statements for current context [[ -n "$CTX_SESSION" ]] && echo "export OLLIE_SESSION=$CTX_SESSION" [[ -n "$CTX_AGENT" ]] && echo "export OLLIE_AGENT=$CTX_AGENT" } # cmd_overview shows all running sessions and their agents with a human-readable # status, so you can see what's live without knowing any names in advance. cmd_overview() { local idx idx=$(ollie-9p read "session/idx" 2>/dev/null || true) if [[ -z "$idx" ]]; then echo "no active sessions" echo "start one: o / new [cwd]" return fi # session/idx: id \t name \t paused \t connected \t remote \t cwd while IFS=$'\t' read -r _id sname paused _conn remote cwd; do [[ -z "$sname" ]] && continue local tag="" [[ "$paused" == "true" ]] && tag=" (paused)" [[ -n "$remote" ]] && tag="$tag [remote: $remote]" printf '%s%s\t%s\n' "$sname" "$tag" "$cwd" # agent idx: sess-id \t agent-id \t agent-name \t parent-id \t depth \t state local aidx aidx=$(ollie-9p read "session/$sname/agent/idx" 2>/dev/null || true) while IFS=$'\t' read -r _sid _aid aname _pid _depth _state; do [[ -z "$aname" ]] && continue local status status=$(ollie-9p read "session/$sname/agent/$aname/status" 2>/dev/null | head -n1) printf ' %s/%s\t%s\n' "$sname" "$aname" "${status:-?}" done <<< "$aidx" done <<< "$idx" } cmd_help() { cat <<'EOF' usage: o [context] [args...] Context is determined by the first argument: o Root level o Session level o / Agent level o : Agent level (colon separator) Slash is the primary separator. Agent names may contain colons. Use colon separator only when session names contain slashes. Paths starting with / are root-relative (ignore context): o myproj ls /bypass List root bypass/ even in session context Commands (availability depends on context): Root level: overview Show running sessions/agents and their status ls List sessions, models, etc. generate One-shot generation (no session needed) Session level (o ...): ls List session contents (agents, config) ctl Session control (kill, pause, resume) bypass Show pending bypass request details approve [id] Approve pending bypass request deny [id] Deny pending bypass request read/write Access session files Agent level (o / or : ...): new [cwd] Create session (if needed) and agent ls List agent files ctl Agent control (stop, compact, model, etc.) prompt Interactive multi-line prompt REPL log [-n N] Show last N lines of agent log tui Launch tmux layout bypass Show pending bypass request (session-level) approve [id] Approve pending bypass request (session-level) deny [id] Deny pending bypass request (session-level) read/write Access agent files (chat, cfg, etc.) Common: read [-l] Read a file (-l: loop) write [data] Write to a file rdwr [data] Write then read (request-response) env Print export statements for context help Show this help Examples: o ls # list root (sessions, models) o myproj/coding new # create session+agent (cwd=$PWD) o myproj:coding new # same, using colon separator o "my proj:coding" new # session name with space o myproj/coding new ~/src # create with specific cwd o myproj ls # list session contents o myproj/coding prompt # interactive prompt o myproj/coding read chat # read chat stream o myproj bypass # show pending bypass details o myproj approve # approve pending bypass o myproj approve 42 # approve specific request by id o generate "explain monads" # one-shot, no session EOF } # ── Dispatch ──────────────────────────────────────────────────── dispatch_cmd() { local cmd="$1"; shift case "$cmd" in read) cmd_read "$@" ;; write) cmd_write "$@" ;; rdwr) cmd_rdwr "$@" ;; ls) cmd_ls "$@" ;; generate) cmd_generate "$@" ;; ctl) cmd_ctl "$@" ;; prompt) cmd_prompt "$@" ;; log) cmd_log "$@" ;; new) cmd_new "$@" ;; tui) cmd_tui "$@" ;; env) cmd_env "$@" ;; overview) cmd_overview "$@" ;; bypass) cmd_bypass "$@" ;; approve) cmd_approve "$@" ;; deny) cmd_deny "$@" ;; help|-h|--help) cmd_help ;; *) echo "unknown command: $cmd" >&2 echo "run 'o help' for usage" >&2 exit 1 ;; esac } # Parse context from first argument # Supports: # session/agent (slash separator - primary) # session:agent (colon separator - for session names with slashes) # "session name/agent" (quoted with spaces) # # Slash is the primary separator. Use colon only when session names contain slashes. # Agent names may contain colons (e.g., "src:ollie"). parse_context() { local arg="$1" # Slash is the primary separator if [[ "$arg" == */* ]]; then CTX_SESSION="${arg%%/*}" CTX_AGENT="${arg#*/}" elif [[ "$arg" == *:* ]]; then # Colon separator (for session names containing slashes) CTX_SESSION="${arg%%:*}" CTX_AGENT="${arg#*:}" else # session only CTX_SESSION="$arg" CTX_AGENT="" fi # Build prefix if [[ -n "$CTX_AGENT" ]]; then PREFIX="session/$CTX_SESSION/agent/$CTX_AGENT" else PREFIX="session/$CTX_SESSION" fi } # Note: We do NOT re-split arguments here. The previous `set -- $*` was # removed because it breaks quoted arguments with spaces. If callers pass # a single quoted string, they should use eval or proper quoting. # For acme integration, the calling script should handle argument parsing. # Main dispatch if [[ $# -lt 1 ]]; then cmd_overview echo "" echo "run 'o help' for usage" exit 0 fi # Check if first arg is a command (root level) or context if is_command "$1"; then # Root level - no context dispatch_cmd "$@" else # First arg is context (session or session/agent) parse_context "$1" shift if [[ $# -lt 1 ]]; then # No command after context - default to ls cmd_ls else dispatch_cmd "$@" fi fi