pi-jar 0.1.2 → 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.
@@ -0,0 +1,252 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { Key, matchesKey, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TuiMouseEvent } from "@earendil-works/pi-tui";
3
+ import type { DelegateRegistry, DelegateRun, SubagentRecord } from "./delegate.ts";
4
+ import { safeLine } from "./diff-view.ts";
5
+ import { icon, withIcon } from "./icons.ts";
6
+ import { describe, elapsed, type ShellJob, type ShellManager } from "./shells.ts";
7
+ import { contentRows, optionList, sidebarWidth, splitFrame } from "./split-view.ts";
8
+ import { ACTIVE_STATES, cleanText } from "./status.ts";
9
+
10
+ /** What to select when the view opens. */
11
+ export type ActivityTarget = { kind: "subagent" | "shell"; id: string };
12
+ /** A teammate another extension publishes through the role-status contract; listed read-only. */
13
+ export interface ActivityRole { id: string; name: string; state: string; task?: string }
14
+ export interface ActivitySources {
15
+ subagents: DelegateRegistry;
16
+ shells?: ShellManager;
17
+ roles?: () => ActivityRole[];
18
+ }
19
+
20
+ /** Doubles as the icon key for the row glyph. */
21
+ type Status = "running" | "pending" | "success" | "error" | "stopped";
22
+ type Fg = (color: string, text: string) => string;
23
+ type Entry =
24
+ | { kind: "subagent"; id: string; status: Status; label: string; record: SubagentRecord }
25
+ | { kind: "shell"; id: string; status: Status; label: string; job: Omit<ShellJob, "lines"> }
26
+ | { kind: "role"; id: string; status: Status; label: string; role: ActivityRole };
27
+ type Item = Entry | { kind: "header"; label: string };
28
+
29
+ const COLOR: Record<Status, string> = { running: "accent", pending: "dim", success: "success", error: "error", stopped: "dim" };
30
+ const ACTIONS = [{ key: "x" }, { key: "f" }] as const;
31
+ const SHELL_TAIL = 400;
32
+ const MAX_TASK_CHARS = 8000;
33
+
34
+ /** Left-pane rows: a header per non-empty section, then its entries. */
35
+ function collect(sources: ActivitySources): Item[] {
36
+ const items: Item[] = [];
37
+ const records = sources.subagents.records();
38
+ if (records.length) items.push({ kind: "header", label: withIcon("subagents", "SUBAGENTS") });
39
+ for (const record of records) {
40
+ const { run } = record;
41
+ const status: Status = run.state === "working" ? "running" : run.state === "queued" ? "pending" : run.state === "done" ? "success" : run.error === "stopped" ? "stopped" : "error";
42
+ items.push({ kind: "subagent", id: record.key, status, label: run.name, record });
43
+ }
44
+ const jobs = sources.shells?.summaries() ?? [];
45
+ if (jobs.length) items.push({ kind: "header", label: withIcon("shell", "SHELLS") });
46
+ for (const job of jobs) {
47
+ const status: Status = job.status === "running" ? "running" : job.status === "exited" && job.exitCode === 0 ? "success" : job.status === "killed" ? "stopped" : "error";
48
+ items.push({ kind: "shell", id: job.id, status, label: `${job.id} ${job.name}`, job });
49
+ }
50
+ let roles: ActivityRole[] = [];
51
+ try { roles = sources.roles?.() ?? []; } catch { /* teammates are decoration */ }
52
+ // jar_delegate publishes each run as a role status under its record key: list those once, as subagents.
53
+ roles = roles.filter((role) => !sources.subagents.get(role.id));
54
+ if (roles.length) items.push({ kind: "header", label: withIcon("roles", "ROLES") });
55
+ for (const role of roles) {
56
+ const status: Status = (ACTIVE_STATES as ReadonlySet<string>).has(role.state) ? "running" : role.state === "done" ? "success" : role.state === "failed" ? "error" : "pending";
57
+ items.push({ kind: "role", id: role.id, status, label: cleanText(role.name, 32) || role.id, role });
58
+ }
59
+ return items;
60
+ }
61
+
62
+ /** `head` stays pinned above the scrolling `body`, so following a transcript never hides who is running and how. */
63
+ interface Details { head: string[]; body: string[] }
64
+
65
+ function subagentDetails(run: DelegateRun, status: Status, width: number, fg: Fg): Details {
66
+ const facts = [
67
+ run.startedAt !== undefined ? elapsed({ startedAt: run.startedAt, endedAt: run.endedAt }) : "",
68
+ `${run.tools} tool${run.tools === 1 ? "" : "s"}`, `${run.turns} turn${run.turns === 1 ? "" : "s"}`, `$${run.cost.toFixed(3)}`
69
+ ].filter(Boolean);
70
+ const head = [
71
+ fg("accent", [run.name, run.role, run.model].filter(Boolean).join(" · ")),
72
+ fg(COLOR[status], withIcon(status, status === "stopped" ? "stopped" : run.state)) + fg("dim", " · " + facts.join(" · "))
73
+ ];
74
+ if (run.activity) head.push(fg("muted", run.activity));
75
+ if (run.error && status !== "stopped") for (const line of wrapTextWithAnsi(safeLine(run.error), width).slice(0, 2)) head.push(fg("error", line));
76
+ const body = ["", fg("dim", "TASK")];
77
+ for (const raw of run.task.slice(0, MAX_TASK_CHARS).split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
78
+ body.push("", fg("dim", "── transcript ──"));
79
+ if (!run.log.length) body.push(fg("dim", "(nothing yet)"));
80
+ for (const entry of run.log) {
81
+ const tone = entry.startsWith("▸ ") ? "accent" : "muted";
82
+ for (const line of wrapTextWithAnsi(entry, width)) body.push(fg(tone, line));
83
+ }
84
+ if (run.output) {
85
+ body.push("", fg("dim", "── report ──"));
86
+ for (const raw of run.output.split("\n")) for (const line of wrapTextWithAnsi(safeLine(raw), width)) body.push(fg("text", line));
87
+ }
88
+ return { head, body };
89
+ }
90
+
91
+ function shellDetails(job: Omit<ShellJob, "lines">, output: readonly string[], width: number, fg: Fg): Details {
92
+ const head = [fg("accent", safeLine(describe(job))), fg("muted", "$ " + cleanText(job.command, 400)), fg("dim", withIcon("folder", safeLine(job.cwd)))];
93
+ const body = ["", ...output.map((line) => fg("muted", truncateToWidth(safeLine(line), width)))];
94
+ if (!output.length) body.push(fg("dim", "(no output yet)"));
95
+ return { head, body };
96
+ }
97
+
98
+ function roleDetails(role: ActivityRole, status: Status, width: number, fg: Fg): Details {
99
+ const head = [fg("accent", cleanText(role.name, 60) || role.id), fg(COLOR[status], withIcon(status, cleanText(role.state, 24))) + fg("dim", " · read-only")];
100
+ const body = role.task ? ["", fg("dim", "TASK"), ...wrapTextWithAnsi(cleanText(role.task, 400), width).map((line) => fg("text", line))] : [];
101
+ return { head, body };
102
+ }
103
+
104
+ /**
105
+ * Overlay: subagents, shells and other extensions' teammates on the left; the selected one's live
106
+ * details on the right, following new output until scrolled up. x stops a subagent or kills a shell.
107
+ */
108
+ export async function openActivityView(ctx: ExtensionContext, sources: ActivitySources, initial?: ActivityTarget): Promise<void> {
109
+ if (!ctx.hasUI || ctx.mode !== "tui") return;
110
+ const opening = collect(sources);
111
+ if (!opening.some((item) => item.kind !== "header")) {
112
+ ctx.ui.notify("pi-jar: nothing running (subagents come from jar_delegate, shells from jar_shell)", "info");
113
+ return;
114
+ }
115
+ await ctx.ui.custom<void>((tui, theme, _keys, done) => {
116
+ let items = opening;
117
+ let at = initial ? items.findIndex((item) => item.kind !== "header" && item.kind === initial.kind && item.id === initial.id) : -1;
118
+ if (at < 0) at = items.findIndex((item) => item.kind !== "header" && item.status === "running");
119
+ if (at < 0) at = items.findIndex((item) => item.kind !== "header");
120
+ let follow = true;
121
+ let scroll = 0;
122
+ let maxScroll = 0;
123
+ let bodyRows = 0;
124
+ let listTop = 0;
125
+ let width = 80;
126
+ let layout = { top: 1, rows: 0, leftWidth: 0, bodyX: 2, footerTop: 0 };
127
+ let timer: NodeJS.Timeout | undefined;
128
+ let closed = false;
129
+ const fg: Fg = (color, text) => theme.fg(color as never, text);
130
+ const shells = sources.shells;
131
+ const previous = shells?.onChange;
132
+ const unsubscribe = sources.subagents.subscribe(() => tui.requestRender());
133
+ if (shells) shells.onChange = () => { previous?.(); tui.requestRender(); };
134
+ const cleanup = () => {
135
+ if (closed) return;
136
+ closed = true;
137
+ clearInterval(timer);
138
+ timer = undefined;
139
+ unsubscribe();
140
+ if (shells) shells.onChange = previous;
141
+ };
142
+ const close = () => { cleanup(); done(); };
143
+ // Rows reorder live (finished subagents sink, old ones drop off): keep the selection by identity.
144
+ const refresh = () => {
145
+ const current = items[at];
146
+ items = collect(sources);
147
+ const same = current && current.kind !== "header" ? items.findIndex((item) => item.kind !== "header" && item.kind === current.kind && item.id === current.id) : -1;
148
+ if (same >= 0) { at = same; return; }
149
+ const from = Math.max(0, Math.min(at, items.length - 1));
150
+ at = -1;
151
+ for (let index = from; index < items.length && at < 0; index++) if (items[index]!.kind !== "header") at = index;
152
+ for (let index = from - 1; index >= 0 && at < 0; index--) if (items[index]!.kind !== "header") at = index;
153
+ };
154
+ const move = (delta: number) => {
155
+ for (let index = at + delta; index >= 0 && index < items.length; index += delta) {
156
+ if (items[index]!.kind === "header") continue;
157
+ at = index; follow = true; scroll = 0;
158
+ return;
159
+ }
160
+ };
161
+ const scrollBy = (delta: number) => {
162
+ if (!maxScroll) return;
163
+ scroll = Math.max(0, Math.min(maxScroll, scroll + delta));
164
+ // Scrolling up pauses follow; scrolling back down to the end resumes it.
165
+ follow = delta > 0 && scroll >= maxScroll;
166
+ };
167
+ const stopSelected = () => {
168
+ const item = items[at];
169
+ if (!item || item.kind === "header" || item.kind === "role" || (item.status !== "running" && item.status !== "pending")) return;
170
+ try {
171
+ if (item.kind === "subagent") sources.subagents.stop(item.id);
172
+ else shells?.kill(item.id);
173
+ } catch (error) { ctx.ui.notify("pi-jar: " + (error instanceof Error ? error.message : String(error)), "error"); }
174
+ };
175
+ const component = {
176
+ invalidate() {},
177
+ dispose: cleanup,
178
+ handleInput(data: string) {
179
+ if (matchesKey(data, Key.escape) || data === "q") return close();
180
+ if (matchesKey(data, Key.up) || data === "k") move(-1);
181
+ else if (matchesKey(data, Key.down) || data === "j") move(1);
182
+ else if (matchesKey(data, Key.pageUp)) scrollBy(-Math.max(1, bodyRows - 2));
183
+ else if (matchesKey(data, Key.pageDown)) scrollBy(Math.max(1, bodyRows - 2));
184
+ else if (data === "f" || data === "G") follow = true;
185
+ else if (matchesKey(data, Key.enter)) follow = !follow;
186
+ else if (data === "x") stopSelected();
187
+ else return;
188
+ tui.requestRender();
189
+ },
190
+ handleMouse(event: TuiMouseEvent) {
191
+ if (event.type === "wheel" && event.wheelDelta) { scrollBy(Math.sign(event.wheelDelta) * 3); tui.requestRender(); return { handled: true }; }
192
+ if (event.type !== "click" || event.button !== "left") return;
193
+ if (event.y === 0 && event.x >= width - 3) { close(); return { handled: true }; }
194
+ const row = event.y - layout.top;
195
+ if (layout.leftWidth && row >= 0 && row < layout.rows && event.x < layout.leftWidth + 3) {
196
+ const index = listTop + row;
197
+ if (!items[index] || items[index].kind === "header") return;
198
+ if (index !== at) { at = index; follow = true; scroll = 0; }
199
+ tui.requestRender();
200
+ return { handled: true, focus: true };
201
+ }
202
+ const action = ACTIONS[event.y - layout.footerTop];
203
+ if (action) { component.handleInput(action.key); return { handled: true }; }
204
+ },
205
+ render(available: number): string[] {
206
+ width = Math.max(24, available);
207
+ const rows = contentRows(6 + ACTIONS.length, 6);
208
+ refresh();
209
+ const picked = items[at];
210
+ const item = picked && picked.kind !== "header" ? picked : undefined;
211
+ const listWidth = sidebarWidth(width, 20, 34);
212
+ const bodyWidth = listWidth ? width - listWidth - 6 : width - 4;
213
+ const entries = items.filter((entry): entry is Entry => entry.kind !== "header");
214
+ const details = !item ? { head: [fg("dim", "Nothing to show.")], body: [] }
215
+ : item.kind === "subagent" ? subagentDetails(item.record.run, item.status, bodyWidth, fg)
216
+ : item.kind === "shell" ? shellDetails(item.job, shells?.output(item.id, SHELL_TAIL) ?? [], bodyWidth, fg)
217
+ : roleDetails(item.role, item.status, bodyWidth, fg);
218
+ // Narrow terminals drop the list: a pager row above the pinned facts says what is selected.
219
+ const pinned = [...(!listWidth && item ? [fg("accent", `‹ ${entries.indexOf(item) + 1}/${entries.length} ${withIcon(item.status, item.label)} ›`)] : []),
220
+ ...details.head].slice(0, rows - 1);
221
+ bodyRows = rows - pinned.length;
222
+ maxScroll = Math.max(0, details.body.length - bodyRows);
223
+ scroll = follow ? maxScroll : Math.min(scroll, maxScroll);
224
+ if (at >= 0 && at < listTop) listTop = items[at - 1]?.kind === "header" ? at - 1 : at;
225
+ if (at >= listTop + rows) listTop = at - rows + 1;
226
+ listTop = Math.max(0, Math.min(listTop, items.length - rows));
227
+ const list = listWidth ? items.slice(listTop, listTop + rows).map((entry, offset) => {
228
+ if (entry.kind === "header") return fg("dim", truncateToWidth(entry.label, listWidth));
229
+ const active = listTop + offset === at;
230
+ const glyph = icon(entry.status);
231
+ return fg(active ? "accent" : "muted", active ? "▌" : " ") + fg(COLOR[entry.status], glyph + " ")
232
+ + fg(active ? "accent" : "muted", truncateToWidth(entry.label, Math.max(1, listWidth - 2 - visibleWidth(glyph))));
233
+ }) : [];
234
+ const running = entries.filter((entry) => entry.status === "running").length;
235
+ const stoppable = item?.kind === "subagent" ? item.status === "running" || item.status === "pending" : item?.kind === "shell" && item.status === "running";
236
+ const actions = optionList(theme, [item?.kind === "shell" ? "Kill the selected shell" : item?.kind === "role" ? "Stop (teammates from other extensions are read-only)" : "Stop the selected subagent",
237
+ "Follow the latest output"], -1, ACTIONS.map((action) => action.key));
238
+ if (!stoppable) actions[0] = fg("dim", stripTerminalSequences(actions[0]!));
239
+ const footer = [...actions, fg("dim", `↑↓ select · PgUp/PgDn scroll · ${withIcon("enter", "follow")} · ${withIcon("esc", "close")}`)];
240
+ const split = splitFrame(theme, width, `ACTIVITY · ${running} running${follow ? "" : " · paused"}`, list,
241
+ [...pinned, ...details.body.slice(scroll, scroll + bodyRows)], footer, rows, listWidth);
242
+ layout = split.layout;
243
+ // Subagent events and shell exits repaint through the listeners; elapsed times and shell output
244
+ // advance on a 1s tick that exists only while something runs.
245
+ if (running && !timer && !closed) { timer = setInterval(() => tui.requestRender(), 1000); timer.unref?.(); }
246
+ else if (!running && timer) { clearInterval(timer); timer = undefined; }
247
+ return available < width ? split.lines.map((line) => truncateToWidth(line, Math.max(0, available))) : split.lines;
248
+ }
249
+ };
250
+ return component;
251
+ }, { overlay: true, overlayOptions: { width: "100%", maxHeight: "100%" } });
252
+ }
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",
@@ -286,7 +301,9 @@ export class ComposerStyle {
286
301
  },
287
302
  session: () => this.session,
288
303
  ghost: () => this.suggestions?.text,
289
- 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`,
290
307
  accept: () => {
291
308
  const text = this.suggestions?.text;
292
309
  if (text) this.suggestions!.clear();
@@ -297,9 +314,13 @@ export class ComposerStyle {
297
314
  dim: (text) => this.colors?.fg("dim", text) ?? `\x1b[2m${text}\x1b[22m`,
298
315
  attachments: (text) => {
299
316
  if (!/\.(png|jpe?g|gif|webp)\b/i.test(text)) return undefined;
300
- const chips = imagePaths(text, this.cwd).map(imageInfo).filter((info) => !!info).map((info) => imageChip(info!));
301
- if (!chips.length) return undefined;
302
- 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", " ") ?? " ");
303
324
  }
304
325
  };
305
326
 
package/src/delegate.ts CHANGED
@@ -18,6 +18,14 @@ const TIMEOUT_MS = 20 * 60_000;
18
18
  const STATUS_REFRESH_MS = 20_000;
19
19
  const LIVE_UPDATE_MS = 1000;
20
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;
21
29
 
22
30
  export type DelegateState = "queued" | "working" | "done" | "failed";
23
31
  export interface DelegateRun {
@@ -33,6 +41,10 @@ export interface DelegateRun {
33
41
  cost: number;
34
42
  output: string;
35
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[];
36
48
  }
37
49
 
38
50
  type Spawn = (command: string, args: string[], options: Parameters<typeof spawn>[2]) => ChildProcess;
@@ -73,6 +85,30 @@ const textOf = (message: { content?: unknown }, limit = MAX_EVENT_TEXT): string
73
85
  return out;
74
86
  };
75
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
+ }
111
+
76
112
  /** Run one child Pi, feeding progress into `run` and calling `update` on every change. */
77
113
  export function runDelegate(run: DelegateRun, args: string[], cwd: string, signal: AbortSignal | undefined, update: () => void, spawnProcess: Spawn = spawn as Spawn): Promise<void> {
78
114
  return new Promise((resolve) => {
@@ -81,9 +117,10 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
81
117
  try {
82
118
  child = spawnProcess(invocation.command, invocation.args, { cwd, shell: false, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, [CHILD_ENV]: "1" } });
83
119
  } catch (error) {
84
- 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;
85
121
  }
86
122
  run.state = "working";
123
+ run.startedAt = Date.now();
87
124
  run.activity = "starting";
88
125
  update();
89
126
  let buffer = "";
@@ -97,25 +134,36 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
97
134
  run.state = state;
98
135
  if (error) run.error = error;
99
136
  run.activity = undefined;
137
+ run.endedAt = Date.now();
100
138
  update();
101
139
  resolve();
102
140
  };
103
141
  const stop = () => { child.kill("SIGTERM"); setTimeout(() => { if (child.exitCode === null) child.kill("SIGKILL"); }, 3000).unref?.(); };
104
- 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"); };
105
144
  const timer = setTimeout(() => { stop(); finish("failed", "timed out"); }, TIMEOUT_MS);
106
145
  timer.unref?.();
107
146
  if (signal?.aborted) { abort(); return; }
108
147
  signal?.addEventListener("abort", abort, { once: true });
109
148
  const line = (raw: string) => {
110
- if (!raw.trim()) return;
111
- let event: any;
112
- try { event = JSON.parse(raw); } catch { return; }
113
- 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
+ }
114
162
  else if (event.type === "message_end" && event.message?.role === "assistant") {
115
163
  run.turns++;
116
164
  run.cost += Number(event.message.usage?.cost?.total) || 0;
117
165
  const text = textOf(event.message).trim();
118
- 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)); }
119
167
  if (event.message.stopReason === "error" && event.message.errorMessage) run.error = cleanText(String(event.message.errorMessage), 200);
120
168
  run.activity = "thinking";
121
169
  update();
@@ -137,6 +185,81 @@ export function runDelegate(run: DelegateRun, args: string[], cwd: string, signa
137
185
  });
138
186
  }
139
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
+
140
263
  const Parameters = Type.Object({
141
264
  tasks: Type.Array(Type.Object({
142
265
  task: Type.String({ description: "A complete, self-contained instruction: the subagent sees nothing else." }),
@@ -149,7 +272,8 @@ const Parameters = Type.Object({
149
272
  const glyph = (state: DelegateState) => state === "done" ? "✔" : state === "failed" ? "✖" : state === "working" ? "●" : "○";
150
273
  const color = (state: DelegateState) => state === "done" ? "success" : state === "failed" ? "error" : state === "working" ? "accent" : "dim";
151
274
 
152
- 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 {
153
277
  if (process.env[CHILD_ENV] || typeof (pi as ExtensionAPI & { registerTool?: unknown }).registerTool !== "function") return;
154
278
  let batch = 0;
155
279
  pi.registerTool({
@@ -170,21 +294,29 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
170
294
  const resolved = roles.resolve(role) ?? roles.resolve("default");
171
295
  const model = resolved ? `${resolved.provider}/${resolved.model}` : ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : undefined;
172
296
  return { index: index + 1, name: cleanText(item.name ?? `agent ${index + 1}`, 24), task: item.task, role, ...(model ? { model } : {}),
173
- state: "queued", tools: 0, turns: 0, cost: 0, output: "" };
297
+ state: "queued", tools: 0, turns: 0, cost: 0, output: "", log: [] };
174
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);
175
306
  const thinking = (role: string) => (roles.resolve(role) ?? roles.resolve("default"))?.thinking;
176
307
  // Live teammates: the welcome TEAM row and the footer read this public status contract.
177
308
  const publish = () => {
178
309
  if (!ctx.hasUI) return;
179
- for (const run of runs) {
310
+ for (const { key, run } of records) {
180
311
  try {
181
- 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({
182
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
183
314
  }));
184
315
  } catch { /* status is decoration */ }
185
316
  }
186
317
  };
187
- const details = (includeOutput = true) => ({ runs: runs.map((run) => includeOutput ? { ...run } : { ...run, output: "" }), write });
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 });
188
320
  let updateTimer: ReturnType<typeof setTimeout> | undefined;
189
321
  let lastUpdateAt = 0;
190
322
  const emitUpdate = () => {
@@ -194,6 +326,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
194
326
  onUpdate?.({ content: [{ type: "text", text: runs.map((run) => `${run.name}: ${run.state}`).join("\n") }], details: details(false) });
195
327
  };
196
328
  const update = () => {
329
+ registry.notify();
197
330
  if (!onUpdate) { publish(); return; }
198
331
  const wait = Math.max(0, LIVE_UPDATE_MS - (Date.now() - lastUpdateAt));
199
332
  if (wait === 0) { if (updateTimer) { clearTimeout(updateTimer); updateTimer = undefined; } emitUpdate(); return; }
@@ -209,10 +342,12 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
209
342
  const refresh = setInterval(publish, STATUS_REFRESH_MS);
210
343
  refresh.unref?.();
211
344
  try {
212
- 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)));
213
346
  } finally {
214
347
  clearInterval(refresh);
215
- for (const run of runs) if (run.state !== "done") run.state = "failed";
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();
216
351
  flushUpdate();
217
352
  }
218
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");
@@ -224,7 +359,7 @@ export function registerDelegate(pi: ExtensionAPI, roles: ModelRoleManager, spaw
224
359
  + theme.fg("dim", args.write ? " · can edit" : " · read-only"), 0, 0);
225
360
  },
226
361
  renderResult(result, { expanded }, theme) {
227
- const runs = (result.details as { runs?: DelegateRun[] } | undefined)?.runs ?? [];
362
+ const runs = (result.details as { runs?: Array<Omit<DelegateRun, "log">> } | undefined)?.runs ?? [];
228
363
  const rows = runs.map((run) => {
229
364
  const meta = [run.role, run.activity, run.tools ? `${run.tools} tools` : "", run.cost ? `$${run.cost.toFixed(3)}` : "", run.error].filter(Boolean).join(" · ");
230
365
  let row = theme.fg(color(run.state) as never, ` ${glyph(run.state)} ${run.name}`) + theme.fg("dim", " · " + meta);