human-friendliness: self-describing ctl, structured errors, status, overview

Make the namespace explain itself instead of requiring prior knowledge.

- ctl is self-describing: reading it (empty write) or writing 'help'
  returns the valid verbs with one-line descriptions; an unknown verb
  errors with the valid list. Refactor dispatch to an ordered []ctlCmd
  carrying descriptions; drop the undocumented '.' alias and the
  drift-prone hardcoded help verb. Keep 'i' (drop 'inject') for fast
  injects. o's ctl usage now reads the live listing.
- Errors carry severity + remediation. backend.ClassifyError maps the
  typed errors to transient/config/fatal with a one-line fix; the error
  event renders [[[error:<severity>]]] and a 'remediation:' line so a
  human knows whether to wait or intervene.
- Add a human status file: 'thinking · 12s', 'calling shell · 3s',
  'idle' — distinct from the machine-facing raw state. Wire the TUI bar
  to it.
- Bare 'o' shows an overview of running sessions/agents with status, so
  you don't need to know any names to get oriented.

Tests: dispatch help/unknown/routing, ClassifyError severity table.
This commit is contained in:
Ollie Agent 2026-09-07 13:55:45 +02:00
parent f3933e21e7
commit 42cae9ee2e
12 changed files with 331 additions and 93 deletions

View File

