@ferris1225/pi-subagents 0.32.2 → 1.0.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/src/fixloop.ts CHANGED
@@ -86,22 +86,6 @@ export function chainKeyFragments(result: SingleResult): string[] {
86
86
  return extractKeyFragments(getResultOutput(result)).slice(0, CHAIN_SUMMARY_FRAGMENTS_MAX);
87
87
  }
88
88
 
89
- /**
90
- * Compact one-line outcome for a finished chain run, shown in the widget so
91
- * each round reads as what it did: a reviewer reports its verdict plus the
92
- * key fragments of what it found ("fail · src/index.ts · render()"), a worker
93
- * the fragments of what it changed. Failed runs and runs with nothing
94
- * distinctive get no summary.
95
- */
96
- export function summarizeChainResult(result: SingleResult): string | undefined {
97
- if (isFailedResult(result)) return undefined;
98
- const verdict = result.agent === "reviewer" ? reviewVerdict(getResultOutput(result)) : undefined;
99
- if (verdict === "pass") return "pass";
100
- const fragments = chainKeyFragments(result);
101
- if (verdict === "fail") return fragments.length > 0 ? `fail · ${fragments.join(" · ")}` : "fail";
102
- return fragments.length > 0 ? fragments.join(" · ") : undefined;
103
- }
104
-
105
89
  /**
106
90
  * Condensed, readable summary of a completed auto-fix chain: one line per step
107
91
  * (run id, role, verdict / what changed) plus aggregate usage. Full per-step
package/src/format.ts CHANGED
@@ -6,6 +6,7 @@
6
6
 
7
7
  import type { AgentConfig } from "./agents.ts";
8
8
  import { formatTaskSummary } from "./monitor.ts";
9
+ import { emptyUsage } from "./rpc-run.ts";
9
10
  import {
10
11
  getResultOutput,
11
12
  isFailedResult,
@@ -15,14 +16,9 @@ import {
15
16
  type UsageStats,
16
17
  } from "./spawn.ts";
17
18
 
18
- export function emptyUsage(): UsageStats {
19
- return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
20
- }
21
-
22
19
  export function queuedResult(agent: AgentConfig, task: string, thinking?: string): SingleResult {
23
20
  return {
24
21
  agent: agent.name,
25
- agentSource: agent.source,
26
22
  task,
27
23
  exitCode: -1,
28
24
  messages: [],
@@ -36,7 +32,6 @@ export function queuedResult(agent: AgentConfig, task: string, thinking?: string
36
32
  export function failedStartResult(agentName: string, task: string, errorMessage: string): SingleResult {
37
33
  return {
38
34
  agent: agentName,
39
- agentSource: "unknown",
40
35
  task,
41
36
  exitCode: 1,
42
37
  messages: [],
@@ -61,7 +56,7 @@ export function dispatchFailedResult(agent: AgentConfig, task: string, error: un
61
56
  };
62
57
  }
63
58
 
64
- export function formatTokens(count: number): string {
59
+ function formatTokens(count: number): string {
65
60
  if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`;
66
61
  if (count >= 1_000) return `${(count / 1_000).toFixed(1)}k`;
67
62
  return String(count);
@@ -103,7 +98,8 @@ export function formatCompletionBlock(result: SingleResult, maxResultLines: numb
103
98
  (result.forkChildRunIds?.length ?? 0) > 0 ? `fork children ${result.forkChildRunIds!.map((id) => `#${id}`).join(", ")}` : undefined,
104
99
  ].filter((value): value is string => Boolean(value));
105
100
  const relationNote = relations.length > 0 ? ` · ${relations.join(" · ")}` : "";
106
- const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
101
+ const runNote = result.runId !== undefined ? ` · run #${result.runId}` : "";
102
+ const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}${startupRetryNote}${modelRetryNote}${runNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, ""];
107
103
  if (result.isolation === "worktree") {
108
104
  const isolation =
109
105
  result.integrationStatus === "integrated"
package/src/index.ts CHANGED
@@ -5,8 +5,9 @@
5
5
  * The heavy lifting lives in focused modules:
6
6
  * - dispatch.ts — the `subagent` tool (spawn, auto-fix chain, vision model)
7
7
  * - tools.ts — subagent_control / subagent_wait / status / stop
8
- * - widget.ts session_start widget + one-time feature announcements
9
- * - runtime.ts shared per-session state
8
+ * - announcements.ts session-start recovery, notices, and widget install
9
+ * - widget.ts active-only TUI run status
10
+ * - runtime.ts — shared per-session state
10
11
  *
11
12
  * Also registers the `/subagents-setup` command and a `before_agent_start` hook
12
13
  * that injects a delegation directive into the parent system prompt so the main
@@ -19,16 +20,16 @@
19
20
  import { getAgentDir, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
20
21
  import { Text } from "@earendil-works/pi-tui";
21
22
  import { discoverAgents } from "./agents.ts";
23
+ import { registerAnnouncements } from "./announcements.ts";
22
24
  import { getConfigPath, loadConfig } from "./config.ts";
23
25
  import { registerSubagentTool } from "./dispatch.ts";
24
26
  import { matchRunIds } from "./format.ts";
25
- import { registerInspectorCommand } from "./inspector-panel.ts";
26
27
  import { buildDelegationDirective } from "./prompt.ts";
27
28
  import { createRuntime } from "./runtime.ts";
28
29
  import { runSetup } from "./setup.ts";
29
30
  import { currentSubagentDepth } from "./spawn.ts";
30
31
  import { registerLookupTools } from "./tools.ts";
31
- import { registerWidget } from "./widget.ts";
32
+ import { clearActiveRunsWidget } from "./widget.ts";
32
33
 
33
34
  export { matchRunIds };
34
35
 
@@ -58,13 +59,13 @@ export default function (pi: ExtensionAPI): void {
58
59
  ),
59
60
  );
60
61
 
61
- pi.on("session_shutdown", async () => {
62
+ pi.on("session_shutdown", async (_event, ctx) => {
63
+ clearActiveRunsWidget(ctx);
62
64
  await runtime.shutdown();
63
65
  });
64
66
 
65
67
  registerSubagentTool(pi, runtime);
66
68
  registerLookupTools(pi, runtime);
67
- registerInspectorCommand(pi, runtime);
68
69
 
69
70
  pi.registerCommand("subagents-setup", {
70
71
  description: "Configure pi-subagents: enabled agents, primary/backup model pools, and runtime settings",
@@ -73,9 +74,7 @@ export default function (pi: ExtensionAPI): void {
73
74
  },
74
75
  });
75
76
 
76
- // Persistent widget above the editor showing live sub-agent status, plus
77
- // one-time feature announcements after updates.
78
- registerWidget(pi, runtime);
77
+ registerAnnouncements(pi, runtime);
79
78
 
80
79
  // Proactive dispatch: inject the delegation directive into the parent system prompt.
81
80
  pi.on("before_agent_start", async (event, ctx) => {
package/src/models.ts CHANGED
@@ -22,8 +22,6 @@ export interface ModelPickerItem {
22
22
  value: string;
23
23
  label: string;
24
24
  description?: string;
25
- /** Visible for diagnosis/search, but cannot be selected. */
26
- disabled?: boolean;
27
25
  }
