pi-jar 0.1.1 → 0.1.3

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/composer.ts CHANGED
@@ -4,6 +4,11 @@ import { Mascot, MASCOT_FACE_WIDTH, mascotFace, paintFace, paintTip, type Mascot
4
4
  import type { SuggestionState } from "./suggest.ts";
5
5
  import type { WorkingPhase } from "./working.ts";
6
6
  import { imageChip, imageInfo, imagePaths } from "./attachments.ts";
7
+ // Aliased: roundedInput has a parameter named `icon`.
8
+ import { icon as glyph, type IconKey } from "./icons.ts";
9
+
10
+ /** `⏎ send`; ascii spells the key out (`enter send`). */
11
+ const keyHint = (key: IconKey, action: string) => glyph(key) + " " + action;
7
12
 
8
13
  type EditorFactory = NonNullable<ReturnType<ExtensionContext["ui"]["getEditorComponent"]>>;
9
14
  type MouseEvent = Parameters<NonNullable<EditorComponent["handleMouse"]>>[0];
@@ -12,6 +17,8 @@ const PHASE_MOODS: Record<WorkingPhase, readonly MascotMood[]> = {
12
17
  idle: ["idle"], generating: ["happy", "thinking"], tool: ["tool"], waiting: ["waiting"]
13
18
  };
14
19
  export const COMPOSER_ACTIVE_INTERVAL_MS = 600;
20
+ /** How long composer image chips trust a previous filesystem check for the same draft. */
21
+ const ATTACHMENT_RECHECK_MS = 1000;
15
22
  /** Plain one-line face for a phase; every phase has the same width. */
16
23
  export function composerIcon(phase: WorkingPhase, frame = 0): string {
17
24
  const moods = PHASE_MOODS[phase];
@@ -95,7 +102,9 @@ export function roundedInput(lines: string[], width: number, focused: boolean, t
95
102
  if (index === 0 && options.ghost && row.includes(CURSOR)) {
96
103
  const base = row.replace(/ +$/, "");
97
104
  const room = inner - visibleWidth(base);
98
- if (room > 2) row = base + dim(truncateToWidth(options.ghost + " ⇥ tab", room, "…"));
105
+ // ascii's tab "icon" is already the word.
106
+ const tab = glyph("tab") === "tab" ? "tab" : keyHint("tab", "tab");
107
+ if (room > 2) row = base + dim(truncateToWidth(options.ghost + " " + tab, room, "…"));
99
108
  }
100
109
  const fitted = truncateToWidth(row, inner);
101
110
  return border("│") + fitted + " ".repeat(Math.max(0, inner - visibleWidth(fitted))) + border("│");
@@ -159,7 +168,9 @@ class RoundedEditor extends CustomEditor {
159
168
 
160
169
  override render(width: number): string[] {
161
170
  const inner = this.renderTall(width < 8 ? width : width - 2);
162
- const empty = this.getText() === "";
171
+ const text = this.getText();
172
+ const empty = text === "";
173
+ const below = this.decor.attachments(text);
163
174
  const ghost = empty && !this.isShowingAutocomplete() ? this.decor.ghost() : undefined;
164
175
  this.ghostShown = !!ghost;
165
176
  const visibleRows = (this as unknown as { renderedVisibleLineCount?: number }).renderedVisibleLineCount;
@@ -167,7 +178,7 @@ class RoundedEditor extends CustomEditor {
167
178
  ...(ghost ? { ghost } : {}), ...(this.decor.hint() ? { hint: this.decor.hint()! } : {}),
168
179
  ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}),
169
180
  ...(visibleRows != null ? { visibleRows } : {}), dim: this.decor.dim,
170
- ...(this.decor.attachments(this.getText()) ? { below: this.decor.attachments(this.getText())! } : {})
181
+ ...(below ? { below } : {})
171
182
  });
172
183
  const tip = width >= 8 ? this.decor.tip(width) : undefined;
173
184
  this.tipRows = tip ? 1 : 0;
@@ -230,10 +241,12 @@ class ThemedEditor implements EditorComponent {
230
241
  }
231
242
  invalidate() { this.base.invalidate(); }
232
243
  render(width: number): string[] {
233
- const ghost = this.base.getText() === "" ? this.decor.ghost() : undefined;
244
+ const text = this.base.getText();
245
+ const ghost = text === "" ? this.decor.ghost() : undefined;
246
+ const below = this.decor.attachments(text);
234
247
  const framed = roundedInput(this.base.render(width < 8 ? width : width - 2), width, this.focused, this.theme, this.paint, this.decor.icon(), this.decor.session(), {
235
248
  ...(ghost ? { ghost } : {}), ...(this.decor.paintedIcon() ? { paintedIcon: this.decor.paintedIcon()! } : {}), dim: this.decor.dim,
236
- ...(this.decor.attachments(this.base.getText()) ? { below: this.decor.attachments(this.base.getText())! } : {})
249
+ ...(below ? { below } : {})
237
250
  });
238
251
  const tip = width >= 8 ? this.decor.tip(width) : undefined;
239
252
  this.tipRows = tip ? 1 : 0;
@@ -269,6 +282,8 @@ export class ComposerStyle {
269
282
  private suggestions?: SuggestionState;
270
283
  private unsubscribe?: () => void;
271
284
  private colors?: { fg(color: string, text: string): string };
285
+ /** Chips for the last draft; image paths hit the filesystem, so reuse them briefly across repaints. */
286
+ private chips?: { text: string; cwd: string; at: number; chips: string[] };
272
287
 
273
288
  private readonly decor: ComposerDecor = {
274
289
  icon: () => this.mascotOn ? mascotFace(this.animations ? this.mascot.mood() : "idle") : "pi",
@@ -280,12 +295,15 @@ export class ComposerStyle {
280
295
  tip: (width) => {
281
296
  if (!this.mascotOn || width < 40) return undefined;
282
297
  const mood = this.animations ? this.mascot.mood() : "idle";
283
- const tip = this.animations ? this.mascot.tip(Date.now(), this.frame) : this.mascot.tip(0, 0);
298
+ const animationFrame = this.phase === "idle" ? this.frame : Math.floor(Date.now() / COMPOSER_ACTIVE_INTERVAL_MS);
299
+ const tip = this.animations ? this.mascot.tip(Date.now(), animationFrame) : this.mascot.tip(0, 0);
284
300
  return " ".repeat(3) + paintTip(tip, mood, this.colors ? (color, text) => this.colors!.fg(color, text) : undefined);
285
301
  },
286
302
  session: () => this.session,
287
303
  ghost: () => this.suggestions?.text,
288
- hint: () => this.suggestions?.text ? "⇥ accept · ⏎ send · ⇧⏎ newline" : "⏎ send · ⇧⏎ newline · /plan · /goal",
304
+ hint: () => this.suggestions?.text
305
+ ? `${keyHint("tab", "accept")} · ${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")}`
306
+ : `${keyHint("enter", "send")} · ${keyHint("shiftEnter", "newline")} · /plan · /goal`,
289
307
  accept: () => {
290
308
  const text = this.suggestions?.text;
291
309
  if (text) this.suggestions!.clear();
@@ -296,9 +314,13 @@ export class ComposerStyle {
296
314
  dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
297
315
  attachments: (text) => {
298
316
  if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
299
- const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
300
- if (!chips.length) return undefined;
301
- return chips.map((chip) => this.colors?.fg("accent", chip) ?? chip).join(this.colors?.fg("dim", " ") ?? " ");
317
+ const now = Date.now();
318
+ if (this.chips?.text !== text || this.chips.cwd !== this.cwd || now - this.chips.at >= ATTACHMENT_RECHECK_MS) {
319
+ const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
320
+ this.chips = { text, cwd: this.cwd, at: now, chips };
321
+ }
322
+ if (!this.chips.chips.length) return undefined;
323
+ return this.chips.chips.map((chip) => this.colors?.fg("accent", chip) ?? chip).join(this.colors?.fg("dim", " ") ?? " ");
302
324
  }
303
325
  };
304
326
 
@@ -335,8 +357,9 @@ export class ComposerStyle {
335
357
  }
336
358
 
337
359
  /**
338
- * Active motion ticks at a modest 600 ms. Idle motion has no polling loop at all: it sleeps
339
- * until the mascot's next blink/sleep/flash deadline, then schedules the following deadline.
360
+ * Pi's own streaming/tool updates already repaint active sessions. Never add another active
361
+ * animation loop: active frames derive from wall time when Pi naturally renders. Idle only
362
+ * schedules exact blink/sleep/flash transitions.
340
363
  */
341
364
  private restartTimer(): void {
342
365
  this.stopTimer();
@@ -346,8 +369,7 @@ export class ComposerStyle {
346
369
  }
347
370
  private scheduleTimer(): void {
348
371
  if (!this.enabled || !this.animations || !this.mascotOn || this.timer) return;
349
- const active = this.phase === "generating" || this.phase === "tool" || this.phase === "waiting";
350
- const delay = active ? COMPOSER_ACTIVE_INTERVAL_MS : this.mascot.nextTransitionDelay(Date.now());
372
+ const delay = this.mascot.nextTransitionDelay(Date.now());
351
373
  if (delay === undefined) return;
352
374
  this.timer = setTimeout(() => {
353
375
  this.timer = undefined;
package/src/delegate.ts CHANGED
@@ -15,7 +15,17 @@ export const MAX_DELEGATES = 4;
15
15
  export const READ_ONLY_TOOLS = ["read", "grep", "find", "ls"] as const;
16
16
  const MAX_OUTPUT_CHARS = 12_000;
17
17
  const TIMEOUT_MS = 20 * 60_000;
18
- const STATUS_REFRESH_MS = 10_000;
18
+ const STATUS_REFRESH_MS = 20_000;
19
+ const LIVE_UPDATE_MS = 1000;
20
+ const MAX_EVENT_TEXT = 16_000;
21
+ const MAX_LOG_LINES = 200;
22
+ const MAX_LOG_CHARS = 240;
23
+ /** Finished subagents the registry keeps for the activity view after their tool call returns. */
24
+ const MAX_FINISHED = 8;
25
+ /** Abort reason for stopping one subagent from the activity view (vs. aborting the whole tool call). */
26
+ const STOP_REASON = "stopped";
27
+ /** Tool arguments that best describe a call in the transcript, most telling first. */
28
+ const HINT_KEYS = ["command", "path", "file_path", "pattern", "query", "url"] as const;
19
29
 
20
30
  export type DelegateState = "queued" | "working" | "done" | "failed";
21
31
  export interface DelegateRun {
@@ -31,6 +41,10 @@ export interface DelegateRun {
31
41
  cost: number;
32
42
  output: string;
33
43
  error?: string;
44
+ startedAt?: number;
45
+ endedAt?: number;
46
+ /** Live transcript (tool calls, assistant text), last MAX_LOG_LINES; never copied into tool details. */
47
+ log: string[];
34
48
  }
35
49
 
36
50
  type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
@@ -56,9 +70,44 @@ export function delegateArgs(task: string, model: string | undefined, thinking:
56
70
  return args;
57
71
  }
58
72
 
59
- const textOf = (message: { content?: unknown }) => Array.isArray(message.content)
60
- ? message.content.filter((part: { type?: string }) => part?.type === "text").map((part: { text?: string }) => part.text ?? "").join("\n")
61
- : typeof message.content === "string" ? message.content : "";
73
+ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string => {
74
+ if (typeof message.content === "string") return message.content.slice(0, limit);
75
+ if (!Array.isArray(message.content)) return "";
76
+ let out = "";
77
+ for (const part of message.content as Array<{ type?: string; text?: string }>) {
78
+ if (part?.type !== "text" || typeof part.text !== "string" || !part.text) continue;
79
+ const separator = out ? "\n" : "";
80
+ const room = limit - out.length - separator.length;
81
+ if (room <= 0) break;
82
+ out += separator + part.text.slice(0, room);
83
+ if (part.text.length > room) break;
84
+ }
85
+ return out;
86
+ };
87
+
88
+ /** Append transcript lines, cleaned and clipped, keeping only the newest MAX_LOG_LINES. */
89
+ const appendLog = (run: DelegateRun, lines: readonly string[]) => {
90
+ for (const raw of lines) { const line = cleanText(raw, MAX_LOG_CHARS); if (line) run.log.push(line); }
91
+ if (run.log.length > MAX_LOG_LINES) run.log.splice(0, run.log.length - MAX_LOG_LINES);
92
+ };
93
+
94
+ /** The argument that says what a tool call does: the bash command, the file path, the search pattern… */
95
+ const toolHint = (args: unknown): string => {
96
+ if (!args || typeof args !== "object") return "";
97
+ for (const key of HINT_KEYS) {
98
+ const value = (args as Record<string, unknown>)[key];
99
+ if (typeof value === "string" && value.trim()) return value;
100
+ }
101
+ return "";
102
+ };
103
+
104
+ /** The slice of Pi's JSON event stream a run reads; leaves are unknown because it is another process's output. */
105
+ interface ChildEvent {
106
+ type?: unknown;
107
+ toolName?: unknown;
108
+ args?: unknown;
109
+ message?: { role?: unknown; content?: unknown; usage?: { cost?: { total?: unknown } }; stopReason?: unknown; errorMessage?: unknown };
110
+ }
62
111
 
63
112
  /** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
64
113
  export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
@@ -68,9 +117,10 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
68
117
  try {
69
118
  child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, [CHILD_ENV]: "1" } });
70
119
  } catch (error) {
71
- run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); update(); resolve(); return;
120
+ run.state = "failed"; run.error = error instanceof Error ? error.message : String(error); run.endedAt = Date.now(); update(); resolve(); return;
72
121
  }
73
122
  run.state = "working";
123
+ run.startedAt = Date.now();
74
124
  run.activity = "starting";
75
125
  update();
76
126
  let buffer = "";
@@ -84,25 +134,36 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
84
134
  run.state = state;
85
135
  if (error) run.error = error;
86
136
  run.activity = undefined;
137
+ run.endedAt = Date.now();
87
138
  update();
88
139
  resolve();
89
140
  };
90
141
  const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
91
- const abort = () => { stop(); finish("failed", "aborted"); };
142
+ // Stopping one run from the activity view reads "stopped"; aborting the whole tool call reads "aborted".
143
+ const abort = () => { stop(); finish("failed", signal?.reason === STOP_REASON ? "stopped" : "aborted"); };
92
144
  const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
93
145
  timer.unref?.();
94
146
  if (signal?.aborted) { abort(); return; }
95
147
  signal?.addEventListener("abort", abort, { once: true });
96
148
  const line = (raw: string) => {
97
- if (!raw.trim()) return;
98
- let event: any;
99
- try { event = JSON.parse(raw); } catch { return; }
100
- if (event.type === "tool_execution_start") { run.tools++; run.activity = cleanText(String(event.toolName ?? "tool"), 24); update(); }
149
+ // Output that arrives after an abort or timeout must not revive a finished run's activity.
150
+ if (finished || !raw.trim()) return;
151
+ let parsed: unknown;
152
+ try { parsed = JSON.parse(raw); } catch { return; }
153
+ if (!parsed || typeof parsed !== "object") return;
154
+ // Parsed child output: the object check above is all the cast assumes; every field stays unknown until read.
155
+ const event = parsed as ChildEvent;
156
+ if (event.type === "tool_execution_start") {
157
+ run.tools++;
158
+ run.activity = cleanText(String(event.toolName ?? "tool"), 24);
159
+ appendLog(run, [`▸ ${run.activity} ${toolHint(event.args)}`]);
160
+ update();
161
+ }
101
162
  else if (event.type === "message_end" && event.message?.role === "assistant") {
102
163
  run.turns++;
103
164
  run.cost += Number(event.message.usage?.cost?.total) || 0;
104
165
  const text = textOf(event.message).trim();
105
- if (text) run.output = text.slice(0, MAX_OUTPUT_CHARS);
166
+ if (text) { run.output = text.slice(0, MAX_OUTPUT_CHARS); appendLog(run, text.split("\n").slice(-MAX_LOG_LINES)); }
106
167
  if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
107
168
  run.activity = "thinking";
108
169
  update();
@@ -124,6 +185,81 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
124
185
  });
125
186
  }
126
187
 
188
+ const isActive = (state: DelegateState) => state === "queued" || state === "working";
189
+
190
+ export interface SubagentRecord { key: string; batch: number; run: DelegateRun; stop(): void }
191
+
192
+ /**
193
+ * Live subagents for the activity view. Tool details are saved in the session, so they stay small;
194
+ * the transcript and the per-run stop handle live only here, for this process.
195
+ */
196
+ export class DelegateRegistry {
197
+ private entries = new Map<string, { record: SubagentRecord; finished?: number }>();
198
+ private listeners = new Set<() => void>();
199
+ private sequence = 0;
200
+
201
+ subscribe(listener: () => void): () => void {
202
+ this.listeners.add(listener);
203
+ return () => { this.listeners.delete(listener); };
204
+ }
205
+ /** Queued/working first (oldest first), then finished ones newest first. */
206
+ records(): SubagentRecord[] {
207
+ this.sweep();
208
+ const live: SubagentRecord[] = [];
209
+ const finished: Array<{ record: SubagentRecord; finished: number }> = [];
210
+ for (const { record, finished: at } of this.entries.values()) {
211
+ if (at === undefined) live.push(record); else finished.push({ record, finished: at });
212
+ }
213
+ finished.sort((a, b) => b.finished - a.finished);
214
+ return [...live, ...finished.map((entry) => entry.record)];
215
+ }
216
+ get(key: string): SubagentRecord | undefined { return this.entries.get(key)?.record; }
217
+ /** Abort one queued/working run, leaving the rest of its batch alone; false when unknown or already finished. */
218
+ stop(key: string): boolean {
219
+ const record = this.entries.get(key)?.record;
220
+ if (!record || !isActive(record.run.state)) return false;
221
+ record.stop();
222
+ this.notify();
223
+ return true;
224
+ }
225
+ running(): number {
226
+ let count = 0;
227
+ for (const { record } of this.entries.values()) if (isActive(record.run.state)) count++;
228
+ return count;
229
+ }
230
+ /** Stop outstanding runs on session changes/shutdown and forget their transcript. */
231
+ clear(): void {
232
+ const stopping = [...this.entries.values()].filter(({ record }) => isActive(record.run.state)).map(({ record }) => record.stop);
233
+ this.entries.clear();
234
+ for (const stop of stopping) stop();
235
+ this.notify();
236
+ }
237
+ /** jar_delegate's side: list new runs. */
238
+ add(...records: SubagentRecord[]): void {
239
+ for (const record of records) this.entries.set(record.key, { record });
240
+ this.notify();
241
+ }
242
+ /** jar_delegate's side: a run changed. */
243
+ notify(): void {
244
+ this.sweep();
245
+ for (const listener of this.listeners) {
246
+ try { listener(); } catch { /* views are decoration; a broken one must not break the run */ }
247
+ }
248
+ }
249
+ /** Stamp runs in the order they finish and keep only the newest MAX_FINISHED of them. */
250
+ private sweep(): void {
251
+ let finished = 0;
252
+ for (const entry of this.entries.values()) {
253
+ if (isActive(entry.record.run.state)) continue;
254
+ entry.finished ??= ++this.sequence;
255
+ finished++;
256
+ }
257
+ if (finished <= MAX_FINISHED) return;
258
+ const oldest = [...this.entries].filter(([, entry]) => entry.finished !== undefined).sort((a, b) => a[1].finished! - b[1].finished!);
259
+ for (const [key] of oldest.slice(0, finished - MAX_FINISHED)) this.entries.delete(key);
260
+ }
261
+ }
262
+
127
263
  const Parameters = Type.Object({
128
264
  tasks: Type.Array(Type.Object({
129
265
  task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
@@ -136,7 +272,8 @@ const Parameters = Type.Object({
136
272
  const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
137
273
  const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
138
274
 
139
- export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spawnProcess?: Spawn): void {
275
+ /** jar_delegate. Every run is listed live in `registry` (with its transcript and a stop handle) for the activity view. */
276
+ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, registry: DelegateRegistry, spawnProcess?: Spawn): void {
140
277
  if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
141
278
  let batch = 0;
142
279
  pi.registerTool({
@@ -157,34 +294,61 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
157
294
  const resolved = roles.resolve(role) ?? roles.resolve("default");
158
295
  const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
159
296
  return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
160
- state: "queued", tools: 0, turns: 0, cost: 0, output: "" };
297
+ state: "queued", tools: 0, turns: 0, cost: 0, output: "", log: [] };
161
298
  });
299
+ // One controller per run: aborting the tool call stops them all, the activity view stops one.
300
+ const controllers = runs.map(() => new AbortController());
301
+ const abortAll = () => { for (const controller of controllers) controller.abort(); };
302
+ if (signal?.aborted) abortAll(); else signal?.addEventListener("abort", abortAll, { once: true });
303
+ // The key doubles as the role-status id, so views can tell our own teammates from other extensions'.
304
+ const records: SubagentRecord[] = runs.map((run, index) => ({ key: `delegate-${id}-${run.index}`, batch: id, run, stop: () => controllers[index]!.abort(STOP_REASON) }));
305
+ registry.add(...records);
162
306
  const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
163
307
  // Live teammates: the welcome TEAM row and the footer read this public status contract.
164
308
  const publish = () => {
165
309
  if (!ctx.hasUI) return;
166
- for (const run of runs) {
310
+ for (const { key, run } of records) {
167
311
  try {
168
- ctx.ui.setStatus(`${ROLE_PREFIX}delegate-${id}-${run.index}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
312
+ ctx.ui.setStatus(`${ROLE_PREFIX}${key}`, run.state === "done" || run.state === "failed" ? undefined : JSON.stringify({
169
313
  name: run.name, label: run.name.slice(0, 12), state: run.state === "queued" ? "waiting" : "working", task: cleanText(run.task, 60), expiresAt: Date.now() + 25_000
170
314
  }));
171
315
  } catch { /* status is decoration */ }
172
316
  }
173
317
  };
174
- const details = () => ({ runs: runs.map((run) => ({ ...run })), write });
175
- const update = () => {
318
+ // Details are persisted with the session: never the transcript, which the registry holds instead.
319
+ const details = (includeOutput = true) => ({ runs: runs.map(({ log: _log, ...run }) => includeOutput ? run : { ...run, output: "" }), write });
320
+ let updateTimer: ReturnType<typeof setTimeout> | undefined;
321
+ let lastUpdateAt = 0;
322
+ const emitUpdate = () => {
323
+ updateTimer = undefined;
324
+ lastUpdateAt = Date.now();
176
325
  publish();
177
- onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details() });
326
+ onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
327
+ };
328
+ const update = () => {
329
+ registry.notify();
330
+ if (!onUpdate) { publish(); return; }
331
+ const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
332
+ if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
333
+ if (!updateTimer) {
334
+ updateTimer = setTimeout(emitUpdate, wait);
335
+ updateTimer.unref?.();
336
+ }
337
+ };
338
+ const flushUpdate = () => {
339
+ if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; }
340
+ emitUpdate();
178
341
  };
179
342
  const refresh = setInterval(publish, STATUS_REFRESH_MS);
180
343
  refresh.unref?.();
181
344
  try {
182
- update();
183
- await Promise.all(runs.map((run) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, signal, update, spawnProcess)));
345
+ await Promise.all(runs.map((run, index) => runDelegate(run, delegateArgs(run.task, run.model, thinking(run.role), write), ctx.cwd, controllers[index]!.signal, update, spawnProcess)));
184
346
  } finally {
185
347
  clearInterval(refresh);
186
- for (const run of runs) if (run.state !== "done") run.state = "failed";
187
- publish();
348
+ signal?.removeEventListener("abort", abortAll);
349
+ for (const run of runs) if (run.state !== "done") { run.state = "failed"; run.endedAt ??= Date.now(); }
350
+ registry.notify();
351
+ flushUpdate();
188
352
  }
189
353
  const report = runs.map((run) => `## [${run.index}] ${run.name} (${run.role}${run.model ? " · " + run.model : ""}) — ${run.state}${run.error ? ": " + run.error : ""}\n${run.output || "(no report)"}`).join("\n\n");
190
354
  return { content: [{ type: "text", text: report }], details: details() };
@@ -195,7 +359,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
195
359
  + theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
196
360
  },
197
361
  renderResult(result, { expanded }, theme) {
198
- const runs = (result.details as { runs?: DelegateRun[] } | undefined)?.runs ?? [];
362
+ const runs = (result.details as { runs?: Array<Omit<DelegateRun, "log">> } | undefined)?.runs ?? [];
199
363
  const rows = runs.map((run) => {
200
364
  const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
201
365
  let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);
package/src/diff-view.ts CHANGED
@@ -167,8 +167,8 @@ export function registerChangeReview(pi: ExtensionAPI, tracker: () => ChangeTrac
167
167
  }
168
168
  }
169
169
  if (!path) path = fallback.shift();
170
- if (path) tracker()?.markDirty(path);
171
- changed();
170
+ const failed = (event as { isError?: unknown }).isError === true;
171
+ if (path && !failed && tracker()?.markDirty(path)) changed();
172
172
  });
173
173
  const open = async (ctx: ExtensionContext) => {
174
174
  const current = tracker();