@ -10,6 +10,7 @@ import (
"strings"
"sync"
"sync/atomic"
"time"
"9fans.net/go/plan9"
p9client "9fans.net/go/plan9/client"
@ -41,6 +42,7 @@ type Agent struct {
// Execution state — owned by the agent, protected by stateMu.
state string // "idle", "thinking", "calling: <tool>"
stateSince time.Time // when the current state began
reply string // last assistant response
getCwd func() string // returns session working directory
id string // agent identity (unique principal)

View File

@ -168,7 +168,7 @@ func (ag *Agent) run(ctx context.Context) error {
_, compactErr := ag.autoCompact(ctx)
if compactErr != nil {
ag.emit(Event{Role: "error", Content: compactErr.Error()})
ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: compactErr.Error()})
break
}

View File

@ -50,7 +50,7 @@ func (ag *Agent) trackErrors(ctx context.Context, results []toolResult, es *erro
}
if es.consecutive >= consecutiveErrorHardLimit {
ag.emit(Event{Role: "error", Content: fmt.Sprintf("%d consecutive tool errors — aborting", es.consecutive)})
ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("%d consecutive tool errors — aborting\nremediation: the model is stuck; rephrase the task or intervene", es.consecutive)})
return fmt.Errorf("step %d: %d consecutive tool errors", step, es.consecutive)
}
if es.consecutive == consecutiveErrorSoftLimit {

View File

@ -6,7 +6,13 @@
package agent
import "context"
import (
"context"
"fmt"
"strconv"
"strings"
"time"
)
// WatchField names supported by Agent.WaitChange.
const (
@ -26,6 +32,7 @@ func (ag *Agent) State() string {
func (ag *Agent) SetState(state string) {
ag.stateMu.Lock()
ag.state = state
ag.stateSince = time.Now()
ag.stateMu.Unlock()
ag.notifyChange()
if ag.onStateChange != nil {
@ -33,6 +40,38 @@ func (ag *Agent) SetState(state string) {
}
}
// Status renders a human-readable one-line status: the current state and how
// long it has been in that state, e.g. "thinking · 12s" or "calling shell · 3s".
// The raw state string is machine-facing; this is the human-facing view.
func (ag *Agent) Status() string {
ag.stateMu.RLock()
state := ag.state
since := ag.stateSince
ag.stateMu.RUnlock()
if state == "" {
state = "idle"
}
// "calling: shell" reads better as "calling shell".
state = strings.Replace(state, "calling: ", "calling ", 1)
if state == "idle" || since.IsZero() {
return state
}
return state + " · " + humanDuration(time.Since(since))
}
// humanDuration renders a short elapsed duration: "3s", "2m10s", "1h04m".
func humanDuration(d time.Duration) string {
secs := int(d.Seconds())
if secs < 60 {
return strconv.Itoa(secs) + "s"
}
if secs < 3600 {
return strconv.Itoa(secs/60) + "m" + strconv.Itoa(secs%60) + "s"
}
return strconv.Itoa(secs/3600) + "h" + fmt.Sprintf("%02dm", (secs%3600)/60)
}
// Reply returns the agent's last assistant response.
func (ag *Agent) Reply() string {
ag.stateMu.RLock()

View File

@ -51,7 +51,7 @@ func (ag *Agent) Submit(ctx context.Context, input string) {
a.cancel(fmt.Errorf("%v", r))
}
ag.SetState("idle")
ag.emit(Event{Role: "error", Content: fmt.Sprintf("%v", r)})
ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("%v", r)})
// Re-submit next FIFO item in a new goroutine so queued
// prompts aren't orphaned by the panic.
if next, ok := ag.fifo.Pop(); ok {
@ -122,7 +122,7 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
ti, listErr := ag.runtime.ToolServer.ListTools()
if listErr != nil {
ag.log.Error("tool server unavailable: %v", listErr)
ag.emit(Event{Role: "error", Content: fmt.Sprintf("tool server unavailable: %v", listErr)})
ag.emit(Event{Role: "error", Name: backend.SeverityFatal, Content: fmt.Sprintf("tool server unavailable: %v\nremediation: the tool server process is not responding; kill and recreate the session", listErr)})
ag.SetState("idle")
return ""
}
@ -354,7 +354,12 @@ func (ag *Agent) executeTurn(ctx context.Context, input string) string {
ag.save()
return ""
}
ag.emit(Event{Role: "error", Content: err.Error()})
severity, remediation := backend.ClassifyError(err)
content := err.Error()
if remediation != "" {
content += "\nremediation: " + remediation
}
ag.emit(Event{Role: "error", Name: severity, Content: content})
// Drain one FIFO item.
if next, ok := ag.fifo.Pop(); ok {
return next

View File

@ -7,6 +7,7 @@ import (
"bufio"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -141,6 +142,41 @@ type ToolUnsupportedError struct {
func (e *ToolUnsupportedError) Error() string { return e.Message }
// Error severity levels for the human-facing error surface.
const (
// SeverityTransient: temporary, the runtime is retrying. Wait.
SeverityTransient = "transient"
// SeverityConfig: the user's configuration or request is wrong. Act.
SeverityConfig = "config"
// SeverityFatal: the turn aborted and won't recover on its own.
SeverityFatal = "fatal"
)
// ClassifyError maps an error to a human-facing severity and a one-line
// remediation hint. The remediation is empty when there's nothing useful to
// suggest. Classification keys off the typed backend errors; anything
// unrecognised is fatal with no remediation.
func ClassifyError(err error) (severity, remediation string) {
if err == nil {
return "", ""
}
var rl *RateLimitError
var tr *TransientError
var co *ContextOverflowError
var tu *ToolUnsupportedError
switch {
case errors.As(err, &rl):
return SeverityTransient, "rate limited by the provider; the runtime will retry automatically"
case errors.As(err, &tr):
return SeverityTransient, "temporary provider/network error; the runtime will retry automatically"
case errors.As(err, &co):
return SeverityConfig, "context window exceeded; run 'ctl compact' or start a fresh session"
case errors.As(err, &tu):
return SeverityConfig, "this model does not support tools; switch models or use an agent with no tools"
}
return SeverityFatal, ""
}
// GenerationParams controls sampling behaviour for a single ChatStream call.
// Zero values mean "use the API default".
type GenerationParams struct {

View File

@ -0,0 +1,43 @@
package backend
import (
"errors"
"fmt"
"testing"
)
func TestClassifyError(t *testing.T) {
cases := []struct {
err error
wantSeverity string
wantRemed bool
}{
{nil, "", false},
{&RateLimitError{}, SeverityTransient, true},
{&TransientError{}, SeverityTransient, true},
{&ContextOverflowError{}, SeverityConfig, true},
{&ToolUnsupportedError{}, SeverityConfig, true},
{errors.New("something else"), SeverityFatal, false},
}
for _, c := range cases {
sev, remed := ClassifyError(c.err)
if sev != c.wantSeverity {
t.Errorf("ClassifyError(%v) severity = %q, want %q", c.err, sev, c.wantSeverity)
}
if (remed != "") != c.wantRemed {
t.Errorf("ClassifyError(%v) remediation = %q, wantNonEmpty=%v", c.err, remed, c.wantRemed)
}
}
}
func TestClassifyError_Wrapped(t *testing.T) {
// Classification must see through fmt.Errorf %w wrapping.
err := fmt.Errorf("step 2: %w", &ContextOverflowError{Message: "too long"})
sev, remed := ClassifyError(err)
if sev != SeverityConfig {
t.Errorf("severity = %q, want %q", sev, SeverityConfig)
}
if remed == "" {
t.Error("expected remediation for context overflow")
}
}

View File

@ -0,0 +1,55 @@
package fs
import (
"strings"
"testing"
)
func testCmds() []ctlCmd {
ok := func(_ []string) ([]byte, error) { return []byte("ok\n"), nil }
return []ctlCmd{
{"stop", "interrupt the current turn", ok},
{"compact", "compact context now", ok},
}
}
func TestDispatch_HelpListsCommands(t *testing.T) {
// Empty input and "help" both return the self-describing listing.
for _, in := range []string{"", "help", " "} {
out, err := dispatch(testCmds(), []byte(in))
if err != nil {
t.Fatalf("dispatch(%q) error: %v", in, err)
}
s := string(out)
if !strings.Contains(s, "stop") || !strings.Contains(s, "interrupt the current turn") {
t.Errorf("help listing missing stop/description: %q", s)
}
if !strings.Contains(s, "compact") {
t.Errorf("help listing missing compact: %q", s)
}
}
}
func TestDispatch_UnknownVerbListsValid(t *testing.T) {
_, err := dispatch(testCmds(), []byte("stpo"))
if err == nil {
t.Fatal("expected error for unknown verb")
}
msg := err.Error()
if !strings.Contains(msg, "stpo") {
t.Errorf("error should name the bad verb: %q", msg)
}
if !strings.Contains(msg, "stop") || !strings.Contains(msg, "compact") {
t.Errorf("error should list valid verbs: %q", msg)
}
}
func TestDispatch_RoutesToHandler(t *testing.T) {
out, err := dispatch(testCmds(), []byte("stop"))
if err != nil {
t.Fatalf("dispatch error: %v", err)
}
if string(out) != "ok\n" {
t.Errorf("got %q, want ok", out)
}
}

View File

@ -220,8 +220,8 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
virtfs.FileNode("ctl", 0666,
virtfs.Doc("Server control. Write: 'invalidate', 'kill'"),
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){
"invalidate": func(_ []string) ([]byte, error) {
return dispatch([]ctlCmd{
{"invalidate", "clear the models cache", func(_ []string) ([]byte, error) {
if mc != nil {
mc.Invalidate()
}
@ -229,13 +229,13 @@ func buildTreeSpec(cfg *Config) virtfs.FsNodeDecl {
cfg.Invalidate()
}
return []byte("ok\n"), nil
},
"kill": func(_ []string) ([]byte, error) {
}},
{"kill", "shut down the server", func(_ []string) ([]byte, error) {
if cfg.Shutdown != nil {
cfg.Shutdown()
}
return []byte("ok\n"), nil
},
}},
}, data)
}),
),
@ -478,30 +478,29 @@ func buildSessionChildren(
),
virtfs.FileNode("ctl", 0666,
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){
"kill": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() },
".": func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() },
"save": func(_ []string) ([]byte, error) {
return dispatch([]ctlCmd{
{"kill", "destroy this session", func(_ []string) ([]byte, error) { return []byte("ok\n"), removeFn() }},
{"save", "persist session state to disk", func(_ []string) ([]byte, error) {
s.Save()
return []byte("ok\n"), nil
},
"invalidate": func(_ []string) ([]byte, error) {
}},
{"invalidate", "clear the models cache", func(_ []string) ([]byte, error) {
s.InvalidateModelsCache()
return []byte("ok\n"), nil
},
"pause": func(_ []string) ([]byte, error) {
}},
{"pause", "pause all agents in this session", func(_ []string) ([]byte, error) {
if err := s.Pause(); err != nil {
return nil, err
}
return []byte("ok\n"), nil
},
"resume": func(_ []string) ([]byte, error) {
}},
{"resume", "resume all agents in this session", func(_ []string) ([]byte, error) {
if err := s.Resume(); err != nil {
return nil, err
}
return []byte("ok\n"), nil
},
"run": func(args []string) ([]byte, error) {
}},
{"run", "run a workflow: run [workflow] [variant]", func(args []string) ([]byte, error) {
workflow := s.Workflow()
variant := s.Variant()
if len(args) > 0 {
@ -512,7 +511,7 @@ func buildSessionChildren(
}
runWorkflow(s, workflow, variant)
return []byte("ok\n"), nil
},
}},
}, data)
}),
),
@ -837,6 +836,12 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return []byte(st + "\n"), st, nil
}, a.SignalCh),
),
virtfs.FileNode("status", 0444,
virtfs.Doc("Human-readable status: current activity and elapsed time"),
virtfs.Read(func() ([]byte, error) {
return []byte(a.Status() + "\n"), nil
}),
),
virtfs.FileNode("log", 0444,
virtfs.Read(func() ([]byte, error) {
const maxWindow = 64 * 1024
@ -936,17 +941,17 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}),
virtfs.FileNode("ctl", 0666,
virtfs.Rdwr(func(_ context.Context, data []byte) ([]byte, error) {
return dispatch(map[string]func([]string) ([]byte, error){
"kill": func(_ []string) ([]byte, error) {
return dispatch([]ctlCmd{
{"kill", "destroy this agent", func(_ []string) ([]byte, error) {
s.RemoveAgent(a.ID())
session.PublishEvent("session."+s.ID+".agent."+a.ID()+".kill", "")
return []byte("ok\n"), nil
},
"stop": func(_ []string) ([]byte, error) {
}},
{"stop", "interrupt the current turn", func(_ []string) ([]byte, error) {
a.Interrupt(agent.ErrInterrupted)
return []byte("ok\n"), nil
},
"detach": func(_ []string) ([]byte, error) {
}},
{"detach", "detach the foreground process to background", func(_ []string) ([]byte, error) {
ts := a.ToolServer()
if ts == nil {
return nil, fmt.Errorf("no tool server")
@ -955,30 +960,29 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return nil, fmt.Errorf("no foreground process to detach")
}
return []byte("ok\n"), nil
},
"compact": func(_ []string) ([]byte, error) {
}},
{"compact", "compact context now", func(_ []string) ([]byte, error) {
if err := a.Compact(s.Ctx); err != nil {
return nil, err
}
return []byte("ok\n"), nil
},
"compactionmodel": func(args []string) ([]byte, error) {
}},
{"compactionmodel", "get/set the compaction model", func(args []string) ([]byte, error) {
model := strings.TrimSpace(strings.Join(args, " "))
if model == "" {
return []byte(a.CompactionModel() + "\n"), nil
}
a.SetCompactionModel(model)
return []byte(model + "\n"), nil
},
"clear": func(_ []string) ([]byte, error) {
}},
{"clear", "clear chat history", func(_ []string) ([]byte, error) {
if err := a.Clear(); err != nil {
return nil, err
}
return []byte("ok\n"), nil
},
"inject": inject,
"i": inject,
"agent": func(args []string) ([]byte, error) {
}},
{"i", "inject text into the running turn", inject},
{"agent", "get/switch agent profile: agent [name]", func(args []string) ([]byte, error) {
if len(args) == 0 {
return []byte(a.Profile() + "\n"), nil
}
@ -992,8 +996,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
session.LoadTools(cfg, ts, s.ID, a.ID(), nil)
}
return []byte(args[0] + "\n"), nil
},
"model": func(args []string) ([]byte, error) {
}},
{"model", "get/set model: model [name]", func(args []string) ([]byte, error) {
if len(args) == 0 {
if be := a.Backend(); be != nil {
return []byte(be.Model() + "\n"), nil
@ -1006,11 +1010,11 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return []byte(modelPricingBlock(be, name)), nil
}
return []byte(name + "\n"), nil
},
"models": func(_ []string) ([]byte, error) {
}},
{"models", "list available models", func(_ []string) ([]byte, error) {
return []byte(s.CachedListModels() + "\n"), nil
},
"tools": func(_ []string) ([]byte, error) {
}},
{"tools", "list loaded tools", func(_ []string) ([]byte, error) {
ts := a.ToolServer()
if ts == nil {
return []byte("(no tool server)\n"), nil
@ -1028,8 +1032,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}
}
return []byte(sb.String()), nil
},
"tool_load": func(args []string) ([]byte, error) {
}},
{"tool_load", "load a tool: tool_load <name>", func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("tool_load requires a tool name")
}
@ -1044,15 +1048,15 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
a.SetToolsPreamble(agent.RenderTools(infos))
}
return []byte(args[0] + "\n"), nil
},
"tools_all": func(args []string) ([]byte, error) {
}},
{"tools_all", "list all available tools", func(args []string) ([]byte, error) {
ts := a.ToolServer()
if ts == nil {
return nil, fmt.Errorf("no tool server")
}
return ts.ListAllTools()
},
"tool_unload": func(args []string) ([]byte, error) {
}},
{"tool_unload", "unload a tool: tool_unload <name>", func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("tool_unload requires a tool name")
}
@ -1067,8 +1071,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
a.SetToolsPreamble(agent.RenderTools(infos))
}
return []byte(args[0] + "\n"), nil
},
"backend": func(args []string) ([]byte, error) {
}},
{"backend", "get/set backend: backend [name]", func(args []string) ([]byte, error) {
if len(args) == 0 {
if be := a.Backend(); be != nil {
return []byte(be.Name() + "\n"), nil
@ -1080,18 +1084,18 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
return nil, err
}
return []byte(name + "\n"), nil
},
"name": func(args []string) ([]byte, error) {
}},
{"name", "get/set agent name: name [value]", func(args []string) ([]byte, error) {
if len(args) == 0 {
return []byte(a.Name() + "\n"), nil
}
a.SetName(strings.Join(args, " "))
return []byte(strings.Join(args, " ") + "\n"), nil
},
"cwd": func(args []string) ([]byte, error) {
}},
{"cwd", "print the working directory", func(args []string) ([]byte, error) {
return []byte(a.Cwd() + "\n"), nil
},
"proc": func(args []string) ([]byte, error) {
}},
{"proc", "manage background procs: proc [top|term|kill|out|dismiss <pid>]", func(args []string) ([]byte, error) {
ts := a.ToolServer()
if ts == nil {
return nil, fmt.Errorf("no tool server")
@ -1160,11 +1164,11 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
default:
return nil, fmt.Errorf("unknown proc subcommand: %s (use: top, term <pid>, kill <pid>, out <pid>, dismiss <pid>)", subcmd)
}
},
"systemprompt": func(_ []string) ([]byte, error) {
}},
{"systemprompt", "print the rendered system prompt", func(_ []string) ([]byte, error) {
return []byte(a.SystemPrompt() + "\n"), nil
},
"peeradd": func(args []string) ([]byte, error) {
}},
{"peeradd", "link a peer agent: peeradd <name>", func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("peeradd requires agent name")
}
@ -1176,8 +1180,8 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
target.AddPeer(a.Name())
go s.Save()
return []byte("ok\n"), nil
},
"peerdel": func(args []string) ([]byte, error) {
}},
{"peerdel", "unlink a peer agent: peerdel <name>", func(args []string) ([]byte, error) {
if len(args) == 0 {
return nil, fmt.Errorf("peerdel requires agent name")
}
@ -1187,18 +1191,15 @@ func buildAgentChildren(a *agent.Agent, s *session.Session) []virtfs.FsNodeDecl
}
go s.Save()
return []byte("ok\n"), nil
},
"peers": func(_ []string) ([]byte, error) {
}},
{"peers", "list linked peer agents", func(_ []string) ([]byte, error) {
peers := a.Peers()
if len(peers) == 0 {
return []byte("(no peers)\n"), nil
}
return []byte(strings.Join(peers, "\n") + "\n"), nil
},
"stats": stats,
"help": func(_ []string) ([]byte, error) {
return []byte("stop detach compact clear inject agent model models tools tool_load tool_unload cwd proc name backend systemprompt peeradd peerdel peers stats help\n"), nil
},
}},
{"stats", "print usage and cost statistics", stats},
}, data)
}),
),

