pi-cockpit 0.5.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,107 +1,130 @@
1
- # pi-cockpit
2
-
3
- A list-mode **agent cockpit** for [Pi](https://pi.dev): a status stack pinned **above the editor** showing live teammates and the current todo plan, plus a Starship-style footer. Everything is drawn through Pi's public extension APIs only (`setWidget` / `setFooter`) — no core patches.
4
-
5
- It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). Since `pi-maestro-flow@0.6.1` it is an exact-pinned dependency of `pi-maestro-flow` — installed together and auto-registered into Pi's `settings.packages` on postinstall — but it also installs and runs standalone. Current version: **0.4.0**.
6
-
7
- ```
8
- ┌─ AGENTS · 2 running ─────────────────────────────┐ ← setWidget(aboveEditor)
9
- │ ⠋ explorer #a1f3c2 map auth read routes.ts │
10
- │ ⠋ explorer #b9e014 trace jwt read jwt.ts │
11
- ├─ TODO · 1/4 ─────────────────────────────────────┤
12
- │ 01 ✓ map auth entrypoints │
13
- │ 02 ⠋ trace jwt verify │
14
- │ 03 · implement refresh │
15
- │ 04 · add tests │
16
- └──────────────────────────────────────────────────┘
17
- > dispatch a goal… ← editor (Pi native)
18
- pi/stream-70b · ctx [████░░░░] 42% · $0.52 · 01:23 ← setFooter
19
- ```
20
-
21
- ## Install
22
-
23
- `pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow` pulls `pi-cockpit` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
24
-
25
- ```bash
26
- pi install npm:pi-cockpit # standalone from npm
27
- # or, for one run:
28
- pi -e ./packages/pi-cockpit
29
- ```
30
-
31
- ## What it shows
32
-
33
- - **AGENTS** — every running teammate as a table row (status spinner · role · id · label · live tail), sorted running-first. Completed/failed agents show their total duration. Collapses to a one-line `N agents running` summary in compact mode.
34
- - **TODO** — the active plan as numbered rows with four states (done ✓ / in-progress spinner / blocked ! / pending ·). Collapses to a segmented progress bar + current step + percent.
35
- - **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`. `bash_bg` background-job state lives on a **dedicated second footer row** so it no longer competes with the primary line.
36
- - **Thinking timer** — while the model is thinking, the folded thinking row shows a spinner and running elapsed time; when the run ends, it settles to the actual duration (e.g. `thoughts · 8.4s`).
37
- - **Quiet mode** — compresses the seven built-in tool calls (read/bash/edit/write/grep/find/ls) into single-line ✓/✗/⋯ summaries and folds thinking blocks. Two glyph sets available: `check` (✓/✗/⋯) and `dot` (●/○/◌). Toggle with `/cockpit quiet`; turning off requires `/reload` to restore native tool renderers.
38
-
39
- Toggle each block between list and compact with `/cockpit`.
40
-
41
- ## Data sources (and the dependency this implies)
42
-
43
- | Block | Source | Available on bare Pi? |
44
- |-------|--------|------------------------|
45
- | AGENTS | `pi.events` channels `teammate:started` / `teammate:message` / `teammate:complete`, broadcast by **pi-maestro-teammate** | **No** — without that extension no events fire, the block stays hidden |
46
- | TODO | the `todo-state` snapshot the **pi-maestro-flow** `todo` tool persists after every mutation (re-read on each `tool_execution_end` and on `session_start`) | **No** — without the `todo` tool the block stays hidden |
47
- | Footer | `ctx.model`, `ctx.getContextUsage()`, session usage totals, `footerData.getGitBranch()` | **Yes** |
48
-
49
- So on a stock Pi (no teammate / no todo tool) the extension loads without error, the status stack renders nothing, and only the footer appears. The roster is **self-accumulated from event deltas** — teammates already running before the extension loads are not back-filled (the teammate extension broadcasts deltas, never a full roster). The todo list **is** back-filled on `session_start` from the persisted snapshot.
50
-
51
- This is the whole coupling story: cockpit has **no package dependency on `pi-maestro-flow`** and only a peer dependency on `pi-maestro-teammate`. It observes the other two plugins through public channels alone — teammate's event broadcasts for AGENTS, and the `todo-state` snapshot flow's `todo` tool persists for TODO. Remove either source and the matching block simply hides; the footer always remains.
52
-
53
- ## Configuration
54
-
55
- `~/.pi/agent/cockpit.json` (created on first run):
56
-
57
- ```json
58
- {
59
- "enabled": true,
60
- "quietMode": false,
61
- "quietSymbols": "check",
62
- "agentsMode": "list",
63
- "todoMode": "list",
64
- "todoExpanded": false,
65
- "hideNativeAgents": true,
66
- "icons": { "mode": "auto" },
67
- "theme": ""
68
- }
69
- ```
70
-
71
- - `quietMode`: when `true`, compresses built-in tool rendering and folds thinking blocks.
72
- - `quietSymbols`: `"check"` (✓/✗/⋯) or `"dot"` (●/○/◌) lifecycle glyphs for quiet tool rows.
73
- - `agentsMode` / `todoMode`: `"list"` or `"compact"`.
74
- - `todoExpanded`: when `true`, expands the todo widget by default.
75
- - `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. On by default.
76
- - `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
77
- - `theme`: named theme override; empty string follows the Pi session theme.
78
-
79
- ## Commands
80
-
81
- - `/cockpit` — opens an overlay to toggle `enabled`, `agentsMode`, `todoMode`, `quietMode`, and `hideNativeAgents`. `/cockpit quiet` toggles quiet mode directly; `/cockpit bg` shows background jobs.
82
- - `/theme` — switch theme with live preview; `/theme <name>` applies directly. Pi ships no standalone theme command; cockpit provides one.
83
-
84
- ## Terminal feasibility — what this design deliberately does NOT do
85
-
86
- The original mockup was a browser page; a terminal is an ANSI stream with no DOM, no CSS, no focus, no hover. Four mockup effects are **out of scope** here, with replacements:
87
-
88
- | Mockup effect | Why it can't work in a TUI | Replacement |
89
- |---------------|----------------------------|-------------|
90
- | Input-focus "power-up" glow (`:has(:focus)`) | no focus pseudo-class; `render(width)` can't see focus | the stack's header dot turns accent-green while an agent is running |
91
- | Hover-to-expand chips/rows | no hover in a terminal | list mode shows everything; compact mode is one line; `/cockpit` toggles |
92
- | Scanlines / logo light-up animation | no overlay/animation layer | a braille spinner frame, advanced on each redraw |
93
- | In-stream thinking-collapse / edit progress bar / colored bash stdout | built-in message & built-in tool rendering is **not** replaceable by extensions (`renderCall`/`renderResult` only apply to tools *you* register) | the conversation stream is left to Pi's native renderer — it is context, not a cockpit deliverable |
94
-
95
- ## Local development
96
-
97
- ```bash
98
- cd packages/pi-cockpit
99
- node --test --experimental-transform-types tests/agents-store.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts
100
- ../../node_modules/.bin/tsc -p tsconfig.json # type-check (uses the monorepo root's tsc + types)
101
- ```
102
-
103
- The package lives inside the `pi-maestro-flow` monorepo under `packages/` so it resolves `@earendil-works/*` types from the root `node_modules`. It is also an exact-pinned dependency of `pi-maestro-flow` and ships as its own npm package (`pi-cockpit`).
104
-
105
- ## License
106
-
107
- MIT
1
+ # pi-cockpit
2
+
3
+ A responsive **agent cockpit** for [Pi](https://pi.dev): wide terminals get a docked Maestro operations sidebar, narrow terminals automatically fall back to the existing Todo and Agent widgets, and every layout keeps the Starship-style footer.
4
+
5
+ It is the third plugin of the `pi-maestro-flow` project (alongside `pi-maestro-flow` and `pi-maestro-teammate`). It is installed and registered with `pi-maestro-flow`, but it also runs standalone. Current version: **0.5.1**.
6
+
7
+ The dock is a non-capturing top-right overlay. Cockpit reserves its columns by wrapping the active TUI renderer at runtime, so the Pi workspace reflows instead of rendering underneath it. No Pi source files are modified.
8
+
9
+ ```
10
+ ┌─ AGENTS · 2 running ─────────────────────────────┐ ← setWidget(aboveEditor)
11
+ │ ⠋ explorer #a1f3c2 map auth read routes.ts │
12
+ │ ⠋ explorer #b9e014 trace jwt read jwt.ts │
13
+ ├─ TODO · 1/4 ─────────────────────────────────────┤
14
+ │ 01 ✓ map auth entrypoints │
15
+ │ 02 ⠋ trace jwt verify │
16
+ │ 03 · implement refresh │
17
+ │ 04 · add tests │
18
+ └──────────────────────────────────────────────────┘
19
+ > dispatch a goal… ← editor (Pi native)
20
+ pi/stream-70b · ctx [████░░░░] 42% · $0.52 · 01:23 ← setFooter
21
+ ```
22
+
23
+ ## Install
24
+
25
+ `pi-cockpit` comes automatically with the orchestration layer — installing `pi-maestro-flow` pulls `pi-cockpit` and registers it into `settings.packages` on postinstall, no manual setup required. To use it on its own:
26
+
27
+ ```bash
28
+ pi install npm:pi-cockpit # standalone from npm
29
+ # or, for one run:
30
+ pi -e ./packages/pi-cockpit
31
+ ```
32
+
33
+ ## What it shows
34
+
35
+ - **SIDEBAR** — Workflow Session/Run progress, Goal state and budget, Todo tasks, Teammate roster, background jobs, and read-only Team Swarm progress. Empty sections disappear and constrained heights retain current or failed work before secondary detail.
36
+ - **AGENTS** — every running teammate as a table row (status spinner · role · id · label · live tail), sorted running-first. Completed/failed agents show their total duration. On narrow terminals this returns to the below-editor widget.
37
+ - **TODO** — the active plan as numbered rows with four states (done ✓ / in-progress spinner / blocked ! / pending ·). On narrow terminals this returns to the above-editor widget.
38
+ - **Footer** — `provider/model · context gauge · ↑in ↓out · $cost · elapsed · git branch`. `bash_bg` background-job state lives on a **dedicated second footer row** so it no longer competes with the primary line.
39
+ - **Thinking timer** — while the model is thinking, the folded thinking row shows a spinner and running elapsed time; when the run ends, it settles to the actual duration (e.g. `thoughts · 8.4s`).
40
+ - **Quiet mode** — compresses the seven built-in tool calls (read/bash/edit/write/grep/find/ls) into single-line ✓/✗/⋯ summaries and folds thinking blocks. Two glyph sets available: `check` (✓/✗/⋯) and `dot` (●/○/◌). Toggle with `/cockpit quiet`; turning off requires `/reload` to restore native tool renderers.
41
+
42
+ Toggle each block between list and compact with `/cockpit`.
43
+
44
+ ## Data sources (and the dependency this implies)
45
+
46
+ | Block | Source | Available on bare Pi? |
47
+ |-------|--------|------------------------|
48
+ | MAESTRO | Versioned `cockpit:maestro-query` / `maestro:ui-snapshot` full snapshots from **pi-maestro-flow** | **No** — Workflow, Goal, and Swarm sections stay hidden |
49
+ | AGENTS | `pi.events` channels `teammate:started` / `teammate:message` / `teammate:complete`, broadcast by **pi-maestro-teammate** | **No** — without that extension no events fire, the block stays hidden |
50
+ | TODO | the `todo-state` snapshot the **pi-maestro-flow** `todo` tool persists after every mutation (re-read on each `tool_execution_end` and on `session_start`) | **No** — without the `todo` tool the block stays hidden |
51
+ | Footer | `ctx.model`, `ctx.getContextUsage()`, session usage totals, `footerData.getGitBranch()` | **Yes** |
52
+
53
+ So on a stock Pi the extension loads without error, the Maestro sections stay empty, and the footer remains available. The roster is **self-accumulated from event deltas**; Todo is back-filled from the latest durable `todo-state`. Workflow, Goal, and Swarm use a versioned full-replacement snapshot with generation fencing and a query path for cold-start recovery.
54
+
55
+ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional producers through public event contracts; removing a producer hides only the matching section.
56
+
57
+ ## Configuration
58
+
59
+ `~/.pi/agent/cockpit.json` (created on first run):
60
+
61
+ ```json
62
+ {
63
+ "enabled": true,
64
+ "quietMode": false,
65
+ "quietSymbols": "check",
66
+ "agentsMode": "list",
67
+ "todoMode": "list",
68
+ "todoExpanded": false,
69
+ "hideNativeAgents": true,
70
+ "icons": { "mode": "auto" },
71
+ "sidebar": {
72
+ "mode": "auto",
73
+ "width": 40,
74
+ "density": "comfortable"
75
+ },
76
+ "theme": ""
77
+ }
78
+ ```
79
+
80
+ - `quietMode`: when `true`, compresses built-in tool rendering and folds thinking blocks.
81
+ - `quietSymbols`: `"check"` (✓/✗/⋯) or `"dot"` (●/○/◌) lifecycle glyphs for quiet tool rows.
82
+ - `agentsMode` / `todoMode`: `"list"` or `"compact"`.
83
+ - `todoExpanded`: when `true`, expands the todo widget by default.
84
+ - `hideNativeAgents`: when `true`, clears the teammate extension's own `teammate-agents` widget (it draws a similar list *below* the editor) so the two don't duplicate. On by default.
85
+ - `sidebar.mode`: `"auto"` or `"on"` enables the dock when at least 72 main columns plus 32 sidebar columns fit; `"off"` always uses widgets.
86
+ - `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
87
+ - `sidebar.density`: `"comfortable"` or `"compact"`.
88
+ - `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
89
+ - `theme`: named theme override; empty string follows the Pi session theme.
90
+
91
+ ## Commands
92
+
93
+ - `/cockpit` — opens the settings overlay.
94
+ - `/cockpit sidebar` — reports the current sidebar mode, width, and density.
95
+ - `/cockpit sidebar auto|on|off` — selects dock behavior.
96
+ - `/cockpit sidebar resize` or `Ctrl+Shift+R` — enters temporary Resize mode. Left/Right adjusts one column, Shift+Left/Shift+Right adjusts four, Enter accepts, and Escape rolls back. Mouse reporting is active only during Resize mode.
97
+ - `/cockpit quiet` — toggles quiet mode; `/cockpit bg` shows background jobs.
98
+ - `/theme` — switch theme with live preview; `/theme <name>` applies directly. Pi ships no standalone theme command; cockpit provides one.
99
+
100
+ ## Sidebar compatibility
101
+
102
+ The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.82.1`. A render integration failure disables the split and retries the original renderer at full width.
103
+
104
+ Do not enable `pi-cockpit`'s dock and `pi-atelier@0.7.0`'s sidebar together. Both reserve columns by wrapping the same renderer, and `pi-atelier@0.7.0` does not participate in Cockpit's split-owner marker protocol. Use `"sidebar": { "mode": "off" }` when running Atelier.
105
+
106
+ ## Terminal feasibility — what this design deliberately does NOT do
107
+
108
+ The original mockup was a browser page; a terminal is an ANSI stream with no DOM, no CSS, no focus, no hover. Four mockup effects are **out of scope** here, with replacements:
109
+
110
+ | Mockup effect | Why it can't work in a TUI | Replacement |
111
+ |---------------|----------------------------|-------------|
112
+ | Input-focus "power-up" glow (`:has(:focus)`) | no focus pseudo-class; `render(width)` can't see focus | the stack's header dot turns accent-green while an agent is running |
113
+ | Hover-to-expand chips/rows | no hover in a terminal | list mode shows everything; compact mode is one line; `/cockpit` toggles |
114
+ | Scanlines / logo light-up animation | no overlay/animation layer | a braille spinner frame, advanced on each redraw |
115
+ | In-stream thinking-collapse / edit progress bar / colored bash stdout | built-in message & built-in tool rendering is **not** replaceable by extensions (`renderCall`/`renderResult` only apply to tools *you* register) | the conversation stream is left to Pi's native renderer — it is context, not a cockpit deliverable |
116
+
117
+ ## Local development
118
+
119
+ ```bash
120
+ cd packages/pi-cockpit
121
+ npm test
122
+ npm run typecheck
123
+ npm pack --dry-run
124
+ ```
125
+
126
+ The package lives inside the `pi-maestro-flow` monorepo under `packages/` so it resolves `@earendil-works/*` types from the root `node_modules`. It is also an exact-pinned dependency of `pi-maestro-flow` and ships as its own npm package (`pi-cockpit`).
127
+
128
+ ## License
129
+
130
+ MIT. The split-pane behavior is adapted from `pi-atelier` under its MIT license; attribution is retained in `src/split-pane.ts`.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "pi-cockpit",
3
- "version": "0.5.1",
4
- "description": "Agent cockpit for Pi: a list-mode status stack (live teammates + todo plan) pinned above the editor, plus a Starship-style footer. Renders only via public extension APIs (setWidget/setFooter).",
3
+ "version": "0.7.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
5
  "type": "module",
6
6
  "keywords": [
7
7
  "pi-package",
@@ -18,7 +18,7 @@
18
18
  "README.md"
19
19
  ],
20
20
  "scripts": {
21
- "test": "node --test --experimental-transform-types tests/config.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
21
+ "test": "node --test --experimental-transform-types tests/config.test.ts tests/public-events.test.ts tests/maestro-store.test.ts tests/split-pane.test.ts tests/sidebar-render.test.ts tests/sidebar-controller.test.ts tests/viewport-stability.test.ts tests/quiet-tools.test.ts tests/agents-store.test.ts tests/bash-bg-store.test.ts tests/bash-bg-widget.test.ts tests/bash-bg-overlay.test.ts tests/todo-store.test.ts tests/render.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/integration-contract.test.ts tests/stack-widget.test.ts tests/settings-view.test.ts tests/thinking-fold.test.ts tests/thinking-timer.test.ts tests/tick-policy.test.ts tests/ambient.test.ts tests/viewport.test.ts tests/theme-picker.test.ts",
22
22
  "typecheck": "tsc --noEmit"
23
23
  },
24
24
  "pi": {
@@ -34,7 +34,7 @@
34
34
  "@earendil-works/pi-ai": "*",
35
35
  "@earendil-works/pi-coding-agent": "*",
36
36
  "@earendil-works/pi-tui": "*",
37
- "pi-maestro-teammate": "^1.0.0"
37
+ "pi-maestro-teammate": "^1.5.0"
38
38
  },
39
39
  "peerDependenciesMeta": {
40
40
  "@earendil-works/pi-agent-core": {
@@ -59,12 +59,13 @@
59
59
  "@earendil-works/pi-coding-agent": "0.82.1",
60
60
  "@earendil-works/pi-tui": "0.82.1",
61
61
  "@types/node": "^22.0.0",
62
- "pi-maestro-teammate": "1.3.1",
62
+ "pi-maestro-teammate": "1.5.0",
63
63
  "typescript": "^5.5.0"
64
64
  },
65
65
  "main": "./src/index.ts",
66
66
  "exports": {
67
67
  ".": "./src/index.ts",
68
+ "./v1/events": "./src/public/v1/events.ts",
68
69
  "./src/*": "./src/*"
69
70
  }
70
71
  }
@@ -1,4 +1,9 @@
1
- import type { TeammateCompleteEvent } from "pi-maestro-teammate/v1/events";
1
+ import type {
2
+ TeammateCompleteEvent,
3
+ TeammateProgressMessageEvent,
4
+ TeammateStartedEvent,
5
+ } from "pi-maestro-teammate/v1/events";
6
+ import type { AgentProgressSnapshot } from "pi-maestro-teammate/v1/types";
2
7
  import { sanitizeExtensionStatusText } from "./extension-status.ts";
3
8
  import type { AgentRow, AgentStatus } from "./types.ts";
4
9
 
@@ -19,56 +24,28 @@ function truncateTail(raw: string): string {
19
24
  return flat.length > TAIL_MAX ? flat.slice(0, TAIL_MAX - 1) + "…" : flat;
20
25
  }
21
26
 
22
- // Shapes mirror what pi-maestro-teammate emits on pi.events (teammate/.../index.ts:378/1636/3420).
23
- export interface StartedPayload {
24
- correlationId: string;
25
- agent: string;
26
- name?: string;
27
- spawnedBy?: string;
28
- startedAt?: number | string;
29
- lastActivityAt?: number | string;
30
- status?: string;
31
- }
32
- export interface ProgressPayload {
33
- agent: string;
34
- name?: string;
35
- correlationId: string;
36
- taskIndex: number;
37
- dependencies?: number[];
38
- status?: string;
39
- startedAt?: number | string;
40
- completedAt?: number | string;
41
- durationMs?: number;
42
- lastActivityAt?: number | string;
27
+ // Store inputs are compatibility-relaxed projections of the versioned public
28
+ // contract. The event boundary validates discriminators and identifiers; the
29
+ // optional fields let older teammate versions continue to feed the same store.
30
+ export type StartedPayload = Pick<TeammateStartedEvent, "correlationId" | "agent">
31
+ & Partial<Omit<TeammateStartedEvent, "correlationId" | "agent" | "startedAt">>
32
+ & { startedAt?: number | string };
33
+ export type ProgressPayload = Pick<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex">
34
+ & Partial<Omit<AgentProgressSnapshot, "correlationId" | "agent" | "taskIndex" | "startedAt" | "completedAt">>
35
+ & { startedAt?: number | string; completedAt?: number | string };
36
+ export type MessagePayload = Partial<Omit<
37
+ TeammateProgressMessageEvent,
38
+ "progress" | "recentTools" | "isSend" | "isInteraction"
39
+ >> & {
40
+ progress?: ProgressPayload[];
43
41
  recentTools?: Array<string | { name?: string; status?: string }>;
44
- toolCount?: number;
45
- tokens?: number;
46
- inputTokens?: number;
47
- outputTokens?: number;
48
- lastMessage?: string;
49
- }
50
- export interface MessagePayload {
51
- correlationId: string;
52
- taskCorrelationId?: string;
53
- // Present on progress/interaction deltas (teammate/.../index.ts publishProgress);
54
- // absent on send deltas. Used to self-heal a row when `started` was missed.
55
- agent?: string;
56
- name?: string;
57
- taskIndex?: number;
58
- dependencies?: number[];
42
+ isSend?: boolean;
43
+ isInteraction?: boolean;
44
+ /** Compatibility with pre-v1 progress deltas; discriminated send events are ignored. */
59
45
  message?: string;
