pi-cockpit 0.9.0 → 0.9.1

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.8.0**.
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.9.1**.
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
 
@@ -25,7 +25,7 @@ The dock is a non-capturing top-right overlay. Cockpit reserves its columns by w
25
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
26
 
27
27
  ```bash
28
- pi install npm:pi-cockpit # standalone from npm
28
+ pi install npm:pi-cockpit@0.9.1 # standalone from npm
29
29
  # or, for one run:
30
30
  pi -e ./packages/pi-cockpit
31
31
  ```
@@ -122,7 +122,7 @@ The tab title is `frame + pi - <session> - <working state>`. The session part fo
122
122
 
123
123
  ## Sidebar compatibility
124
124
 
125
- 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.
125
+ The split-pane wrapper depends on Pi's current TUI renderer shape and is verified against Pi `0.83.0`. A render integration failure disables the split and retries the original renderer at full width.
126
126
 
127
127
  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.
128
128
 
package/package.json CHANGED
@@ -1,75 +1,80 @@
1
1
  {
2
- "name": "pi-cockpit",
3
- "version": "0.9.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.7.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
- }
2
+ "name": "pi-cockpit",
3
+ "version": "0.9.1",
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/edit-guard.test.ts tests/agents-store.test.ts tests/supervision-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/agent-scroll.test.ts tests/session-bar.test.ts tests/session-detail.test.ts tests/footer.test.ts tests/extension-status.test.ts tests/input-routing.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/model-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 tests/ui-primitives.test.ts tests/settings-shell-secret.test.ts tests/settings-shell-listcrud.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
+ "typebox": "*"
43
+ },
44
+ "peerDependenciesMeta": {
45
+ "@earendil-works/pi-agent-core": {
46
+ "optional": true
47
+ },
48
+ "@earendil-works/pi-ai": {
49
+ "optional": true
50
+ },
51
+ "@earendil-works/pi-coding-agent": {
52
+ "optional": true
53
+ },
54
+ "@earendil-works/pi-tui": {
55
+ "optional": true
56
+ },
57
+ "pi-maestro-teammate": {
58
+ "optional": true
59
+ },
60
+ "typebox": {
61
+ "optional": true
62
+ }
63
+ },
64
+ "devDependencies": {
65
+ "@earendil-works/pi-agent-core": "0.83.0",
66
+ "@earendil-works/pi-ai": "0.83.0",
67
+ "@earendil-works/pi-coding-agent": "0.83.0",
68
+ "@earendil-works/pi-tui": "0.83.0",
69
+ "@types/node": "^22.0.0",
70
+ "pi-maestro-teammate": "1.7.1",
71
+ "typebox": "1.3.7",
72
+ "typescript": "^5.5.0"
73
+ },
74
+ "main": "./src/index.ts",
75
+ "exports": {
76
+ ".": "./src/index.ts",
77
+ "./v1/events": "./src/public/v1/events.ts",
78
+ "./src/*": "./src/*"
79
+ }
75
80
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Agent-list scroll window.
3
+ *
4
+ * The below-input agent list is bounded by the terminal height. It follows the
5
+ * newest activity by default (auto-scroll as agents stream); Shift+↑/Shift+↓
6
+ * (with an empty composer, via a cockpit terminal-input hook) scroll up/down
7
+ * through the whole roster. Scrolling up pauses follow; reaching the bottom
8
+ * resumes it. Pure state math, no UI dependencies.
9
+ */
10
+
11
+ import { panelRows } from "./viewport.ts";
12
+
13
+ export interface AgentScrollState {
14
+ /** Window start over the activity-ordered roster. */
15
+ offset: number;
16
+ /** When true the window tracks the newest rows (auto-scroll). */
17
+ following: boolean;
18
+ }
19
+
20
+ /** Window start for a state; follow pins the window to the newest rows. */
21
+ export function scrollWindowStart(total: number, budget: number, state: AgentScrollState): number {
22
+ if (total <= budget) return 0;
23
+ if (state.following) return total - budget;
24
+ return Math.max(0, Math.min(state.offset, total - budget));
25
+ }
26
+
27
+ /** Move the window by `delta` rows; reaching the bottom resumes follow. */
28
+ export function scrollBy(
29
+ state: AgentScrollState,
30
+ delta: number,
31
+ total: number,
32
+ budget: number,
33
+ ): AgentScrollState {
34
+ if (total <= budget) return { offset: 0, following: true };
35
+ const start = scrollWindowStart(total, budget, state);
36
+ const next = Math.max(0, Math.min(start + delta, total - budget));
37
+ return { offset: next, following: next >= total - budget };
38
+ }
39
+
40
+ /** Tail-following window: newest rows first. */
41
+ export function followWindow(total: number, budget: number): AgentScrollState {
42
+ return { offset: Math.max(0, total - budget), following: true };
43
+ }
44
+
45
+ /**
46
+ * How many agent rows the panel may render. Mirrors the roster widget's own
47
+ * budget: a width cap (6 wide / 3 narrow) plus one marker row, bounded by the
48
+ * terminal-height panel share.
49
+ */
50
+ export function agentListWindowRows(
51
+ terminalColumns: number | undefined,
52
+ terminalRows: number | undefined,
53
+ total?: number,
54
+ ): number {
55
+ const widthCap = (terminalColumns ?? 80) < 40 ? 3 : 6;
56
+ const panel = panelRows(terminalRows);
57
+ const panelBudget = Math.min(widthCap + 1, panel ?? widthCap + 1);
58
+ const bodyBudget = Math.max(1, panelBudget - 1); // roster header owns one row
59
+ // One combined overflow marker is paid from the body budget, preserving at
60
+ // least one actual agent row even in the minimum three-row panel.
61
+ return total !== undefined && total > bodyBudget
62
+ ? Math.max(1, bodyBudget - 1)
63
+ : bodyBudget;
64
+ }
@@ -7,7 +7,9 @@ import type { AgentProgressSnapshot } from "pi-maestro-teammate/v1/types";
7
7
  import { sanitizeExtensionStatusText } from "./extension-status.ts";
