pi-cockpit 0.6.0 → 0.8.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
@@ -2,7 +2,7 @@
2
2
 
3
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
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**.
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.8.0**.
6
6
 
7
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
8
 
@@ -73,6 +73,16 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
73
73
  "width": 40,
74
74
  "density": "comfortable"
75
75
  },
76
+ "title": {
77
+ "enabled": true,
78
+ "showSession": true,
79
+ "showCwd": false,
80
+ "showModel": false,
81
+ "showThinking": false,
82
+ "showGit": false,
83
+ "showMaestro": false,
84
+ "maxLength": 80
85
+ },
76
86
  "theme": ""
77
87
  }
78
88
  ```
@@ -86,6 +96,17 @@ Cockpit has no package dependency on `pi-maestro-flow`. It observes optional pro
86
96
  - `sidebar.width`: persisted dock width, rounded and clamped to `32..56`; default `40`.
87
97
  - `sidebar.density`: `"comfortable"` or `"compact"`.
88
98
  - `icons.mode`: `"auto"` (detect Nerd Font), `"nerd"`, or `"ascii"`.
99
+ - `title.enabled`: master switch for the terminal tab title (session summary + working state + opt-in tags). On by default.
100
+ - `title.showSession`: include the session summary (the `session_info` name, else a short session id) right after `pi`. On by default.
101
+ - `title.showCwd`: include the working directory after the session. Off by default — the title stays short, since the tab strip has no room for a wall of tags.
102
+ - `title.showModel`: include the active model tag (`m:gpt-5.6-sol`). Off by default.
103
+ - `title.showThinking`: include the thinking level tag (`t:high`); skipped while off. Off by default.
104
+ - `title.showGit`: include the git branch tag (`git:main`, `git:detached`); read synchronously from `.git/HEAD`, so it costs no process spawn. Off by default.
105
+ - `title.showMaestro`: include the Maestro workflow status tag (`wf:running`, `wf:done`). Off by default.
106
+ - `title.maxLength`: hard cap on the composed title; the middle is ellided to keep the head and the working-state tail. Clamped to `20..200`; default `80`.
107
+ - `title.generationModel`: `"provider/model"` (e.g. `"maestro-qwen/qwen3.8-max-preview"`) of a model registered with the `/api-manager` command (or any other provider pi resolves). When set, the session title is generated by that model over the first completed turn (user prompt + assistant reply) through its OpenAI-compatible endpoint, with a 10s timeout. Empty (default) uses the offline rule-based extractor instead. Generation is best-effort — on any failure the title silently falls back to the rule-based one.
108
+
109
+ The tab title is `frame + pi - <session> - <working state>`. The session part follows Claude Code's chain (`sessionTitle ?? agentTitle ?? haikuTitle ?? default`): the `/session name` title wins, otherwise the generated title, otherwise a short session id. The frame is Claude Code's title chrome: `⠂`/`⠐` braille spinner while a turn runs, static `✳` when idle; failure replaces it with `✗`. On quit, Cockpit clears the title so no stale tab label lingers (Claude Code's `CLEAR_TERMINAL_TITLE`).
89
110
  - `theme`: named theme override; empty string follows the Pi session theme.
90
111
 
91
112
  ## Commands
package/package.json CHANGED
@@ -1,71 +1,75 @@
1
- {
2
- "name": "pi-cockpit",
3
- "version": "0.6.0",
4
- "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
- "type": "module",
6
- "keywords": [
7
- "pi-package",
8
- "pi",
9
- "pi-extension",
10
- "tui",
11
- "teammate",
12
- "todo"
13
- ],
14
- "license": "MIT",
15
- "files": [
16
- "src/",
17
- "themes/",
18
- "README.md"
19
- ],
20
- "scripts": {
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/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",
22
- "typecheck": "tsc --noEmit"
23
- },
24
- "pi": {
25
- "extensions": [
26
- "./src/extension/index.ts"
27
- ],
28
- "themes": [
29
- "./themes"
30
- ]
31
- },
32
- "peerDependencies": {
33
- "@earendil-works/pi-agent-core": "*",
34
- "@earendil-works/pi-ai": "*",
35
- "@earendil-works/pi-coding-agent": "*",
36
- "@earendil-works/pi-tui": "*",
37
- "pi-maestro-teammate": "^1.0.0"
38
- },
39
- "peerDependenciesMeta": {
40
- "@earendil-works/pi-agent-core": {
41
- "optional": true
42
- },
43
- "@earendil-works/pi-ai": {
44
- "optional": true
45
- },
46
- "@earendil-works/pi-coding-agent": {
47
- "optional": true
48
- },
49
- "@earendil-works/pi-tui": {
50
- "optional": true
51
- },
52
- "pi-maestro-teammate": {
53
- "optional": true
54
- }
55
- },
56
- "devDependencies": {
57
- "@earendil-works/pi-agent-core": "0.82.1",
58
- "@earendil-works/pi-ai": "0.82.1",
59
- "@earendil-works/pi-coding-agent": "0.82.1",
60
- "@earendil-works/pi-tui": "0.82.1",
61
- "@types/node": "^22.0.0",
62
- "pi-maestro-teammate": "1.3.1",
63
- "typescript": "^5.5.0"
64
- },
65
- "main": "./src/index.ts",
66
- "exports": {
67
- ".": "./src/index.ts",
68
- "./v1/events": "./src/public/v1/events.ts",
69
- "./src/*": "./src/*"
70
- }
71
- }
1
+ {
2
+ "name": "pi-cockpit",
3
+ "version": "0.8.0",
4
+ "description": "Responsive Maestro operations sidebar, fallback status widgets, and footer for Pi.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi-package",
8
+ "pi",
9
+ "pi-extension",
10
+ "tui",
11
+ "teammate",
12
+ "todo"
13
+ ],
14
+ "license": "MIT",
15
+ "files": [
16
+ "src/",
17
+ "themes/",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
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/editor-bottom.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 tests/title-gen.test.ts tests/title-llm.test.ts tests/settings-registry.test.ts tests/settings-coordinator.test.ts tests/settings-locale.test.ts tests/settings-shell.test.ts tests/settings-cockpit-provider.test.ts tests/settings-cross-package.test.ts",
22
+ "typecheck": "tsc --noEmit"
23
+ },
24
+ "pi": {
25
+ "extensions": [
26
+ "./src/extension/index.ts"
27
+ ],
28
+ "themes": [
29
+ "./themes"
30
+ ]
31
+ },
32
+ "dependencies": {
33
+ "pi-maestro-settings-core": "0.1.0",
34
+ "proper-lockfile": "4.1.2"
35
+ },
36
+ "peerDependencies": {
37
+ "@earendil-works/pi-agent-core": "*",
38
+ "@earendil-works/pi-ai": "*",
39
+ "@earendil-works/pi-coding-agent": "*",
40
+ "@earendil-works/pi-tui": "*",
41
+ "pi-maestro-teammate": "^1.6.0"
42
+ },
43
+ "peerDependenciesMeta": {
44
+ "@earendil-works/pi-agent-core": {
45
+ "optional": true
46
+ },
47
+ "@earendil-works/pi-ai": {
48
+ "optional": true
49
+ },
50
+ "@earendil-works/pi-coding-agent": {
51
+ "optional": true
52
+ },
53
+ "@earendil-works/pi-tui": {
54
+ "optional": true
55
+ },
56
+ "pi-maestro-teammate": {
57
+ "optional": true
58
+ }
59
+ },
60
+ "devDependencies": {
61
+ "@earendil-works/pi-agent-core": "0.83.0",
62
+ "@earendil-works/pi-ai": "0.83.0",
63
+ "@earendil-works/pi-coding-agent": "0.83.0",
64
+ "@earendil-works/pi-tui": "0.83.0",
65
+ "@types/node": "^22.0.0",
66
+ "pi-maestro-teammate": "1.6.0",
67
+ "typescript": "^5.5.0"
68
+ },
69
+ "main": "./src/index.ts",
70
+ "exports": {
71
+ ".": "./src/index.ts",
72
+ "./v1/events": "./src/public/v1/events.ts",
73
+ "./src/*": "./src/*"
74
+ }
75
+ }
@@ -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" | "wakeable">>;
48
+ & Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable" | "cancelled">>;
72
49
 
73
50
  /**
74
51
  * How long a failed agent stays on screen after it completes.
@@ -86,6 +63,14 @@ export const FAILED_LINGER_MS = 30_000;
86
63
  */
87
64
  export const SLEEPING_LINGER_MS = 60_000;
88
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
+
89
74
  /**
90
75
  * How long a completed agent's tombstone suppresses self-healing.
91
76
  *
@@ -121,12 +106,30 @@ export function mapAgentStatus(status: unknown): AgentStatus {
121
106
  return "done";
122
107
  case "failed":
123
108
  return "failed";
109
+ case "terminated":
110
+ return "terminated";
124
111
  case "running":
125
112
  default:
126
113
  return "running";
127
114
  }
128
115
  }
129
116
 
117
+ function activityFromProgressStatus(status: unknown): "running" | "sleeping" | undefined {
118
+ if (typeof status !== "string") return undefined;
119
+ return status === "sleeping" || status === "completed" || status === "failed" || status === "terminated"
120
+ ? "sleeping"
121
+ : "running";
122
+ }
123
+
124
+ export const AGENT_STALL_TIMEOUT_MS = 30_000;
125
+ export type AgentDisplayStatus = AgentStatus | "result-ready" | "stalled";
126
+
127
+ export function effectiveAgentStatus(row: AgentRow, now: number = Date.now()): AgentDisplayStatus {
128
+ if (row.status !== "running") return row.status;
129
+ if (row.resultReadyAt !== undefined) return "result-ready";
130
+ return now - row.lastActivityAt >= AGENT_STALL_TIMEOUT_MS ? "stalled" : "running";
131
+ }
132
+
130
133
  function normalizeStartedAt(value: number | string | undefined, fallback: number): number {
131
134
  if (typeof value === "number" && Number.isFinite(value)) return value;
132
135
  if (typeof value === "string") {
@@ -142,14 +145,15 @@ function terminalTime(
142
145
  now: number,
143
146
  ): number {
144
147
  const completedAt = normalizeStartedAt(payload.completedAt, Number.NaN);
145
- if (Number.isFinite(completedAt)) return Math.max(row.startedAt, completedAt);
148
+ const turnStartedAt = row.turnStartedAt ?? row.startedAt;
149
+ if (Number.isFinite(completedAt)) return Math.max(turnStartedAt, completedAt);
146
150
  if (typeof payload.durationMs === "number" && Number.isFinite(payload.durationMs)) {
147
- return row.startedAt + Math.max(0, payload.durationMs);
151
+ return turnStartedAt + Math.max(0, payload.durationMs);
148
152
  }
149
153
  return row.finishedAt ?? now;
150
154
  }
151
155
 
152
- function latestTool(tools: MessagePayload["recentTools"]): string | undefined {
156
+ function latestTool(tools: Array<string | { name?: string; status?: string }> | undefined): string | undefined {
153
157
  if (!tools?.length) return undefined;
154
158
  const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
155
159
  ?? tools.at(-1);
@@ -187,6 +191,7 @@ export class AgentsStore {
187
191
  }
188
192
 
189
193
  applyStarted(p: StartedPayload, now: number = Date.now()): void {
194
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
190
195
  const id = p.correlationId;
191
196
  // An explicit start is authoritative: a woken or re-dispatched agent
192
197
  // reuses its correlationId and must reappear even if an earlier run was
@@ -201,6 +206,14 @@ export class AgentsStore {
201
206
  role: deriveRole(p.agent, p.name),
202
207
  task: prev?.task ?? clean(p.name),
203
208
  status: p.status === undefined ? prev?.status ?? "running" : mapAgentStatus(p.status),
209
+ phase: typeof p.phase === "string" ? clean(p.phase) : prev?.phase,
210
+ lastOutcome: p.lastOutcome
211
+ ? {
212
+ status: p.lastOutcome.status,
213
+ ...(p.lastOutcome.message ? { message: truncateTail(p.lastOutcome.message) } : {}),
214
+ settledAt: p.lastOutcome.settledAt,
215
+ }
216
+ : prev?.lastOutcome,
204
217
  tail: prev?.tail ?? "",
205
218
  startedAt: prev?.startedAt ?? normalizeStartedAt(p.startedAt, now),
206
219
  lastActivityAt: normalizeStartedAt(p.lastActivityAt, now),
@@ -210,15 +223,30 @@ export class AgentsStore {
210
223
  ? { parentCorrelationId: prev.parentCorrelationId }
211
224
  : {}),
212
225
  };
213
- if (row.status !== "done" && row.status !== "failed") delete row.finishedAt;
226
+ if (row.status === "running") {
227
+ row.turnStartedAt = now;
228
+ delete row.finishedAt;
229
+ delete row.failedAt;
230
+ } else if (row.status === "sleeping") {
231
+ row.finishedAt = row.lastOutcome?.settledAt ?? prev?.finishedAt ?? now;
232
+ } else if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") {
233
+ delete row.finishedAt;
234
+ }
214
235
  this.roster.set(id, row);
215
236
  }
216
237
 
217
238
  applyMessage(p: MessagePayload, now = Date.now()): void {
239
+ if (p.isSend === true || p.isInteraction === true) return;
240
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
218
241
  if (p.progress) {
219
- for (const progress of p.progress) this.applyProgress(p.correlationId, progress, now);
242
+ for (const progress of p.progress) {
243
+ if (typeof progress.correlationId !== "string" || progress.correlationId.length === 0) continue;
244
+ this.applyProgress(p.correlationId, progress, now);
245
+ }
220
246
  }
221
- const targetId = p.taskCorrelationId ?? p.correlationId;
247
+ const targetId = typeof p.taskCorrelationId === "string" && p.taskCorrelationId.length > 0
248
+ ? p.taskCorrelationId
249
+ : p.correlationId;
222
250
  const parentId = p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
223
251
  ? p.correlationId
224
252
  : undefined;
@@ -235,7 +263,7 @@ export class AgentsStore {
235
263
  correlationId: targetId,
236
264
  agent: p.agent ?? "",
237
265
  name: p.name,
238
- status: p.status,
266
+ status: activityFromProgressStatus(p.status),
239
267
  lastActivityAt: p.lastActivityAt,
240
268
  ...(p.taskCorrelationId && p.taskCorrelationId !== p.correlationId
241
269
  ? { spawnedBy: p.correlationId }
@@ -246,7 +274,7 @@ export class AgentsStore {
246
274
  if (!row) return;
247
275
  const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
248
276
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
249
- const tail = p.message ?? p.lastMessage;
277
+ const tail = p.lastMessage ?? p.message;
250
278
  if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
251
279
  if (p.recentTools) {
252
280
  const tool = latestTool(p.recentTools);
@@ -257,17 +285,31 @@ export class AgentsStore {
257
285
  if (typeof p.tokens === "number") row.tokens = p.tokens;
258
286
  if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
259
287
  if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
288
+ if (typeof p.cacheReadTokens === "number") row.cacheReadTokens = p.cacheReadTokens;
289
+ if (typeof p.cacheWriteTokens === "number") row.cacheWriteTokens = p.cacheWriteTokens;
290
+ if (typeof p.error === "string") row.error = truncateTail(p.error);
291
+ if (typeof p.requestedModel === "string") row.requestedModel = clean(p.requestedModel);
292
+ if (typeof p.resolvedModel === "string") row.resolvedModel = clean(p.resolvedModel);
293
+ if (Array.isArray(p.attemptedModels)) {
294
+ row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
295
+ }
260
296
  if (typeof p.status === "string") {
261
297
  row.taskStatus = p.status;
262
298
  row.status = mapAgentStatus(p.status);
263
- if (row.status === "done" || row.status === "failed") row.finishedAt ??= now;
264
- else delete row.finishedAt;
299
+ if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
300
+ row.finishedAt ??= now;
301
+ } else {
302
+ delete row.finishedAt;
303
+ }
265
304
  }
305
+ if (typeof p.phase === "string") row.phase = clean(p.phase);
306
+ else if (row.status === "sleeping") delete row.phase;
266
307
  if (typeof p.taskIndex === "number") row.taskIndex = p.taskIndex;
267
308
  if (Array.isArray(p.dependencies)) row.dependencies = [...p.dependencies];
268
309
  }
269
310
 
270
311
  applyComplete(p: CompletePayload, now = Date.now()): void {
312
+ if (typeof p.correlationId !== "string" || p.correlationId.length === 0) return;
271
313
  const pending = [p.correlationId];
272
314
  const visited = new Set<string>();
273
315
  while (pending.length > 0) {
@@ -281,21 +323,46 @@ export class AgentsStore {
281
323
  // only evidence of the failure disappeared in the same frame it appeared.
282
324
  // Successes still vanish immediately — the work has simply moved on.
283
325
  const row = this.roster.get(id);
326
+ const cancelledById = id === p.correlationId && p.cancelled === true;
284
327
  const failedByExitCode = id === p.correlationId
285
328
  && Number.isFinite(p.exitCode)
286
329
  && p.exitCode !== 0;
287
- if (row && (row.status === "failed" || failedByExitCode)) {
288
- row.status = "failed";
289
- row.taskStatus = "failed";
330
+ if (row && (cancelledById || row.status === "terminated")) {
331
+ // Cancelled/terminated runs carry exitCode 1 but are not failures:
332
+ // a user abort or a result-ready reclaim must not light the red ✗.
333
+ row.status = "terminated";
334
+ row.taskStatus = "terminated";
290
335
  row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
291
- row.failedAt = now;
336
+ row.lastOutcome = { status: "terminated", settledAt: row.finishedAt };
292
337
  row.lastActivityAt = now;
293
338
  delete row.activeTool;
294
- } else if (row && p.wakeable && p.exitCode === 0) {
295
- // A wakeable agent enters sleeping state after completion.
296
- // Keep the row visible so the user can see it is idle but recallable.
339
+ delete row.failedAt;
340
+ } else if (row && id === p.correlationId && p.wakeable) {
341
+ // A recoverable logical agent is sleeping regardless of the previous
342
+ // run outcome. Failure remains visible as orthogonal outcome metadata.
297
343
  row.status = "sleeping";
344
+ row.taskStatus = "sleeping";
345
+ row.phase = undefined;
346
+ row.finishedAt = terminalTime(row, p, now);
347
+ row.lastActivityAt = now;
348
+ row.lastOutcome = {
349
+ status: failedByExitCode ? "failed" : "completed",
350
+ ...(failedByExitCode && row.error ? { message: row.error } : {}),
351
+ settledAt: row.finishedAt,
352
+ };
353
+ if (failedByExitCode) row.failedAt = now;
354
+ else delete row.failedAt;
355
+ delete row.activeTool;
356
+ } else if (row && (row.status === "failed" || failedByExitCode)) {
357
+ row.status = "failed";
358
+ row.taskStatus = "failed";
298
359
  row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
360
+ row.lastOutcome = {
361
+ status: "failed",
362
+ ...(row.error ? { message: row.error } : {}),
363
+ settledAt: row.finishedAt,
364
+ };
365
+ row.failedAt = now;
299
366
  row.lastActivityAt = now;
300
367
  delete row.activeTool;
301
368
  } else {
@@ -307,13 +374,16 @@ export class AgentsStore {
307
374
  }
308
375
  }
309
376
 
310
- /** Drop failed or expired sleeping rows that have had their time on screen. Returns true if any went. */
377
+ /** Drop failed, terminated or expired sleeping rows that have had their time on screen. Returns true if any went. */
311
378
  prune(now = Date.now()): boolean {
312
379
  let changed = false;
313
380
  for (const [id, row] of this.roster) {
314
381
  if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
315
382
  this.roster.delete(id);
316
383
  changed = true;
384
+ } else if (row.status === "terminated" && row.finishedAt !== undefined && now - row.finishedAt >= TERMINATED_LINGER_MS) {
385
+ this.roster.delete(id);
386
+ changed = true;
317
387
  } else if (row.status === "sleeping" && row.finishedAt !== undefined && now - row.finishedAt >= SLEEPING_LINGER_MS) {
318
388
  this.roster.delete(id);
319
389
  changed = true;
@@ -325,10 +395,11 @@ export class AgentsStore {
325
395
  return changed;
326
396
  }
327
397
 
328
- /** True while a failed or sleeping row is still counting down, so the redraw loop must run. */
398
+ /** True while a failed, terminated or sleeping row is still counting down, so the redraw loop must run. */
329
399
  hasLingering(): boolean {
330
400
  for (const row of this.roster.values()) {
331
401
  if (row.failedAt !== undefined) return true;
402
+ if (row.status === "terminated") return true;
332
403
  if (row.status === "sleeping") return true;
333
404
  }
334
405
  return false;
@@ -345,7 +416,7 @@ export class AgentsStore {
345
416
  correlationId: p.correlationId,
346
417
  agent: p.agent,
347
418
  name: p.name,
348
- status: p.status,
419
+ status: activityFromProgressStatus(p.status),
349
420
  startedAt: p.startedAt,
350
421
  lastActivityAt: p.lastActivityAt,
351
422
  ...(parentCorrelationId !== p.correlationId
@@ -367,12 +438,17 @@ export class AgentsStore {
367
438
  if (typeof p.status === "string") {
368
439
  row.taskStatus = p.status;
369
440
  row.status = mapAgentStatus(p.status);
370
- if (row.status === "done" || row.status === "failed") {
441
+ if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
371
442
  row.finishedAt = terminalTime(row, p, now);
372
443
  } else {
373
444
  delete row.finishedAt;
374
445
  }
375
446
  }
447
+ if (typeof p.phase === "string") row.phase = clean(p.phase);
448
+ else if (row.status === "sleeping") delete row.phase;
449
+ row.resultReadyAt = p.resultReadyAt === undefined
450
+ ? undefined
451
+ : normalizeStartedAt(p.resultReadyAt, now);
376
452
  row.taskIndex = p.taskIndex;
377
453
  row.dependencies = Array.isArray(p.dependencies) ? [...p.dependencies] : [];
378
454
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt, now);
@@ -385,6 +461,16 @@ export class AgentsStore {
385
461
  if (typeof p.tokens === "number") row.tokens = p.tokens;
386
462
  if (typeof p.inputTokens === "number") row.inputTokens = p.inputTokens;
387
463
  if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
464
+ row.cacheReadTokens = p.cacheReadTokens;
465
+ row.cacheWriteTokens = p.cacheWriteTokens;
466
+ if (p.error === undefined) delete row.error;
467
+ else row.error = truncateTail(p.error);
468
+ if (p.requestedModel === undefined) delete row.requestedModel;
469
+ else row.requestedModel = clean(p.requestedModel);
470
+ if (p.resolvedModel === undefined) delete row.resolvedModel;
471
+ else row.resolvedModel = clean(p.resolvedModel);
472
+ if (p.attemptedModels === undefined) delete row.attemptedModels;
473
+ else row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
388
474
  if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
389
475
  row.tail = truncateTail(p.lastMessage);
390
476
  }