60
- lastMessage?: string;
61
- recentTools?: Array<string | { name?: string; status?: string }>;
62
- toolCount?: number;
63
- tokens?: number;
64
- inputTokens?: number;
65
- outputTokens?: number;
66
- status?: string;
67
- lastActivityAt?: number | string;
68
- progress?: ProgressPayload[];
69
- }
46
+ };
70
47
  export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">
71
- & Partial<Pick<TeammateCompleteEvent, "durationMs">>;
48
+ & Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable" | "cancelled">>;
72
49
 
73
50
  /**
74
51
  * How long a failed agent stays on screen after it completes.
@@ -78,6 +55,22 @@ export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exi
78
55
  */
79
56
  export const FAILED_LINGER_MS = 30_000;
80
57
 
58
+ /**
59
+ * How long a sleeping (wakeable) row stays visible after completion.
60
+ *
61
+ * Matches the native widget's idle-hide window so both surfaces agree on
62
+ * when an idle agent stops occupying screen space.
63
+ */
64
+ export const SLEEPING_LINGER_MS = 60_000;
65
+
66
+ /**
67
+ * How long a terminated (cancelled/aborted) row stays on screen after it ends.
68
+ *
69
+ * Shorter than a failure: cancellation is expected, but the user should still
70
+ * see the × long enough to read which agent was taken down.
71
+ */
72
+ export const TERMINATED_LINGER_MS = 15_000;
73
+
81
74
  /**
82
75
  * How long a completed agent's tombstone suppresses self-healing.
83
76
  *
@@ -113,12 +106,23 @@ export function mapAgentStatus(status: unknown): AgentStatus {
113
106
  return "done";
114
107
  case "failed":
115
108
  return "failed";
109
+ case "terminated":
110
+ return "terminated";
116
111
  case "running":
117
112
  default:
118
113
  return "running";
119
114
  }
120
115
  }
121
116
 
117
+ export const AGENT_STALL_TIMEOUT_MS = 30_000;
118
+ export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
119
+
120
+ export function effectiveAgentStatus(row: AgentRow, now: number = Date.now()): AgentDisplayStatus {
121
+ if (row.status !== "running") return row.status;
122
+ if (row.resultReadyAt !== undefined) return "result-ready";
123
+ return now - row.lastActivityAt >= AGENT_STALL_TIMEOUT_MS ? "stalled" : "running";
124
+ }
125
+
122
126
  function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
123
127
  if (typeof value === "number" && Number.isFinite(value)) return value;
124
128
  if (typeof value === "string") {
@@ -141,7 +145,7 @@ function terminalTime(
141
145
  return row.finishedAt ?? now;
142
146
  }
143
147
 
144
- function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
148
+ function latestTool(tools: Array<string | { name?: string; status?: string }> | undefined): string | undefined {
145
149
  if (!tools?.length) return undefined;
146
150
  const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
147
151
  ?? tools.at(-1);
@@ -170,7 +174,16 @@ export class AgentsStore {
170
174
  return at !== undefined && now - at < COMPLETED_TOMBSTONE_MS;
171
175
  }
172
176
 
177
+ private canMaterialize(id: string, parentId: string | undefined, now: number): boolean {
178
+ if (this.isTombstoned(parentId, now)) return false;
179
+ if (!this.isTombstoned(id, now)) return true;
180
+ // An explicit parent restart clears only the graph container's tombstone.
181
+ // Its next full snapshot is authoritative for the child lifecycle too.
182
+ return parentId !== undefined && parentId !== id && this.roster.has(parentId);
183
+ }
184
+
173
185
  applyStarted(p: StartedPayload, now: number = Date.now()): void {
186
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
174
187
  const id = p.correlationId;
175
188
  // An explicit start is authoritative: a woken or re-dispatched agent
176
189
  // reuses its correlationId and must reappear even if an earlier run was
@@ -194,18 +207,27 @@ export class AgentsStore {
194
207
  ? { parentCorrelationId: prev.parentCorrelationId }
195
208
  : {}),
196
209
  };
197
- if (row.status !== "done" && row.status !== "failed") delete row.finishedAt;
210
+ if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") delete row.finishedAt;
198
211
  this.roster.set(id, row);
199
212
  }
200
213
 
201
214
  applyMessage(p: MessagePayload, now = Date.now()): void {
215
+ if (p.isSend === true || p.isInteraction === true) return;
216
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
202
217
  if (p.progress) {
203
- for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
218
+ for (const progress of p.progress) {
219
+ if (typeof progress.correlationId !== "string" || progress.correlationId.length === 0) continue;
220
+ this.applyProgress(p.correlationId, progress, now);
221
+ }
204
222
  }
205
- const targetId = p.taskCorrelationId ?? p.correlationId;
223
+ const targetId = typeof p.taskCorrelationId === "string" && p.taskCorrelationId.length > 0
224
+ ? p.taskCorrelationId
225
+ : p.correlationId;
226
+ const parentId = p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
227
+ ? p.correlationId
228
+ : undefined;
206
229
  if (!this.roster.has(targetId)
207
- && !this.isTombstoned(targetId, now)
208
- && !this.isTombstoned(p.correlationId, now)) {
230
+ && this.canMaterialize(targetId, parentId, now)) {
209
231
  // Self-heal the roster: a running agent must stay visible even when its
210
232
  // `started` delta was missed (cold start, event reorder, or a foreground
211
233
  // dispatch that detached to background). Materialize the row from this
@@ -228,7 +250,7 @@ export class AgentsStore {
228
250
  if (!row) return;
229
251
  const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
230
252
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
231
- const tail = p.message ?? p.lastMessage;
253
+ const tail = p.lastMessage ?? p.message;
232
254
  if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
233
255
  if (p.recentTools) {
234
256
  const tool = latestTool(p.recentTools);
@@ -239,17 +261,29 @@ export class AgentsStore {
239
261
  if (typeof p.tokens === "number") row.tokens = p.tokens;
240
262
  if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
241
263
  if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
264
+ if (typeof p.cacheReadTokens === "number") row.cacheReadTokens = p.cacheReadTokens;
265
+ if (typeof p.cacheWriteTokens === "number") row.cacheWriteTokens = p.cacheWriteTokens;
266
+ if (typeof p.error === "string") row.error = truncateTail(p.error);
267
+ if (typeof p.requestedModel === "string") row.requestedModel = clean(p.requestedModel);
268
+ if (typeof p.resolvedModel === "string") row.resolvedModel = clean(p.resolvedModel);
269
+ if (Array.isArray(p.attemptedModels)) {
270
+ row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
271
+ }
242
272
  if (typeof p.status === "string") {
243
273
  row.taskStatus = p.status;
244
274
  row.status = mapAgentStatus(p.status);
245
- if (row.status === "done" || row.status === "failed") row.finishedAt ??= now;
246
- else delete row.finishedAt;
275
+ if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
276
+ row.finishedAt ??= now;
277
+ } else {
278
+ delete row.finishedAt;
279
+ }
247
280
  }
248
281
  if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
249
282
  if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
250
283
  }
251
284
 
252
285
  applyComplete(p: CompletePayload, now = Date.now()): void {
286
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
253
287
  const pending = [p.correlationId];
254
288
  const visited = new Set<string>();
255
289
  while (pending.length > 0) {
@@ -263,16 +297,33 @@ export class AgentsStore {
263
297
  // only evidence of the failure disappeared in the same frame it appeared.
264
298
  // Successes still vanish immediately — the work has simply moved on.
265
299
  const row = this.roster.get(id);
300
+ const cancelledById = id === p.correlationId && p.cancelled === true;
266
301
  const failedByExitCode = id === p.correlationId
267
302
  && Number.isFinite(p.exitCode)
268
303
  && p.exitCode !== 0;
269
- if (row && (row.status === "failed" || failedByExitCode)) {
304
+ if (row && (cancelledById || row.status === "terminated")) {
305
+ // Cancelled/terminated runs carry exitCode 1 but are not failures:
306
+ // a user abort or a result-ready reclaim must not light the red ✗.
307
+ row.status = "terminated";
308
+ row.taskStatus = "terminated";
309
+ row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
310
+ row.lastActivityAt = now;
311
+ delete row.activeTool;
312
+ delete row.failedAt;
313
+ } else if (row && (row.status === "failed" || failedByExitCode)) {
270
314
  row.status = "failed";
271
315
  row.taskStatus = "failed";
272
316
  row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
273
317
  row.failedAt = now;
274
318
  row.lastActivityAt = now;
275
319
  delete row.activeTool;
320
+ } else if (row && id === p.correlationId && p.wakeable && p.exitCode === 0) {
321
+ // A wakeable agent enters sleeping state after completion.
322
+ // Keep the row visible so the user can see it is idle but recallable.
323
+ row.status = "sleeping";
324
+ row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
325
+ row.lastActivityAt = now;
326
+ delete row.activeTool;
276
327
  } else {
277
328
  this.roster.delete(id);
278
329
  // Tombstone even when the row never existed: a `complete` that beats
@@ -282,13 +333,19 @@ export class AgentsStore {
282
333
  }
283
334
  }
284
335
 
285
- /** Drop failed rows that have had their time on screen. Returns true if any went. */
336
+ /** Drop failed, terminated or expired sleeping rows that have had their time on screen. Returns true if any went. */
286
337
  prune(now = Date.now()): boolean {
287
338
  let changed = false;
288
339
  for (const [id, row] of this.roster) {
289
340
  if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
290
341
  this.roster.delete(id);
291
342
  changed = true;
343
+ } else if (row.status === "terminated" && row.finishedAt !== undefined && now - row.finishedAt >= TERMINATED_LINGER_MS) {
344
+ this.roster.delete(id);
345
+ changed = true;
346
+ } else if (row.status === "sleeping" && row.finishedAt !== undefined && now - row.finishedAt >= SLEEPING_LINGER_MS) {
347
+ this.roster.delete(id);
348
+ changed = true;
292
349
  }
293
350
  }
