Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,13 +205,15 @@ command and press `⏎`.
| `/model [name]` | Switch model (opens a picker with no argument) |
| `/thinking [on\|off]` | Toggle extended thinking for the next turn |
| `/cancel` | Cancel the running turn |
| `/stop <SA#>` | Stop one running sub-agent (bare `/stop` lists them) |
| `/agents` | Sub-agent registry — recent delegated tasks (drawer tab) |
| `/attach <path>` | Stage a file to send with the next prompt (5 MB each, 10 MB total) |
| `/unattach [name]` | Drop staged files (all when no name given) |
| `/quit` | Exit bodek |

### The management drawer

`/sessions`, `/runs`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and
`/sessions`, `/runs`, `/agents`, `/events`, `/plan`, `/memory`, `/skills`, `/tools`, and
`/config` all open tabs of **one drawer** with a shared grammar:

- `]` / `[` cycle tabs · `1`–`8` jump · `r` refresh · `esc` closes.
Expand Down Expand Up @@ -303,6 +305,11 @@ collect an approval for a prompt the engine already abandoned.
drives live cards — step, tool, iterations, tokens, duration — and terminal
status glyphs (`✓` success, `◐` partial, `✗` error, `⊘` cancelled, `⏱`
timeout), with a `1/2 agents · 6.3k tok` rollup on the collapsed line.
Framed delegate results render as a structured card — status, summary,
changed files, and usage — while prose results keep the generic preview.
`ctrl+s` (or `/stop <SA#>`, two-step confirmed) stops one running
sub-agent, and the `/agents` drawer tab lists the serve instance's
registry snapshot — recent delegated tasks with goal, status, and usage.
- **Security approvals** — odek's `danger` engine prompts surface as an inline
panel; your answer is sent straight back over the socket.
- **Live reasoning** — the model's pre-tool thinking streams in dimmed text,
Expand Down
22 changes: 19 additions & 3 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,11 @@ type Event struct {
TimeoutSeconds int `json:"timeout_seconds,omitempty"`

// approval_ack (Action echoes the client's reply) / cancelled (Idle is
// true when nothing was running for the target session).
Action string `json:"action"`
Idle bool `json:"idle"`
// true when nothing was running for the target session) /
// subagent_cancelled (Accepted:false is a benign race — task already done).
Action string `json:"action"`
Idle bool `json:"idle"`
Accepted bool `json:"accepted"`

// server_info / pong snapshot. T is the pong timestamp (unix ms); the
// client measures round-trip latency from its own send clock instead.
Expand Down Expand Up @@ -267,6 +269,20 @@ func (c *Client) SendCancel(sessionID, authToken string) error {
}{Type: "cancel", SessionID: sessionID, AuthToken: authToken})
}

// SendSubagentCancel stops ONE running sub-agent by task id over the
// WebSocket (handled inline by the socket reader, so it works while
// delegate_tasks occupies the prompt processor; session-scoped auth). The
// subagent_cancelled ack replies; the card's terminal state arrives as a
// subagent_state transition.
func (c *Client) SendSubagentCancel(sessionID, authToken, taskID string) error {
return c.send(struct {
Type string `json:"type"`
SessionID string `json:"session_id"`
AuthToken string `json:"auth_token,omitempty"`
TaskID string `json:"task_id"`
}{Type: "subagent_cancel", SessionID: sessionID, AuthToken: authToken, TaskID: taskID})
}