View File

@ -22,11 +22,25 @@ func wireAgentEvents(sessID string, ag *agent.Agent) {
})
}
// dispatch routes a command to the matching handler based on the first word.
func dispatch(handlers map[string]func([]string) ([]byte, error), data []byte) ([]byte, error) {
// ctlCmd is one control verb: its name, a one-line description for the
// self-describing help listing, and its handler.
type ctlCmd struct {
name string
desc string
fn func([]string) ([]byte, error)
}
// dispatch routes a ctl write to a command by its first word. Reading the ctl
// file (empty write) or writing "help" returns the command listing. An unknown
// verb returns an error listing the valid verbs.
func dispatch(cmds []ctlCmd, data []byte) ([]byte, error) {
input := strings.TrimSpace(string(data))
if input == "" {
return nil, nil
if input == "" || input == "help" {
var sb strings.Builder
for _, c := range cmds {
fmt.Fprintf(&sb, "%-16s %s\n", c.name, c.desc)
}
return []byte(sb.String()), nil
}
parts := strings.Fields(input)
command := parts[0]
@ -35,11 +49,16 @@ func dispatch(handlers map[string]func([]string) ([]byte, error), data []byte) (
command = name
args = append([]string{value}, args...)
}
h, ok := handlers[command]
if !ok {
return nil, fmt.Errorf("unknown ctl command: %s", command)
for _, c := range cmds {
if c.name == command {
return c.fn(args)
}
}
return h(args)
names := make([]string, len(cmds))
for i, c := range cmds {
names[i] = c.name
}
return nil, fmt.Errorf("unknown ctl command %q; valid: %s", command, strings.Join(names, ", "))
}
// stripMarkers removes [[[...]]] marker lines, source fence delimiter lines,

View File

@ -41,7 +41,7 @@ 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 help"
COMMANDS="read write rdwr ls new ctl prompt log tui env generate overview help"
is_command() {
local arg="$1"
@ -148,14 +148,14 @@ cmd_generate() {
cmd_ctl() {
if [[ $# -lt 1 ]]; then
echo "usage: o ctl <command> [args...]" >&2
if [[ -n "$CTX_AGENT" ]]; then
echo " Agent: stop, compact, clear, inject, agent, model, cwd, name" >&2
elif [[ -n "$CTX_SESSION" ]]; then
echo " Session: kill, pause, resume" >&2
else
echo " (requires session or agent context)" >&2
if [[ -z "$PREFIX" ]]; then
echo "usage: o <session> ctl <cmd> (or o <session>/<agent> ctl <cmd>)" >&2
exit 1
fi
# The ctl file is self-describing: an empty request returns the
# valid commands with descriptions.
echo "usage: o ctl <command> [args...]" >&2
printf '' | ollie-9p rdwr "${PREFIX}/ctl" >&2
exit 1
fi
@ -191,7 +191,7 @@ cmd_prompt() {
# 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, /inject X"
echo "/ prefix runs ctl: /stop, /compact, /model X, /i X (type /help for all)"
echo "!q exits the TUI."
echo ""
@ -330,7 +330,8 @@ cmd_tui() {
*) 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
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
done &
local bg_pid=$!
@ -346,6 +347,35 @@ cmd_env() {
[[ -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 <session>/<agent> 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] <command> [args...]
@ -363,6 +393,7 @@ Paths starting with / are root-relative (ignore context):
Commands (availability depends on context):
Root level:
overview Show running sessions/agents and their status
ls List sessions, models, etc.
generate <prompt> One-shot generation (no session needed)
@ -415,6 +446,7 @@ dispatch_cmd() {
new) cmd_new "$@" ;;
tui) cmd_tui "$@" ;;
env) cmd_env "$@" ;;
overview) cmd_overview "$@" ;;
help|-h|--help) cmd_help ;;
*)
echo "unknown command: $cmd" >&2
@ -445,7 +477,9 @@ set -- $*
# Main dispatch
if [[ $# -lt 1 ]]; then
cmd_help
cmd_overview
echo ""
echo "run 'o help' for usage"
exit 0
fi

View File

@ -25,7 +25,11 @@ func FormatEvent(role, name, content, outputFormat string) []byte {
}
return []byte(ToolDelim(name) + fence + "\n" + strings.TrimRight(content, "\n") + "\n```\n" + EndTag + "\n")
case "error":
return []byte(RoleDelim("error") + "\n" + content + "\n" + EndTag + "\n")
role := "error"
if name != "" {
role = "error:" + name
}
return []byte(RoleDelim(role) + "\n" + content + "\n" + EndTag + "\n")
case "info":
return []byte(RoleDelim("info") + "\n" + content + "\n" + EndTag + "\n")
case "retry":