8
8
  import type { AgentRow, AgentStatus } from "./types.ts";
9
9
 
10
- const TAIL_MAX = 48;
10
+ const STATUS_TEXT_MAX = 48;
11
+ /** Selected-session output kept for the expandable fixed detail region. */
12
+ export const SESSION_CONTENT_MAX = 2_048;
11
13
 
12
14
  // Every string here originates in an LLM-authored teammate event. A raw newline
13
15
  // would split one widget row into several physical terminal rows and a raw escape
@@ -18,10 +20,18 @@ function clean(raw: string | undefined): string {
18
20
  return raw === undefined ? "" : sanitizeExtensionStatusText(raw);
19
21
  }
20
22
 
21
- function truncateTail(raw: string): string {
23
+ function truncateText(raw: string, max: number): string {
22
24
  const flat = clean(raw);
23
25
  if (flat.length === 0) return "";
24
- return flat.length > TAIL_MAX ? flat.slice(0, TAIL_MAX - 1) + "…" : flat;
26
+ return flat.length > max ? flat.slice(0, max - 1) + "…" : flat;
27
+ }
28
+
29
+ function truncateStatusText(raw: string): string {
30
+ return truncateText(raw, STATUS_TEXT_MAX);
31
+ }
32
+
33
+ function truncateSessionContent(raw: string): string {
34
+ return truncateText(raw, SESSION_CONTENT_MAX);
25
35
  }
26
36
 
27
37
  // Store inputs are compatibility-relaxed projections of the versioned public
@@ -47,29 +57,11 @@ export type MessagePayload = Partial<Omit<
47
57
  export type CompletePayload = Pick<TeammateCompleteEvent, "correlationId" | "exitCode">
48
58
  & Partial<Pick<TeammateCompleteEvent, "durationMs" | "wakeable" | "cancelled">>;
49
59
 
50
- /**
51
- * How long a failed agent stays on screen after it completes.
52
- *
53
- * Long enough to read the role and the tail that explains it; short enough that
54
- * the panel still empties itself without the user clearing anything.
55
- */
56
- export const FAILED_LINGER_MS = 30_000;
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;
60
+ export const AGENT_LINGER_MS = 60_000;
61
+ /** Compatibility exports: every terminal state now uses one visible window. */
62
+ export const FAILED_LINGER_MS = AGENT_LINGER_MS;
63
+ export const SLEEPING_LINGER_MS = AGENT_LINGER_MS;
64
+ export const TERMINATED_LINGER_MS = AGENT_LINGER_MS;
73
65
 
74
66
  /**
75
67
  * How long a completed agent's tombstone suppresses self-healing.
@@ -158,11 +150,11 @@ function latestTool(tools: Array<string | { name?: string; status?: string }> |
158
150
  const tool = tools.find((candidate) => typeof candidate === "object" && candidate?.status === "running")
159
151
  ?? tools.at(-1);
160
152
  if (!tool) return undefined;
161
- if (typeof tool === "string") return truncateTail(tool);
153
+ if (typeof tool === "string") return truncateStatusText(tool);
162
154
  const name = tool?.name?.trim();
163
155
  if (!name) return undefined;
164
156
  const status = tool.status?.trim();
165
- return truncateTail(status && status !== "completed" ? `${name} (${status})` : name);
157
+ return truncateStatusText(status && status !== "completed" ? `${name} (${status})` : name);
166
158
  }
167
159
 
168
160
  // Self-accumulating roster. The teammate extension only broadcasts deltas
@@ -175,6 +167,8 @@ export class AgentsStore {
175
167
  private readonly roster = new Map<string, AgentRow>();
176
168
  /** correlationId -> completion time; suppresses post-complete self-healing. */
177
169
  private readonly completedAt = new Map<string, number>();
170
+ /** correlationId of the agent currently shown in teammate's viewing view. */
171
+ private viewingId: string | undefined;
178
172
 
179
173
  private isTombstoned(id: string | undefined, now: number): boolean {
180
174
  if (id === undefined) return false;
@@ -198,6 +192,29 @@ export class AgentsStore {
198
192
  // tombstoned.
199
193
  this.completedAt.delete(id);
200
194
  const prev = this.roster.get(id);
195
+ const prevIsTerminal = prev?.status === "done"
196
+ || prev?.status === "failed"
197
+ || prev?.status === "terminated"
198
+ || prev?.status === "sleeping";
199
+ const nextStatus = p.status === undefined
200
+ ? prevIsTerminal ? "running" : prev?.status ?? "running"
201
+ : mapAgentStatus(p.status);
202
+ if ((clean(p.agent) || prev?.agent || "").startsWith("graph(") && nextStatus === "running") {
203
+ // Restarting a graph is authoritative for its retained descendants too;
204
+ // their next progress snapshot may reuse the same correlation ids.
205
+ const pending = [id];
206
+ const visited = new Set<string>();
207
+ while (pending.length > 0) {
208
+ const parent = pending.pop()!;
209
+ if (visited.has(parent)) continue;
210
+ visited.add(parent);
211
+ for (const candidate of this.roster.values()) {
212
+ if (candidate.parentCorrelationId !== parent) continue;
213
+ this.completedAt.delete(candidate.correlationId);
214
+ pending.push(candidate.correlationId);
215
+ }
216
+ }
217
+ }
201
218
  const row: AgentRow = {
202
219
  ...prev,
203
220
  correlationId: id,
@@ -205,12 +222,12 @@ export class AgentsStore {
205
222
  name: p.name === undefined ? prev?.name : clean(p.name),
206
223
  role: deriveRole(p.agent, p.name),
207
224
  task: prev?.task ?? clean(p.name),
208
- status: p.status === undefined ? prev?.status ?? "running" : mapAgentStatus(p.status),
225
+ status: nextStatus,
209
226
  phase: typeof p.phase === "string" ? clean(p.phase) : prev?.phase,
210
227
  lastOutcome: p.lastOutcome
211
228
  ? {
212
229
  status: p.lastOutcome.status,
213
- ...(p.lastOutcome.message ? { message: truncateTail(p.lastOutcome.message) } : {}),
230
+ ...(p.lastOutcome.message ? { message: truncateStatusText(p.lastOutcome.message) } : {}),
214
231
  settledAt: p.lastOutcome.settledAt,
215
232
  }
216
233
  : prev?.lastOutcome,
@@ -227,12 +244,16 @@ export class AgentsStore {
227
244
  row.turnStartedAt = now;
228
245
  delete row.finishedAt;
229
246
  delete row.failedAt;
247
+ delete row.resultReadyAt;
230
248
  } else if (row.status === "sleeping") {
231
249
  row.finishedAt = row.lastOutcome?.settledAt ?? prev?.finishedAt ?? now;
232
250
  } else if (row.status !== "done" && row.status !== "failed" && row.status !== "terminated") {
233
251
  delete row.finishedAt;
234
252
  }
235
253
  this.roster.set(id, row);
254
+ if (row.status === "done" || row.status === "failed" || row.status === "terminated" || row.status === "sleeping") {
255
+ this.completedAt.set(id, row.finishedAt ?? now);
256
+ }
236
257
  }
237
258
 
238
259
  applyMessage(p: MessagePayload, now = Date.now()): void {
@@ -269,13 +290,16 @@ export class AgentsStore {
269
290
  ? { spawnedBy: p.correlationId }
270
291
  : {}),
271
292
  }, now);
293
+ // This same delta is the authoritative content for the self-healed row;
294
+ // a provisional sleeping seed must not tombstone itself before ingestion.
295
+ this.completedAt.delete(targetId);
272
296
  }
273
297
  const row = this.roster.get(targetId);
274
- if (!row) return;
298
+ if (!row || this.isTombstoned(targetId, now)) return;
275
299
  const progressActivity = p.progress?.find((progress) => progress.correlationId === targetId)?.lastActivityAt;
276
300
  row.lastActivityAt = normalizeStartedAt(p.lastActivityAt ?? progressActivity, now);
277
301
  const tail = p.lastMessage ?? p.message;
278
- if (typeof tail === "string" && tail.length > 0) row.tail = truncateTail(tail);
302
+ if (typeof tail === "string" && tail.length > 0) row.tail = truncateSessionContent(tail);
279
303
  if (p.recentTools) {
280
304
  const tool = latestTool(p.recentTools);
281
305
  if (tool) row.activeTool = tool;
@@ -287,7 +311,7 @@ export class AgentsStore {
287
311
  if (typeof p.outputTokens === "number") row.outputTokens = p.outputTokens;
288
312
  if (typeof p.cacheReadTokens === "number") row.cacheReadTokens = p.cacheReadTokens;
289
313
  if (typeof p.cacheWriteTokens === "number") row.cacheWriteTokens = p.cacheWriteTokens;
290
- if (typeof p.error === "string") row.error = truncateTail(p.error);
314
+ if (typeof p.error === "string") row.error = truncateStatusText(p.error);
291
315
  if (typeof p.requestedModel === "string") row.requestedModel = clean(p.requestedModel);
292
316
  if (typeof p.resolvedModel === "string") row.resolvedModel = clean(p.resolvedModel);
293
317
  if (Array.isArray(p.attemptedModels)) {
@@ -298,6 +322,10 @@ export class AgentsStore {
298
322
  row.status = mapAgentStatus(p.status);
299
323
  if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
300
324
  row.finishedAt ??= now;
325
+ this.completedAt.set(targetId, now);
326
+ } else if (row.status === "sleeping") {
327
+ row.finishedAt ??= now;
328
+ this.completedAt.set(targetId, now);
301
329
  } else {
302
330
  delete row.finishedAt;
303
331
  }
@@ -319,10 +347,11 @@ export class AgentsStore {
319
347
  for (const row of this.roster.values()) {
320
348
  if (row.parentCorrelationId === id) pending.push(row.correlationId);
321
349
  }
322
- // A failed agent used to be deleted the moment its result arrived, so the
323
- // only evidence of the failure disappeared in the same frame it appeared.
324
- // Successes still vanish immediately — the work has simply moved on.
350
+ // Every terminal row remains visible for one minute. Tombstone it now so
351
+ // late progress cannot regress the retained row back to running; an
352
+ // explicit started event clears the tombstone for a genuine new turn.
325
353
  const row = this.roster.get(id);
354
+ this.completedAt.set(id, now);
326
355
  const cancelledById = id === p.correlationId && p.cancelled === true;
327
356
  const failedByExitCode = id === p.correlationId
328
357
  && Number.isFinite(p.exitCode)
@@ -365,27 +394,30 @@ export class AgentsStore {
365
394
  row.failedAt = now;
366
395
  row.lastActivityAt = now;
367
396
  delete row.activeTool;
368
- } else {
369
- this.roster.delete(id);
370
- // Tombstone even when the row never existed: a `complete` that beats
371
- // the first delta must still suppress the self-heal that follows.
372
- this.completedAt.set(id, now);
397
+ } else if (row) {
398
+ row.status = "done";
399
+ row.taskStatus = "completed";
400
+ row.finishedAt = terminalTime(row, id === p.correlationId ? p : {}, now);
401
+ row.lastOutcome = { status: "completed", settledAt: row.finishedAt };
402
+ row.lastActivityAt = now;
403
+ delete row.activeTool;
404
+ delete row.failedAt;
373
405
  }
374
406
  }
375
407
  }
376
408
 
377
- /** Drop failed, terminated or expired sleeping rows that have had their time on screen. Returns true if any went. */
409
+ /** Drop terminal rows after their shared one-minute visible window. */
378
410
  prune(now = Date.now()): boolean {
379
411
  let changed = false;
380
412
  for (const [id, row] of this.roster) {
381
- if (row.failedAt !== undefined && now - row.failedAt >= FAILED_LINGER_MS) {
382
- this.roster.delete(id);
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;
387
- } else if (row.status === "sleeping" && row.finishedAt !== undefined && now - row.finishedAt >= SLEEPING_LINGER_MS) {
413
+ const terminal = row.status === "done"
414
+ || row.status === "failed"
415
+ || row.status === "terminated"
416
+ || row.status === "sleeping";
417
+ const settledAt = this.completedAt.get(id);
418
+ if (terminal && settledAt !== undefined && now - settledAt >= AGENT_LINGER_MS) {
388
419
  this.roster.delete(id);
420
+ if (this.viewingId === id) this.viewingId = undefined;
389
421
  changed = true;
390
422
  }
391
423
  }
@@ -395,12 +427,13 @@ export class AgentsStore {
395
427
  return changed;
396
428
  }
397
429
 
398
- /** True while a failed, terminated or sleeping row is still counting down, so the redraw loop must run. */
430
+ /** True while a terminal row is counting down, so the redraw loop can expire it. */
399
431
  hasLingering(): boolean {
400
432
  for (const row of this.roster.values()) {
401
- if (row.failedAt !== undefined) return true;
402
- if (row.status === "terminated") return true;
403
- if (row.status === "sleeping") return true;
433
+ if (row.status === "done"
434
+ || row.status === "failed"
435
+ || row.status === "terminated"
436
+ || row.status === "sleeping") return true;
404
437
  }
405
438
  return false;
406
439
  }
@@ -414,6 +447,7 @@ export class AgentsStore {
414
447
  }
415
448
 
416
449
  private applyProgress(parentCorrelationId: string, p: ProgressPayload, now: number): void {
450
+ if (this.isTombstoned(p.correlationId, now)) return;
417
451
  if (!this.roster.has(p.correlationId)
418
452
  && this.canMaterialize(p.correlationId, parentCorrelationId, now)) {
419
453
  // Self-heal: see applyMessage. A graph child whose `started` delta was
@@ -448,6 +482,10 @@ export class AgentsStore {
448
482
  row.status = mapAgentStatus(p.status);
449
483
  if (row.status === "done" || row.status === "failed" || row.status === "terminated") {
450
484
  row.finishedAt = terminalTime(row, p, now);
485
+ this.completedAt.set(p.correlationId, now);
486
+ } else if (row.status === "sleeping") {
487
+ row.finishedAt = terminalTime(row, p, now);
488
+ this.completedAt.set(p.correlationId, now);
451
489
  } else {
452
490
  delete row.finishedAt;
453
491
  }
@@ -472,7 +510,7 @@ export class AgentsStore {
472
510
  row.cacheReadTokens = p.cacheReadTokens;
473
511
  row.cacheWriteTokens = p.cacheWriteTokens;
474
512
  if (p.error === undefined) delete row.error;
475
- else row.error = truncateTail(p.error);
513
+ else row.error = truncateStatusText(p.error);
476
514
  if (p.requestedModel === undefined) delete row.requestedModel;
477
515
  else row.requestedModel = clean(p.requestedModel);
478
516
  if (p.resolvedModel === undefined) delete row.resolvedModel;
@@ -480,7 +518,7 @@ export class AgentsStore {
480
518
  if (p.attemptedModels === undefined) delete row.attemptedModels;
481
519
  else row.attemptedModels = p.attemptedModels.map((model) => clean(model)).filter(Boolean);
482
520
  if (typeof p.lastMessage === "string" && p.lastMessage.length > 0) {
483
- row.tail = truncateTail(p.lastMessage);
521
+ row.tail = truncateSessionContent(p.lastMessage);
484
522
  }
485
523
  }
486
524
 
@@ -491,7 +529,19 @@ export class AgentsStore {
491
529
  return [...this.roster.values()].sort((a, b) => {
492
530
  const activity = b.lastActivityAt - a.lastActivityAt;
493
531
  return activity || a.correlationId.localeCompare(b.correlationId);
494
- });
532
+ }).map((row) =>
533
+ row.correlationId === this.viewingId ? { ...row, viewing: true } : row,
534
+ );
535
+ }
536
+
537
+ /** Mark which agent the teammate viewing view currently shows. */
538
+ setViewingAgent(correlationId: string | undefined): void {
539
+ this.viewingId = correlationId;
540
+ }
541
+
542
+ /** Correlation id of the agent currently shown in the viewing view. */
543
+ getViewingAgent(): string | undefined {
544
+ return this.viewingId;
495
545
  }
496
546
 
497
547
  get size(): number {
@@ -505,5 +555,6 @@ export class AgentsStore {
505
555
  clear(): void {
506
556
  this.roster.clear();
507
557
  this.completedAt.clear();
558
+ this.viewingId = undefined;
508
559
  }
509
560
  }