28
26
 
29
27
  export type ModelListEntry = Pick<
@@ -72,19 +70,18 @@ export function currentModelRef(ctx: Pick<ModelContext, "model">): string | unde
72
70
  }
73
71
 
74
72
  /**
75
- * Models usable by this main window. Scoped models replace the full available
76
- * registry, matching pi's built-in model picker semantics.
73
+ * Current authenticated registry models narrowed by the session scope. Scope
74
+ * entries are a session snapshot, so they act only as a whitelist; the live
75
+ * registry remains the source of truth for availability and model metadata.
77
76
  */
78
- export function availableModelRefs(ctx: ModelContext): string[] {
77
+ export function availableModelsInScope(ctx: ModelContext): readonly Model<Api>[] {
78
+ const models = ctx.modelRegistry.getAvailable();
79
79
  // scopedModels was added after the declared Pi 0.80.6 minimum. Treat a
80
- // missing field exactly like an empty scope and use the registry fallback.
80
+ // missing field exactly like an empty scope and use the full live registry.
81
81
  const scopedModels = ctx.scopedModels ?? [];
82
- const scoped = scopedModels.length > 0 ? scopedModels.map((entry) => entry.model) : undefined;
83
- const models = scoped ?? ctx.modelRegistry.getAvailable();
84
- const refs = [...new Set(models.map(modelRef))];
85
- const currentRef = currentModelRef(ctx);
86
- if (!currentRef) return refs;
87
- return [currentRef, ...refs.filter((ref) => ref !== currentRef)];
82
+ if (scopedModels.length === 0) return models;
83
+ const scopedRefs = new Set(scopedModels.map((entry) => modelRef(entry.model)));
84
+ return models.filter((model) => scopedRefs.has(modelRef(model)));
88
85
  }
89
86
 
90
87
  /**
@@ -118,7 +115,9 @@ function modelCapabilities(model: ModelListEntry): string {
118
115
  return capabilities.join(" + ");
119
116
  }
120
117
 
121
- /** Build the single searchable model list shared by primary/backup/vision picks. */
118
+ /** Build the single searchable model list shared by primary/backup/vision picks.
119
+ * Only refs Pi currently reports as available are shown, which means providers
120
+ * without a configured API key/OAuth session never flood the setup picker. */
122
121
  export function buildModelPickerItems(options: {
123
122
  models: readonly ModelListEntry[];
124
123
  availableRefs: readonly string[];
@@ -132,21 +131,16 @@ export function buildModelPickerItems(options: {
132
131
  const byRef = new Map<string, ModelListEntry>();
133
132
  for (const model of options.models) {
134
133
  const ref = modelRef(model);
135
- if (!byRef.has(ref)) byRef.set(ref, model);
134
+ if (available.has(ref) && !byRef.has(ref)) byRef.set(ref, model);
136
135
  }
137
136
 
138
137
  const refs = [...byRef.keys()]
139
- .filter((ref) =>
140
- options.slot !== "vision" ||
141
- byRef.get(ref)?.input.includes("image") === true ||
142
- ref === configuredRef,
143
- )
138
+ .filter((ref) => options.slot !== "vision" || byRef.get(ref)?.input.includes("image") === true)
144
139
  .sort((left, right) => {
145
140
  const leftRank = left === configuredRef ? 0 : left === mainRef ? 1 : 2;
146
141
  const rightRank = right === configuredRef ? 0 : right === mainRef ? 1 : 2;
147
142
  return leftRank - rightRank || left.localeCompare(right);
148
143
  });
149
- if (configuredRef && !byRef.has(configuredRef)) refs.unshift(configuredRef);
150
144
 
151
145
  const dynamic = options.slot === "backup"
152
146
  ? {
@@ -164,31 +158,15 @@ export function buildModelPickerItems(options: {
164
158
 
165
159
  const items: ModelPickerItem[] = [dynamic];
166
160
  for (const ref of refs) {
167
- const model = byRef.get(ref);
168
- const tags = [available.has(ref) ? "available" : "unavailable"];
161
+ const model = byRef.get(ref)!;
162
+ const tags = ["available"];
169
163
  if (ref === configuredRef) tags.push("configured");
170
164
  if (ref === mainRef) tags.push("current main");
171
- if (!model) {
172
- const compatibility = options.slot === "vision"
173
- ? "incompatible with vision (capability unknown)"
174
- : undefined;
175
- items.push({
176
- value: ref,
177
- label: ref,
178
- description: [...tags, compatibility, "saved model reference"].filter(Boolean).join(" · "),
179
- ...(options.slot === "vision" ? { disabled: true } : {}),
180
- });
181
- continue;
182
- }
183
165
  const name = model.name.trim() && model.name !== model.id ? model.name.trim() : undefined;
184
- const compatibility = options.slot === "vision" && !model.input.includes("image")
185
- ? "incompatible with vision"
186
- : undefined;
187
166
  items.push({
188
167
  value: ref,
189
168
  label: ref,
190
- description: [name, modelCapabilities(model), compatibility, ...tags].filter(Boolean).join(" · "),
191
- ...(compatibility ? { disabled: true } : {}),
169
+ description: [name, modelCapabilities(model), ...tags].filter(Boolean).join(" · "),
192
170
  });
193
171
  }
194
172
  return items;
package/src/monitor.ts CHANGED
@@ -2,18 +2,16 @@
2
2
  * Sub-agent monitor: a module-level singleton store that tracks subagent runs
3
3
  * for the current turn.
4
4
  *
5
- * The store notifies subscribers on every mutation so the persistent widget
6
- * above the editor can re-render. Each run carries timing information
7
- * (started/ended) plus a concise activity string describing what the run is
8
- * doing right now ("thinking", "read src/index.ts", ...). Runs are removed
9
- * as soon as they finish: the tool result is the durable record in the main
10
- * conversation, so a stale "done" row must not linger in the widget.
5
+ * The store notifies wait/status consumers on every mutation. Each run carries
6
+ * timing information plus a concise activity string ("thinking",
7
+ * "read src/index.ts", ...). Runs are removed after publication; tool results
8
+ * and the finished-run registry are the durable user-facing records.
11
9
  */
12
10
 
13
11
  import { stripVTControlCharacters } from "node:util";
14
12
  import type { Theme } from "@earendil-works/pi-coding-agent";
15
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
16
- import type { UsageStats } from "./spawn.ts";
13
+ import { visibleWidth } from "@earendil-works/pi-tui";
14
+ import { emptyUsage, type UsageStats } from "./rpc-run.ts";
17
15
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
18
16
 
19
17
  // ---------------------------------------------------------------------------
@@ -26,20 +24,6 @@ export function isRunActiveStatus(status: RunStatus): boolean {
26
24
  return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
27
25
  }
28
26
 
29
- /** Soft state-awareness signals, complementary to the hard idle-kill: a run may
30
- * be alive (stdout streaming) yet "stuck thinking" (no tool running for a while),
31
- * or simply taking a long time. Both are surfaced as widget annotations so the
32
- * user can tell a healthy busy run from one that needs a nudge. */
33
- export type ActivityState = "needs_attention" | "active_long_running";
34
-
35
- /** A run with no tool running and no activity for this long is "needs attention"
36
- * (the model may be stuck between turns). Below the idle-kill threshold so the
37
- * soft signal always fires before the hard kill. */
38
- export const NEEDS_ATTENTION_AFTER_MS = 60_000;
39
- /** A run whose total elapsed time exceeds this is "long-running": still active
40
- * but worth flagging so the user can decide whether to wait or steer. */
41
- export const ACTIVE_LONG_RUNNING_AFTER_MS = 240_000;
42
-
43
27
  export interface RunView {
44
28
  id: number;
45
29
  agent: string;
@@ -61,14 +45,6 @@ export interface RunView {
61
45
  usage: UsageStats;
62
46
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
63
47
  activity?: string;
64
- /** Total tool calls started by the run so far (a progress signal). */
65
- toolCount?: number;
66
- /** Tool currently executing (set on tool_start, cleared on tool_end). When set,
67
- * the run is NOT idle for needs-attention purposes. */
68
- currentTool?: string;
69
- /** Epoch ms of the last live activity (tool, usage, status). Used to derive
70
- * the needs-attention state: no tool running AND now - lastActivityAt > threshold. */
71
- lastActivityAt?: number;
72
48
  /** Epoch ms when the run started executing (set on first "running" status). */
73
49
  startedAt?: number;
74
50
  /** Epoch ms when the run finished (set on "done"/"failed"). */
@@ -77,17 +53,6 @@ export interface RunView {
77
53
  groupId?: string;
78
54
  /** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
79
55
  relationLabel?: string;
80
- /** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
81
- annotation?: string;
82
- /** One-line outcome summary of a finished chain run, shown in the widget so
83
- * each auto-fix round reads as what it did: a reviewer reports its verdict
84
- * plus key fragments of what it found ("fail · src/index.ts · render()"), a
85
- * worker the fragments of what it changed. Unset for non-chain runs. */
86
- summary?: string;
87
- /** True when a finished run is intentionally kept in the widget (e.g. an
88
- * auto-fix chain parent whose chain is still running). beginTurn preserves
89
- * retained runs so they are not swept between turns. */
90
- retained?: boolean;
91
56
  }
92
57
 
93
58
  /** Optional chain metadata for runs spawned by an auto-fix loop. */
@@ -107,7 +72,7 @@ const TASK_SUMMARY_ELLIPSIS = "…";
107
72
  /** Columns reserved at the END of a truncated summary so the distinguishing
108
73
  * keywords (paths, symbols, ...) survive; the head gets the rest. */
109
74
  const TASK_SUMMARY_TAIL_MAX = 28;
110
- /** Tail share of a non-default maxWidth (narrow widgets keep a usable tail). */
75
+ /** Tail share of a non-default maxWidth (narrow summaries keep a usable tail). */
111
76
  const TASK_SUMMARY_TAIL_SHARE = 0.35;
112
77
  const TASK_SUMMARY_TAIL_MIN = 8;
113
78
  const TASK_SUMMARY_KEY_SEP = " · ";
@@ -296,72 +261,49 @@ export function formatDuration(ms: number): string {
296
261
  /** Elapsed wall time of a run: live while running, final once finished. */
297
262
  export function formatElapsed(run: RunView, now: number = Date.now()): string {
298
263
  if (run.startedAt === undefined) return "";
299
- // A retained row (e.g. an auto-fix chain parent whose chain is still running)
300
- // must keep ticking: its `endedAt` was stamped when the review itself
301
- // finished, but the work is ongoing, so show live elapsed until the chain
302
- // resolves and the row is removed. Without this, subagent_status would show a
303
- // frozen elapsed for a run the UI otherwise presents as still active.
304
- const end = run.retained ? now : (run.endedAt ?? now);
305
- return formatDuration(end - run.startedAt);
306
- }
307
-
308
- /** Strip the provider prefix from a "provider/model-id" reference for compact
309
- * widget display ("anthropic/claude-sonnet-4" → "claude-sonnet-4"). A bare id is
310
- * left unchanged. */
311
- export function compactModelRef(model: string | undefined): string {
312
- if (!model) return "";
313
- const slash = model.lastIndexOf("/");
314
- return slash >= 0 ? model.slice(slash + 1) : model;
264
+ return formatDuration((run.endedAt ?? now) - run.startedAt);
315
265
  }
316
266
 
317
- /** Human-readable label for a soft activity-state annotation. */
318
- export function activityStateLabel(state: ActivityState): string {
319
- return state === "needs_attention" ? "idle" : "long-running";
320
- }
267
+ /** Max length of the argument target inside a formatted activity line. */
268
+ export const ACTIVITY_TARGET_MAX = 60;
321
269
 
322
- /** Derive the soft activity state of a run at render time: needs_attention
323
- * (no tool running, idle past the threshold) takes priority over
324
- * active_long_running (total elapsed past its threshold). Both are suppressed
325
- * for non-running runs. */
326
- export function deriveActivityState(run: RunView, now: number = Date.now()): ActivityState | undefined {
327
- if (run.status !== "running" && run.status !== "steering") return undefined;
328
- if (!run.currentTool) {
329
- const since = run.lastActivityAt ?? run.startedAt ?? now;
330
- if (now - since >= NEEDS_ATTENTION_AFTER_MS) return "needs_attention";
331
- }
332
- if (run.startedAt !== undefined && now - run.startedAt >= ACTIVE_LONG_RUNNING_AFTER_MS) {
333
- return "active_long_running";
334
- }
335
- return undefined;
336
- }
270
+ const REDACTED = "<redacted>";
337
271
 
338
- /** Left/right split a widget line so the right side (status, elapsed) is always
339
- * visible and the left side (title) clips on overflow instead of pushing it off.
340
- * `left`/`right` may carry ANSI styling; widths are measured display-column-wise. */
341
- export function rightAlign(left: string, right: string, width: number): string {
342
- const rightWidth = visibleWidth(right);
343
- const leftMax = Math.max(0, width - rightWidth - 1);
344
- const leftClipped = truncateToWidth(left, leftMax);
345
- const gap = Math.max(1, width - visibleWidth(leftClipped) - rightWidth);
346
- return truncateToWidth(`${leftClipped}${" ".repeat(gap)}${right}`, width);
272
+ /** Remove credentials embedded in otherwise ordinary activity strings such as
273
+ * shell commands and HTTP headers. */
274
+ function redactSensitiveText(value: string): string {
275
+ let text = stripVTControlCharacters(value);
276
+ text = text.replace(
277
+ /(\bauthorization\s*:\s*(?:bearer|basic)\s+)([^\s'"`;,]+)/giu,
278
+ `$1${REDACTED}`,
279
+ );
280
+ text = text.replace(
281
+ /(\bbearer\s+)([A-Za-z0-9._~+/=-]{6,})/giu,
282
+ `$1${REDACTED}`,
283
+ );
284
+ text = text.replace(
285
+ /(\b(?:api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|token|password|passwd|secret|credential|cookie)\b\s*(?:=|:)\s*)(?:"[^"]*"|'[^']*'|[^\s;&,]+)/giu,
286
+ `$1${REDACTED}`,
287
+ );
288
+ text = text.replace(
289
+ /((?:--?(?:api[-_]?key|access[-_]?token|token|password|secret|credential))\s+)(?:"[^"]*"|'[^']*'|\S+)/giu,
290
+ `$1${REDACTED}`,
291
+ );
292
+ return text.replace(
293
+ /\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[A-Z0-9]{16})\b/gu,
294
+ REDACTED,
295
+ );
347
296
  }
348
297
 
349
- /** Concatenate `left` and `right` (no center padding) and clip the combined line
350
- * to `width`. Unlike {@link rightAlign}, the right side trails the left side
351
- * instead of being pinned to the far edge, so a short header leaves no gap in
352
- * the middle. `right` should carry its own leading separator (e.g. ` \u00b7 `);
353
- * pass an empty string when there is nothing to append. Styled strings are
354
- * measured by display width. */
355
- export function compactLine(left: string, right: string, width: number): string {
356
- return truncateToWidth(`${left}${right}`, width);
298
+ /** Monitor activity is returned to the parent model and rendered in the terminal,
299
+ * so treat every live string as untrusted before it reaches store state. */
300
+ function sanitizeActivityText(value: string): string {
301
+ return redactSensitiveText(value).replace(/\s+/g, " ").trim();
357
302
  }
358
303
 
359
- /** Max length of the argument target inside a formatted activity line. */
360
- export const ACTIVITY_TARGET_MAX = 60;
361
-
362
304
  function shortTarget(value: unknown): string {
363
305
  if (typeof value !== "string") return "";
364
- const oneLine = value.replace(/\s+/g, " ").trim();
306
+ const oneLine = sanitizeActivityText(value);
365
307
  // Slice by code point so emoji / CJK-ext never leave a lone surrogate.
366
308
  const chars = [...oneLine];
367
309
  return chars.length > ACTIVITY_TARGET_MAX ? `${chars.slice(0, ACTIVITY_TARGET_MAX - 1).join("")}…` : oneLine;
@@ -410,7 +352,8 @@ export function formatToolActivity(toolName: string, args: unknown): string {
410
352
  default:
411
353
  target = pick("path", "command", "query", "pattern", "url", "file", "task");
412
354
  }
413
- return target ? `${toolName} ${target}` : toolName;
355
+ const safeToolName = sanitizeActivityText(toolName) || "tool";
356
+ return target ? `${safeToolName} ${target}` : safeToolName;
414
357
  }
415
358
 
416
359
  // ---------------------------------------------------------------------------
@@ -423,19 +366,22 @@ export class MonitorStore {
423
366
  private subscribers = new Set<() => void>();
424
367
 
425
368
  beginTurn(): void {
426
- // Clear finished runs from a previous turn, but keep any still-active
427
- // (queued/running) ones so a concurrent sub-agent call is not wiped.
428
- // Retained runs (e.g. an auto-fix chain parent whose chain is still
429
- // running) are also preserved — their status is "done" but they must
430
- // stay visible until the chain resolves.
369
+ // Clear finished runs from a previous turn, but keep active and parked
370
+ // threads so concurrent work is not wiped between parent turns.
431
371
  this.runs = this.runs.filter(
432
- (r) => isRunActiveStatus(r.status) || r.status === "parked" || r.retained,
372
+ (r) => isRunActiveStatus(r.status) || r.status === "parked",
433
373
  );
434
374
  this.notify();
435
375
  }
436
376
 
377
+ /** Reserve a stable id for a durable result that must remain independently
378
+ * addressable without appearing as a live monitor row. */
379
+ reserveRunId(): number {
380
+ return this.nextId++;
381
+ }
382
+
437
383
  addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
438
- const id = this.nextId++;
384
+ const id = this.reserveRunId();
439
385
  this.runs.push({
440
386
  id,
441
387
  agent,
@@ -444,7 +390,7 @@ export class MonitorStore {
444
390
  model,
445
391
  thinking,
446
392
  status: "queued",
447
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
393
+ usage: emptyUsage(),
448
394
  ...(meta?.groupId ? { groupId: meta.groupId } : {}),
449
395
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
450
396
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
@@ -463,7 +409,6 @@ export class MonitorStore {
463
409
  // A model-fallback retry or resumed generation restarts the clock; a
464
410
  // stale endedAt would freeze the elapsed display at the first attempt.
465
411
  if (run.endedAt !== undefined) run.endedAt = undefined;
466
- run.lastActivityAt = Date.now();
467
412
  } else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
468
413
  run.endedAt = Date.now();
469
414
  }
@@ -474,7 +419,6 @@ export class MonitorStore {
474
419
  if (!run) return;
475
420
  run.usage = { ...usage };
476
421
  if (model) run.model = model;
477
- run.lastActivityAt = Date.now();
478
422
  this.notify();
479
423
  }
480
424
 
@@ -491,56 +435,25 @@ export class MonitorStore {
491
435
  setActivity(id: number, text: string): void {
492
436
  const run = this.find(id);
493
437
  if (!run) return;
494
- run.activity = text;
495
- run.lastActivityAt = Date.now();
438
+ run.activity = sanitizeActivityText(text) || undefined;
496
439
  this.notify();
497
440
  }
498
441
 
499
- /** Record a tool starting: counts it, marks it current, and updates activity.
500
- * A running tool means the run is NOT idle, so needs-attention is suppressed
501
- * while it stays current. */
442
+ /** Record a tool starting and update the run's visible activity. */
502
443
  recordToolStart(id: number, toolName: string, activity: string): void {
503
444
  const run = this.find(id);
504
445
  if (!run) return;
505
- run.toolCount = (run.toolCount ?? 0) + 1;
506
- run.currentTool = toolName;
507
- run.activity = activity;
508
- run.lastActivityAt = Date.now();
446
+ const safeToolName = sanitizeActivityText(toolName) || "tool";
447
+ run.activity = sanitizeActivityText(activity) || safeToolName;
509
448
  this.notify();
510
449
  }
511
450
 
512
- /** Record a tool ending: clears the current-tool marker (so the run becomes
513
- * eligible for needs-attention again) and notes the failure in activity. */
451
+ /** Record a failed tool; successful completions keep their last activity
452
+ * until the next model event supplies a more useful description. */
514
453
  recordToolEnd(id: number, toolName: string, isError: boolean): void {
515
454
  const run = this.find(id);
516
455
  if (!run) return;
517
- run.currentTool = undefined;
518
- run.lastActivityAt = Date.now();
519
- if (isError) run.activity = `✗ ${toolName} failed`;
520
- this.notify();
521
- }
522
-
523
- /** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
524
- setAnnotation(id: number, text: string): void {
525
- const run = this.find(id);
526
- if (!run) return;
527
- run.annotation = text;
528
- this.notify();
529
- }
530
-
531
- /** Set the run's one-line outcome summary (what a finished chain round did). */
532
- setSummary(id: number, text: string | undefined): void {
533
- const run = this.find(id);
534
- if (!run) return;
535
- run.summary = text;
536
- this.notify();
537
- }
538
-
539
- /** Mark a run as retained (kept in the widget despite being finished). */
540
- setRetained(id: number, retained: boolean): void {
541
- const run = this.find(id);
542
- if (!run) return;
543
- run.retained = retained;
456
+ if (isError) run.activity = `✗ ${sanitizeActivityText(toolName) || "tool"} failed`;
544
457
  this.notify();
545
458
  }
546
459
 
@@ -585,7 +498,7 @@ export class MonitorStore {
585
498
  thinking,
586
499
  ...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
587
500
  status: "queued",
588
- usage: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
501
+ usage: emptyUsage(),
589
502
  });
590
503
  this.notify();
591
504
  return;
@@ -598,16 +511,10 @@ export class MonitorStore {
598
511
  if (isolation) run.isolation = isolation;
599
512
  run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
600
513
  run.status = "queued";
601
- run.usage = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
514
+ run.usage = emptyUsage();
602
515
  run.activity = undefined;
603
- run.toolCount = undefined;
604
- run.currentTool = undefined;
605
- run.lastActivityAt = undefined;
606
516
  run.startedAt = undefined;
607
517
  run.endedAt = undefined;
608
- run.annotation = undefined;
609
- run.summary = undefined;
610
- run.retained = undefined;
611
518
  this.notify();
612
519
  }
613
520
 
@@ -624,7 +531,7 @@ export class MonitorStore {
624
531
  this.notify();
625
532
  }
626
533
 
627
- /** Remove a run (finished runs leave the widget). Returns the removed run. */
534
+ /** Remove a run after publication. Returns the removed run. */
628
535
  removeRun(id: number): RunView | undefined {
629
536
  const index = this.runs.findIndex((r) => r.id === id);
630
537
  if (index === -1) return undefined;
@@ -648,7 +555,6 @@ export class MonitorStore {
648
555
  const usage = formatUsageCompact(run.usage);
649
556
  const parts = [run.agent];
650
557
  if (run.relationLabel) parts.push(run.relationLabel);
651
- if (run.summary) parts.push(run.summary);
652
558
  if (run.model) parts.push(run.model);
653
559
  if (run.thinking) parts.push(`thinking ${run.thinking}`);
654
560
  if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
@@ -698,7 +604,7 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
698
604
  }
699
605
  }
700
606
 
701
- /** User-facing status label shown in the widget. */
607
+ /** User-facing status label used by tool/status rendering. */
702
608
  export function statusLabel(status: RunStatus): string {
703
609
  switch (status) {
704
610
  case "queued":
@@ -717,20 +623,3 @@ export function statusLabel(status: RunStatus): string {
717
623
  return "stopped";
718
624
  }
719
625
  }
720
-
721
- /** Theme color matching the status label. */
722
- export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
723
- switch (status) {
724
- case "running":
725
- case "steering":
726
- return "accent";
727
- case "interrupting":
728
- return "warning";
729
- case "done":
730
- return "success";
731
- case "failed":
732
- return "error";
733
- default:
734
- return "dim";
735
- }
736
- }