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/README.md +16 -12
- package/docs/DEVELOPMENT.md +4 -2
- package/docs/WORKFLOWS.md +2 -1
- package/extensions/index.ts +212 -89
- package/package.json +1 -1
- package/src/activity-view.ts +252 -0
- package/src/advisor.ts +30 -6
- package/src/changes.ts +12 -20
- package/src/compact-tools.ts +62 -18
- package/src/composer.ts +36 -14
- package/src/delegate.ts +187 -23
- package/src/diff-view.ts +2 -2
- package/src/footer.ts +105 -18
- package/src/goal-loop.ts +34 -9
- package/src/goals.ts +13 -2
- package/src/history-ui.ts +2 -1
- package/src/icons.ts +57 -0
- package/src/info-panels.ts +19 -7
- package/src/plan.ts +37 -10
- package/src/prompt-search.ts +27 -8
- package/src/quota.ts +14 -4
- package/src/session-branch.ts +24 -0
- package/src/session-gallery.ts +10 -5
- package/src/session-ui.ts +119 -45
- package/src/settings-ui.ts +5 -2
- package/src/settings.ts +5 -1
- package/src/shells.ts +77 -99
- package/src/side-model.ts +14 -2
- package/src/status.ts +36 -24
- package/src/suggest.ts +6 -25
- package/src/task-tool.ts +35 -11
- package/src/tasks.ts +24 -7
- package/src/usage.ts +2 -1
- package/src/welcome.ts +32 -12
- package/src/working.ts +3 -1
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
|
-
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
-
...(
|
|
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
|
|
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
|
|
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
|
|
300
|
-
if (
|
|
301
|
-
|
|
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
|
-
*
|
|
339
|
-
*
|
|
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
|
|
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 =
|
|
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 }) =>
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
|
|
310
|
+
for (const { key, run } of records) {
|
|
167
311
|
try {
|
|
168
|
-
ctx.ui.setStatus(`${ROLE_PREFIX}
|
|
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
|
-
|
|
175
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
187
|
-
|
|
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
|
|
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
|
-
|
|
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();
|