// SessionSwitch adopts an existing session without sending a prompt: the
// connection's agent restores the session's memory buffer and the server
// emits the standard `session` event.
Expand Down
35 changes: 35 additions & 0 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -300,6 +300,41 @@ func (c *Client) Cancel(sessionID, token string) error {

// ── low-level helpers ────────────────────────────────────────────────────────

// SubagentEntry is one delegated task's lifecycle record from the serve
// registry snapshot (GET /api/subagents).
type SubagentEntry struct {
TaskID string `json:"task_id"`
RunKey string `json:"run_key"`
Goal string `json:"goal,omitempty"`
Status string `json:"status,omitempty"`
Phase string `json:"phase"`
PID int `json:"pid,omitempty"`
StartedAt time.Time `json:"started_at"`
FinishedAt time.Time `json:"finished_at,omitempty"`
Iterations int `json:"iterations,omitempty"`
Step int `json:"step,omitempty"`
LastTool string `json:"last_tool,omitempty"`
DurationSeconds float64 `json:"duration_seconds,omitempty"`
TokensUsed int `json:"tokens_used,omitempty"`
}

// Subagents fetches the sub-agent registry snapshot, optionally filtered by
// run key. Auth mirrors the other instance-level GETs.
func (c *Client) Subagents(runKey string) ([]SubagentEntry, error) {
u := c.baseURL + "/api/subagents"
if runKey != "" {
u += "?key=" + url.QueryEscape(runKey)
}
var out struct {
Entries []SubagentEntry `json:"entries"`
Count int `json:"count"`
}
if err := c.getJSON(u, "", &out); err != nil {
return nil, err
}
return out.Entries, nil
}

func (c *Client) do(method, u, sessionToken string) (*http.Response, error) {
req, err := http.NewRequest(method, u, nil)
if err != nil {
Expand Down
61 changes: 61 additions & 0 deletions internal/client/subagent_cancel_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package client

import (
"encoding/json"
"net/http"
"sync"
"testing"
"time"

ws "golang.org/x/net/websocket"
)

// TestSendSubagentCancelFrame pins the wire shape of the per-agent stop:
// session-scoped auth plus the task id, mirroring the cancel frame.
func TestSendSubagentCancelFrame(t *testing.T) {
var mu sync.Mutex
var frames []map[string]any
mux := http.NewServeMux()
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {
for {
var data []byte
if err := ws.Message.Receive(c, &data); err != nil {
return
}
var m map[string]any
if err := json.Unmarshal(data, &m); err != nil {
continue
}
mu.Lock()
frames = append(frames, m)
mu.Unlock()
}
}))
cl, _ := newTestServer(t, mux)

if err := cl.SendSubagentCancel("s1", "a1", "task-uuid"); err != nil {
t.Fatalf("SendSubagentCancel: %v", err)
}

deadline := time.After(3 * time.Second)
for {
mu.Lock()
n := len(frames)
mu.Unlock()
if n >= 1 {
break
}
select {
case <-deadline:
t.Fatal("no frame arrived")
case <-time.After(20 * time.Millisecond):
}
}

mu.Lock()
defer mu.Unlock()
f := frames[0]
if f["type"] != "subagent_cancel" || f["session_id"] != "s1" || f["auth_token"] != "a1" || f["task_id"] != "task-uuid" {
t.Errorf("subagent_cancel frame = %v", f)
}
}
51 changes: 51 additions & 0 deletions internal/client/subagents_rest_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
package client

import (
"encoding/json"
"net/http"
"testing"

ws "golang.org/x/net/websocket"
)

// TestSubagentsREST pins GET /api/subagents decoding and the ?key= filter.
func TestSubagentsREST(t *testing.T) {
var gotKey string
mux := http.NewServeMux()
mux.Handle("/ws", ws.Handler(func(c *ws.Conn) {
buf := make([]byte, 512)
for {
if _, err := c.Read(buf); err != nil {
return
}
}
}))
mux.HandleFunc("/api/subagents", func(w http.ResponseWriter, r *http.Request) {
gotKey = r.URL.Query().Get("key")
_ = json.NewEncoder(w).Encode(map[string]any{
"entries": []map[string]any{
{
"task_id": "t1", "run_key": "rk1", "goal": "explore the repo",
"phase": "finished", "status": "success",
"iterations": 3, "tokens_used": 1500, "duration_seconds": 4.2,
},
},
"count": 1,
})
})
cl, _ := newTestServer(t, mux)

entries, err := cl.Subagents("")
if err != nil {
t.Fatalf("Subagents: %v", err)
}
if len(entries) != 1 || entries[0].TaskID != "t1" || entries[0].Goal != "explore the repo" || entries[0].TokensUsed != 1500 {
t.Fatalf("entries = %+v", entries)
}
if _, err := cl.Subagents("rk1"); err != nil {
t.Fatalf("Subagents(key): %v", err)
}
if gotKey != "rk1" {
t.Errorf("key filter not sent, got %q", gotKey)
}
}
6 changes: 6 additions & 0 deletions internal/tui/commands.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,12 @@ func slashCommands() []command {
{"cancel", "cancel the running turn", func(m *Model, _ string) tea.Cmd {
return m.cancelRun()
}},
{"stop", "stop one sub-agent — /stop <SA#>", func(m *Model, args string) tea.Cmd {
return m.stopByLabel(args)
}},
{"agents", "sub-agent registry — recent delegated tasks", func(m *Model, _ string) tea.Cmd {
return m.openAgents()
}},
{"attach", "stage a file for the next prompt — /attach <path>", func(m *Model, args string) tea.Cmd {
return m.attachFile(args)
}},
Expand Down
13 changes: 13 additions & 0 deletions internal/tui/commands_e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,19 @@ func TestE2EAllCommands(t *testing.T) {
t.Fatal("/cancel did not clear the busy state")
}
},
"/stop": func(t *testing.T, m *Model) {
if m.confirm != confirmNone {
t.Fatal("/stop without live agents armed a gate")
}
if !notePresent(m, "no running sub-agents") {
t.Errorf("/stop note missing: %v", m.notices)
}
},
"/agents": func(t *testing.T, m *Model) {
if m.panel != panelAgents {
t.Fatalf("/agents opened panel %d", m.panel)
}
},
"/attach": func(t *testing.T, m *Model) {
if len(m.attachments) != 1 || m.attachments[0].Name != "notes.txt" {
t.Fatalf("/attach staged = %+v", m.attachments)
Expand Down
26 changes: 26 additions & 0 deletions internal/tui/drawer.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,31 @@ func (m *Model) handleRunStarted(msg runStartedMsg) tea.Cmd {
return tea.Batch(note, m.openRuns())
}

// ── agents tab ──────────────────────────────────────────────────────────────

// openAgents opens the sub-agent registry tab (GET /api/subagents snapshot).
func (m *Model) openAgents() tea.Cmd {
m.panel = panelAgents
m.panelSel = 0
m.panelEdit = panelEditNone
m.panelMsg = "loading sub-agents…"
m.relayout()
m.refresh()
return m.fetchAgents()
}

// fetchAgents refetches the registry snapshot; r re-runs it while open.
func (m *Model) fetchAgents() tea.Cmd {
if m.cl == nil {
return nil
}
cl := m.cl
return func() tea.Msg {
entries, err := cl.Subagents("")
return mgmtMsg{tab: panelAgents, sag: entries, err: err}
}
}

// drawerTab is one tab of the management drawer.
type drawerTab struct {
name string
Expand All @@ -112,6 +137,7 @@ func drawerTabs() []drawerTab {
return []drawerTab{
{"sessions", panelSessions, func(m *Model) tea.Cmd { return m.openSessions() }},
{"runs", panelRuns, func(m *Model) tea.Cmd { return m.openRuns() }},
{"agents", panelAgents, func(m *Model) tea.Cmd { return m.openAgents() }},
{"events", panelEvents, func(m *Model) tea.Cmd { return m.openEvents() }},
{"plan", panelPlan, func(m *Model) tea.Cmd { return m.openPlan() }},
{"memory", panelMemory, func(m *Model) tea.Cmd { return m.openMemory() }},
Expand Down
17 changes: 9 additions & 8 deletions internal/tui/drawer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,16 +71,16 @@ func TestDrawerEventsTab(t *testing.T) {
}
}

// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL seven
// TestDrawerTabCycling verifies ]/[ and digit jumps move between ALL nine
// drawer tabs (management panels included — they are full tabs, not loose
// overlays) and esc closes from any of them.
func TestDrawerTabCycling(t *testing.T) {
m := wired(t)
m.Update(exec(m.openRuns()))

// ] walks the full ring: runs → eventsplanmemoryskills
// tools → config → sessions.
want := []panelMode{panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions}
// ] walks the full ring: runs → agentseventsplanmemory
// skills → tools → config → sessions.
want := []panelMode{panelAgents, panelEvents, panelPlan, panelMemory, panelSkills, panelTools, panelConfig, panelSessions}
for _, w := range want {
_, cmd := m.Update(key("]"))
m.Update(exec(cmd))
Expand All @@ -96,8 +96,9 @@ func TestDrawerTabCycling(t *testing.T) {
}
// Digits jump straight to any tab.
for d, w := range map[string]panelMode{
"1": panelSessions, "2": panelRuns, "3": panelEvents, "4": panelPlan,
"5": panelMemory, "6": panelSkills, "7": panelTools, "8": panelConfig,
"1": panelSessions, "2": panelRuns, "3": panelAgents, "4": panelEvents,
"5": panelPlan, "6": panelMemory, "7": panelSkills, "8": panelTools,
"9": panelConfig,
} {
_, cmd := m.Update(key(d))
m.Update(exec(cmd))
Expand All @@ -108,13 +109,13 @@ func TestDrawerTabCycling(t *testing.T) {
// The strip renders every tab name, and r refreshes a management tab
// the same as a core tab (they are drawer tabs now).
out := plain(m.View())
for _, name := range []string{"sessions", "runs", "events", "plan", "memory", "skills", "tools", "config"} {
for _, name := range []string{"sessions", "runs", "agents", "events", "plan", "memory", "skills", "tools", "config"} {
if !strings.Contains(out, name) {
t.Errorf("tab strip missing %q:\n%s", name, out)
}
}
m.Update(exec(m.fetchSessionsPage("", 0, false)))
_, cmd = m.Update(key("5")) // memory tab (plan is 4 since its insertion)
_, cmd = m.Update(key("6")) // memory tab (agents shifted the digits)
m.Update(exec(cmd))
_, cmd = m.Update(key("r"))
if cmd == nil {
Expand Down
11 changes: 11 additions & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,9 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
steps[j].done = true
steps[j].result = resultPreview(ev.Data)
steps[j].isErr = looksLikeError(steps[j].result)
if steps[j].subagent {
steps[j].resultCard = parseAgentResult(ev.Data)
}
if !steps[j].started.IsZero() {
steps[j].dur = time.Since(steps[j].started)
}
Expand Down Expand Up @@ -308,6 +311,14 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
}
m.addTransientNote("subagent · " + stateNoticeLine(ev))

case "subagent_cancelled":
// Stop ack. accepted:false is a benign race — the task already
// finished. The card's terminal state comes exclusively from the
// subagent_state frame; the ack never flips UI state.
if !ev.Accepted {
m.addTransientNote("stop declined · sub-agent already finished")
}

case client.EventDisconnected:
m.disconn = true
m.busy = false
Expand Down
Loading