294
351
  for (const [id, at] of this.completedAt) {
@@ -297,18 +354,19 @@ export class AgentsStore {
297
354
  return changed;
298
355
  }
299
356
 
300
- /** True while a failed row is still counting down, so the redraw loop must run. */
357
+ /** True while a failed, terminated or sleeping row is still counting down, so the redraw loop must run. */
301
358
  hasLingering(): boolean {
302
359
  for (const row of this.roster.values()) {
303
360
  if (row.failedAt !== undefined) return true;
361
+ if (row.status === "terminated") return true;
362
+ if (row.status === "sleeping") return true;
304
363
  }
305
364
  return false;
306
365
  }
307
366
 
308
367
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
309
368
  if (!this.roster.has(p.correlationId)
310
- && !this.isTombstoned(p.correlationId, now)
311
- && !this.isTombstoned(parentCorrelationId, now)) {
369
+ && this.canMaterialize(p.correlationId, parentCorrelationId, now)) {
312
370
  // Self-heal: see applyMessage. A graph child whose `started` delta was
313
371
  // missed still materializes from its first progress event — unless the
314
372
  // child or its graph parent already completed, in which case this late
@@ -339,12 +397,15 @@ export class AgentsStore {
339
397
  if (typeof p.status === "string") {
340
398
  row.taskStatus = p.status;
341
399
  row.status = mapAgentStatus(p.status);
342
- if (row.status === "done" || row.status === "failed") {
400
+ if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
343
401
  row.finishedAt = terminalTime(row, p, now);
344
402
  } else {
345
403
  delete row.finishedAt;
346
404
  }
347
405
  }
406
+ row.resultReadyAt = p.resultReadyAt === undefined
407
+ ? undefined
408
+ : normalizeStartedAt(p.resultReadyAt, now);
348
409
  row.taskIndex = p.taskIndex;
349
410
  row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
350
411
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
@@ -357,6 +418,16 @@ export class AgentsStore {
357
418
  if (typeof p.tokens === "number") row.tokens = p.tokens;
358
419
  if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
359
420
  if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
421
+ row.cacheReadTokens = p.cacheReadTokens;
422
+ row.cacheWriteTokens = p.cacheWriteTokens;
423
+ if (p.error === undefined) delete row.error;
424
+ else row.error = truncateTail(p.error);
425
+ if (p.requestedModel === undefined) delete row.requestedModel;
426
+ else row.requestedModel = clean(p.requestedModel);
427
+ if (p.resolvedModel === undefined) delete row.resolvedModel;
428
+ else row.resolvedModel = clean(p.resolvedModel);
429
+ if (p.attemptedModels === undefined) delete row.attemptedModels;
430
+ else row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
360
431
  if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
361
432
  row.tail = truncateTail(p.lastMessage);
362
433
  }