@ferris1225/pi-subagents 0.13.0 → 0.15.0
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/package.json +1 -1
- package/src/index.ts +55 -20
- package/src/monitor.ts +154 -14
- package/src/spawn.ts +13 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ferris1225/pi-subagents",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Focused sub-agent delegation for pi: explore / worker / reviewer agents in isolated context, with proactive dispatch injection and per-agent model selection.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
package/src/index.ts
CHANGED
|
@@ -134,7 +134,7 @@ function formatCompletionBlock(result: SingleResult, maxResultLines: number): st
|
|
|
134
134
|
const fallbackNote = result.modelFallbackFrom
|
|
135
135
|
? ` (model fell back from ${result.modelFallbackFrom} to ${result.model ?? "main-window model"})`
|
|
136
136
|
: "";
|
|
137
|
-
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task)}`, "", text];
|
|
137
|
+
const lines = [`### [${result.agent}] ${status}${usage ? ` (${usage})` : ""}${fallbackNote}`, "", `Task: ${formatTaskSummary(result.task, 80, false)}`, "", text];
|
|
138
138
|
if (truncated) {
|
|
139
139
|
// The full text lives on disk so the main agent can read it on demand.
|
|
140
140
|
lines.push("", `(output truncated to ${maxResultLines} lines; full result: ${writeResultArtifact(output, result.agent)})`);
|
|
@@ -243,22 +243,34 @@ export default function (pi: ExtensionAPI): void {
|
|
|
243
243
|
|
|
244
244
|
// Finished runs leave the widget immediately. Their final findings are sent
|
|
245
245
|
// back as a custom message that automatically starts a follow-up turn.
|
|
246
|
-
const finishRun = (
|
|
246
|
+
const finishRun = (
|
|
247
|
+
runId: number,
|
|
248
|
+
status: "done" | "failed",
|
|
249
|
+
opts?: { silent?: boolean; retain?: boolean },
|
|
250
|
+
): void => {
|
|
247
251
|
monitor.setStatus(runId, status); // stamps endedAt for the elapsed time
|
|
248
|
-
const run = monitor.removeRun(runId);
|
|
252
|
+
const run = opts?.retain ? monitor.findRun(runId) : monitor.removeRun(runId);
|
|
249
253
|
if (!run) return; // already finished — stay idempotent
|
|
250
|
-
if (!sessionActive) return;
|
|
254
|
+
if (opts?.silent || !sessionActive) return;
|
|
251
255
|
const icon = status === "done" ? "✓" : "✗";
|
|
252
256
|
ctx.ui.notify(`${icon} ${monitor.summarize(run)}`, status === "done" ? "info" : "error");
|
|
253
257
|
};
|
|
254
258
|
|
|
255
259
|
// Live sub-agent activity → concise one-line status ("thinking",
|
|
256
|
-
// "read src/index.ts", ...), never a raw args blob.
|
|
257
|
-
|
|
260
|
+
// "read src/index.ts", ...), never a raw args blob. Reviewer runs started
|
|
261
|
+
// by the main agent defer finishing so the queue task can decide between
|
|
262
|
+
// delivering the review and starting an auto-fix chain: a triggered chain
|
|
263
|
+
// keeps the parent row in the widget (annotated) until it completes and
|
|
264
|
+
// suppresses the premature "done" notification.
|
|
265
|
+
const makeLiveHandler = (runId: number, deferFinish = false) => (e: SubagentLiveEvent): void => {
|
|
258
266
|
switch (e.kind) {
|
|
259
267
|
case "status":
|
|
260
|
-
if (e.status === "done" || e.status === "failed")
|
|
261
|
-
|
|
268
|
+
if (e.status === "done" || e.status === "failed") {
|
|
269
|
+
// Deferred runs only update the widget; the queue task finishes
|
|
270
|
+
// them once it knows whether an auto-fix chain will follow.
|
|
271
|
+
if (deferFinish) monitor.setStatus(runId, e.status);
|
|
272
|
+
else finishRun(runId, e.status);
|
|
273
|
+
} else monitor.setStatus(runId, e.status);
|
|
262
274
|
break;
|
|
263
275
|
case "usage":
|
|
264
276
|
monitor.setUsage(runId, e.usage, e.model);
|
|
@@ -386,8 +398,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
386
398
|
* findings) → reviewer re-review, up to maxFixRounds times. The main agent is
|
|
387
399
|
* not woken mid-loop; the full chain is delivered as one group at the end.
|
|
388
400
|
* Failures short-circuit: a crashed worker skips its re-review and delivers.
|
|
401
|
+
* The triggering reviewer's run stays visible in the widget (annotated) until
|
|
402
|
+
* the chain resolves, so the ↳ rows have an obvious parent.
|
|
389
403
|
*/
|
|
390
|
-
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string): void => {
|
|
404
|
+
const startFixLoop = (initialReviewerResult: SingleResult, parentGroupId: string, parentRunId: number): void => {
|
|
391
405
|
backgroundQueue.enqueue(
|
|
392
406
|
async (signal) => {
|
|
393
407
|
const chain: SingleResult[] = [initialReviewerResult];
|
|
@@ -408,12 +422,18 @@ export default function (pi: ExtensionAPI): void {
|
|
|
408
422
|
});
|
|
409
423
|
chain.push(reviewResult);
|
|
410
424
|
lastReviewer = reviewResult;
|
|
411
|
-
|
|
425
|
+
// A crashed re-review must stop the chain like a crashed worker: its
|
|
426
|
+
// output (if any) is not a verdict, and feeding it to the next fix
|
|
427
|
+
// round would brief the worker from garbage.
|
|
428
|
+
if (!sessionActive || isFailedResult(reviewResult)) break;
|
|
412
429
|
if (reviewVerdict(getResultOutput(reviewResult)) === "pass") break;
|
|
413
430
|
}
|
|
431
|
+
// The chain is done (success, exhaustion, or abort): drop the retained
|
|
432
|
+
// parent row, then deliver the whole chain as one group. The loop's
|
|
433
|
+
// outcome always wakes the main agent (a passing chain reports
|
|
434
|
+
// success, a stuck one needs a human).
|
|
435
|
+
monitor.removeRun(parentRunId);
|
|
414
436
|
if (!sessionActive) return;
|
|
415
|
-
// Deliver the whole chain as one group; the loop's outcome always wakes
|
|
416
|
-
// the main agent (a passing chain reports success, a stuck one needs a human).
|
|
417
437
|
const items: CompletionMessageItem[] = chain.map((r) => ({
|
|
418
438
|
agent: r.agent,
|
|
419
439
|
block: formatCompletionBlock(r, config.maxResultLines),
|
|
@@ -423,7 +443,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
423
443
|
completionBatcher.flush();
|
|
424
444
|
},
|
|
425
445
|
() => {
|
|
426
|
-
// Cancelled
|
|
446
|
+
// Cancelled before delivery: clean up the retained parent row (each
|
|
447
|
+
// in-flight chain run was already finished by its launchInLoop path).
|
|
448
|
+
monitor.removeRun(parentRunId);
|
|
427
449
|
},
|
|
428
450
|
);
|
|
429
451
|
};
|
|
@@ -436,7 +458,9 @@ export default function (pi: ExtensionAPI): void {
|
|
|
436
458
|
const thinkingLevel = config.agentThinkingLevels[agent.name] ?? agent.thinking ?? config.thinkingLevel;
|
|
437
459
|
const pending = queuedResult(agent, task, thinkingLevel);
|
|
438
460
|
const runId = monitor.addRun(agent.name, task, agent.model, thinkingLevel);
|
|
439
|
-
|
|
461
|
+
// Only a main-agent-dispatched reviewer can trigger an auto-fix chain, so
|
|
462
|
+
// only its finish is deferred to the queue task (see startFixLoop).
|
|
463
|
+
const onLive = makeLiveHandler(runId, agent.name === "reviewer");
|
|
440
464
|
|
|
441
465
|
backgroundQueue.enqueue(
|
|
442
466
|
async (backgroundSignal) => {
|
|
@@ -473,12 +497,22 @@ export default function (pi: ExtensionAPI): void {
|
|
|
473
497
|
// triggers a worker→reviewer chain (up to maxFixRounds) without waking
|
|
474
498
|
// the main agent. Loop-internal re-reviews never reach here (they are
|
|
475
499
|
// awaited inside launchInLoop); the initial review is delivered with
|
|
476
|
-
// the chain at the end.
|
|
500
|
+
// the chain at the end. While the chain runs, the triggering review
|
|
501
|
+
// stays in the widget (annotated) so the chain rows have an obvious
|
|
502
|
+
// parent; no premature "done" notification is shown.
|
|
477
503
|
if (shouldTriggerFixLoop(result, config)) {
|
|
478
|
-
|
|
504
|
+
// The session is known active here (checked above), so the chain
|
|
505
|
+
// always starts: keep the triggering review in the widget
|
|
506
|
+
// (annotated) without a premature "done" notification, and let
|
|
507
|
+
// startFixLoop deliver the whole chain and drop the parent row.
|
|
508
|
+
finishRun(runId, "done", { silent: true, retain: true });
|
|
509
|
+
monitor.setAnnotation(runId, "auto-fix chain running");
|
|
510
|
+
startFixLoop(result, `fix-${runId}`, runId);
|
|
479
511
|
return;
|
|
480
512
|
}
|
|
481
513
|
const failed = isFailedResult(result);
|
|
514
|
+
finishRun(runId, failed ? "failed" : "done");
|
|
515
|
+
if (!sessionActive) return;
|
|
482
516
|
const completion: CompletionMessageItem = {
|
|
483
517
|
agent: result.agent,
|
|
484
518
|
block: formatCompletionBlock(result, config.maxResultLines),
|
|
@@ -553,14 +587,14 @@ export default function (pi: ExtensionAPI): void {
|
|
|
553
587
|
if (args.tasks && args.tasks.length > 0) {
|
|
554
588
|
let text = `${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", `parallel (${args.tasks.length})`)}`;
|
|
555
589
|
for (const t of args.tasks.slice(0, 4)) {
|
|
556
|
-
const preview = t.task
|
|
590
|
+
const preview = formatTaskSummary(t.task, 48);
|
|
557
591
|
text += `\n ${theme.fg("accent", t.agent)} ${theme.fg("dim", preview)}`;
|
|
558
592
|
}
|
|
559
593
|
if (args.tasks.length > 4) text += `\n ${theme.fg("dim", `… +${args.tasks.length - 4} more`)}`;
|
|
560
594
|
return new Text(text, 0, 0);
|
|
561
595
|
}
|
|
562
596
|
const task: string = args.task ?? "";
|
|
563
|
-
const preview = task
|
|
597
|
+
const preview = formatTaskSummary(task, 60);
|
|
564
598
|
return new Text(
|
|
565
599
|
`${theme.fg("toolTitle", theme.bold("subagent "))}${theme.fg("accent", args.agent ?? "?")} ${theme.fg("dim", preview)}`,
|
|
566
600
|
0,
|
|
@@ -628,9 +662,10 @@ export default function (pi: ExtensionAPI): void {
|
|
|
628
662
|
// Chain-internal runs (auto-fix worker/reviewer) indent under their
|
|
629
663
|
// parent reviewer; summarize() already carries the relationLabel.
|
|
630
664
|
const head = r.groupId ? theme.fg("dim", " ↳ ") : " ";
|
|
631
|
-
|
|
665
|
+
const note = r.annotation ? theme.fg("dim", ` · ${r.annotation}`) : "";
|
|
666
|
+
lines.push(truncateToWidth(`${head}${icon} #${r.id} ${monitor.summarize(r)} · ${label}${note}`, width, ""));
|
|
632
667
|
if (r.status === "queued" || r.status === "running") {
|
|
633
|
-
lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task)}`), width, ""));
|
|
668
|
+
lines.push(truncateToWidth(theme.fg("dim", ` task: ${formatTaskSummary(r.task, Math.max(20, width - 11))}`), width, ""));
|
|
634
669
|
}
|
|
635
670
|
// Activity sits one indent level below the agent name.
|
|
636
671
|
if (r.activity) lines.push(truncateToWidth(theme.fg("dim", ` ${r.activity}`), width, ""));
|
package/src/monitor.ts
CHANGED
|
@@ -40,6 +40,8 @@ export interface RunView {
|
|
|
40
40
|
groupId?: string;
|
|
41
41
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
42
42
|
relationLabel?: string;
|
|
43
|
+
/** Free-form note shown in the widget next to the status label (e.g. "auto-fix chain running"). */
|
|
44
|
+
annotation?: string;
|
|
43
45
|
}
|
|
44
46
|
|
|
45
47
|
/** Optional chain metadata for runs spawned by an auto-fix loop. */
|
|
@@ -54,23 +56,145 @@ export interface RunChainMeta {
|
|
|
54
56
|
|
|
55
57
|
const TASK_SUMMARY_MAX = 80;
|
|
56
58
|
const TASK_SUMMARY_ELLIPSIS = "…";
|
|
59
|
+
/** Columns reserved at the END of a truncated summary so the distinguishing
|
|
60
|
+
* keywords (paths, symbols, ...) survive; the head gets the rest. */
|
|
61
|
+
const TASK_SUMMARY_TAIL_MAX = 28;
|
|
62
|
+
/** Tail share of a non-default maxWidth (narrow widgets keep a usable tail). */
|
|
63
|
+
const TASK_SUMMARY_TAIL_SHARE = 0.35;
|
|
64
|
+
const TASK_SUMMARY_TAIL_MIN = 8;
|
|
65
|
+
const TASK_SUMMARY_KEY_SEP = " · ";
|
|
66
|
+
/** kebab/snake words that are task boilerplate, never distinguishing signal. */
|
|
67
|
+
const KEY_FRAGMENT_STOPWORDS = new Set([
|
|
68
|
+
"self-contained",
|
|
69
|
+
"read-only",
|
|
70
|
+
"write-only",
|
|
71
|
+
"auto-fix",
|
|
72
|
+
"re-review",
|
|
73
|
+
"one-line",
|
|
74
|
+
"pre-commit",
|
|
75
|
+
]);
|
|
57
76
|
const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
|
|
58
77
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
interface KeyFragment {
|
|
79
|
+
text: string;
|
|
80
|
+
index: number;
|
|
81
|
+
}
|
|
63
82
|
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
83
|
+
/**
|
|
84
|
+
* Pull the most distinguishing fragments out of a task: file paths, quoted
|
|
85
|
+
* phrases, camelCase/PascalCase symbols and kebab/snake compounds. Sorted by
|
|
86
|
+
* first occurrence and deduped (a path covers its own sub-fragments). These
|
|
87
|
+
* are what make parallel tasks of the same agent look different.
|
|
88
|
+
*/
|
|
89
|
+
export function extractKeyFragments(text: string): string[] {
|
|
90
|
+
const fragments: KeyFragment[] = [];
|
|
91
|
+
const add = (re: RegExp, group = 0): void => {
|
|
92
|
+
for (const m of text.matchAll(re)) {
|
|
93
|
+
const g = m[group];
|
|
94
|
+
if (g === undefined) continue;
|
|
95
|
+
fragments.push({ text: g, index: m.index ?? 0 });
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
// Quoted phrases first (highest signal).
|
|
99
|
+
add(/["'`]([^"'`]{4,60})["'`]/g, 1);
|
|
100
|
+
// Paths with a known extension (src/index.ts, build/out.js.map).
|
|
101
|
+
add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+\.[A-Za-z0-9]{1,5}(?![A-Za-z0-9_.-])/g);
|
|
102
|
+
// Paths with a slash but no extension (src/components, .github/workflows).
|
|
103
|
+
add(/(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+(?:[\\/][A-Za-z0-9_.-]+)+(?![\\/])/g);
|
|
104
|
+
// camelCase / PascalCase identifiers (function or type names).
|
|
105
|
+
add(/\b[a-z][a-zA-Z0-9]*[A-Z][a-zA-Z0-9]*\b/g);
|
|
106
|
+
// snake_case / kebab-case compound words.
|
|
107
|
+
add(/\b[a-z][a-z0-9]+[-_][a-z0-9][a-z0-9_-]*\b/g);
|
|
108
|
+
|
|
109
|
+
fragments.sort((a, b) => a.index - b.index);
|
|
110
|
+
const seen = new Set<string>();
|
|
111
|
+
const out: string[] = [];
|
|
112
|
+
for (const f of fragments) {
|
|
113
|
+
const t = f.text.trim();
|
|
114
|
+
if (t.length < 4 || KEY_FRAGMENT_STOPWORDS.has(t)) continue;
|
|
115
|
+
if (seen.has(t)) continue;
|
|
116
|
+
// A longer fragment (the full path) covers its own sub-fragments.
|
|
117
|
+
if (out.some((o) => o.includes(t) || t.includes(o))) continue;
|
|
118
|
+
seen.add(t);
|
|
119
|
+
out.push(t);
|
|
120
|
+
}
|
|
121
|
+
return out;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function takeGraphemes(segments: string[], maxWidth: number): string {
|
|
125
|
+
let width = 0;
|
|
126
|
+
const out: string[] = [];
|
|
127
|
+
for (const segment of segments) {
|
|
68
128
|
const segmentWidth = visibleWidth(segment);
|
|
69
|
-
if (
|
|
70
|
-
|
|
71
|
-
|
|
129
|
+
if (width + segmentWidth > maxWidth) break;
|
|
130
|
+
out.push(segment);
|
|
131
|
+
width += segmentWidth;
|
|
132
|
+
}
|
|
133
|
+
return out.join("");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function tailGraphemes(segments: string[], maxWidth: number): string {
|
|
137
|
+
let width = 0;
|
|
138
|
+
const tail: string[] = [];
|
|
139
|
+
for (let i = segments.length - 1; i >= 0; i--) {
|
|
140
|
+
const segmentWidth = visibleWidth(segments[i]);
|
|
141
|
+
if (width + segmentWidth > maxWidth) break;
|
|
142
|
+
tail.unshift(segments[i]);
|
|
143
|
+
width += segmentWidth;
|
|
72
144
|
}
|
|
73
|
-
return
|
|
145
|
+
return tail.join("");
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* One-line task preview, capped by `maxWidth` display columns (default 80).
|
|
150
|
+
* `keysOnly` (default): extracted key fragments (paths, quoted phrases,
|
|
151
|
+
* symbols) are shown bare — the agent name is already displayed next to the
|
|
152
|
+
* task line, so templated prose ("explore: trace how ...") adds nothing.
|
|
153
|
+
* `keysOnly: false` keeps the prose as `head…tail` (used for completion
|
|
154
|
+
* messages, where the Task line is the reader's only context).
|
|
155
|
+
* Grapheme-safe — CJK, ZWJ emoji and combining sequences are never split.
|
|
156
|
+
*/
|
|
157
|
+
export function formatTaskSummary(task: string, maxWidth: number = TASK_SUMMARY_MAX, keysOnly = true): string {
|
|
158
|
+
const oneLine = stripVTControlCharacters(task).replace(/\s+/g, " ").trim();
|
|
159
|
+
if (maxWidth <= 0 || visibleWidth(oneLine) <= maxWidth) return oneLine;
|
|
160
|
+
|
|
161
|
+
const segments = [...graphemeSegmenter.segment(oneLine)].map((s) => s.segment);
|
|
162
|
+
const ellipsisWidth = visibleWidth(TASK_SUMMARY_ELLIPSIS);
|
|
163
|
+
|
|
164
|
+
if (keysOnly) {
|
|
165
|
+
const fragments = extractKeyFragments(oneLine);
|
|
166
|
+
if (fragments.length > 0) {
|
|
167
|
+
const keyMax = maxWidth - 1;
|
|
168
|
+
let keys = "";
|
|
169
|
+
for (const fragment of fragments) {
|
|
170
|
+
const piece = keys ? `${TASK_SUMMARY_KEY_SEP}${fragment}` : fragment;
|
|
171
|
+
const total = keys + piece;
|
|
172
|
+
if (visibleWidth(total) > keyMax) {
|
|
173
|
+
// Budget exhausted: keep what fits, unless nothing fits yet.
|
|
174
|
+
if (!keys) {
|
|
175
|
+
// A single over-long fragment keeps its tail
|
|
176
|
+
// (extension/symbol) and is prefixed with the ellipsis.
|
|
177
|
+
const fragmentSegments = [...graphemeSegmenter.segment(piece)].map((s) => s.segment);
|
|
178
|
+
keys = `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(fragmentSegments, keyMax - ellipsisWidth)}`;
|
|
179
|
+
}
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
keys = total;
|
|
183
|
+
}
|
|
184
|
+
return keys;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// No distinctive fragments (or prose mode): fall back to head…tail.
|
|
189
|
+
const tailMax = Math.max(
|
|
190
|
+
TASK_SUMMARY_TAIL_MIN,
|
|
191
|
+
Math.min(TASK_SUMMARY_TAIL_MAX, Math.round(maxWidth * TASK_SUMMARY_TAIL_SHARE)),
|
|
192
|
+
);
|
|
193
|
+
const headMax = maxWidth - ellipsisWidth - tailMax;
|
|
194
|
+
if (headMax <= 0) {
|
|
195
|
+
return `${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, maxWidth - ellipsisWidth)}`;
|
|
196
|
+
}
|
|
197
|
+
return `${takeGraphemes(segments, headMax)}${TASK_SUMMARY_ELLIPSIS}${tailGraphemes(segments, tailMax)}`;
|
|
74
198
|
}
|
|
75
199
|
|
|
76
200
|
function formatTokens(count: number): string {
|
|
@@ -199,8 +323,11 @@ export class MonitorStore {
|
|
|
199
323
|
const run = this.find(id);
|
|
200
324
|
if (!run) return;
|
|
201
325
|
run.status = status;
|
|
202
|
-
if (status === "running"
|
|
203
|
-
run.startedAt = Date.now();
|
|
326
|
+
if (status === "running") {
|
|
327
|
+
if (run.startedAt === undefined) run.startedAt = Date.now();
|
|
328
|
+
// A model-fallback retry after a failed attempt restarts the clock; a
|
|
329
|
+
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
330
|
+
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
204
331
|
} else if ((status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
205
332
|
run.endedAt = Date.now();
|
|
206
333
|
}
|
|
@@ -222,6 +349,19 @@ export class MonitorStore {
|
|
|
222
349
|
this.notify();
|
|
223
350
|
}
|
|
224
351
|
|
|
352
|
+
/** Set a widget note on the run (e.g. that its auto-fix chain is still running). */
|
|
353
|
+
setAnnotation(id: number, text: string): void {
|
|
354
|
+
const run = this.find(id);
|
|
355
|
+
if (!run) return;
|
|
356
|
+
run.annotation = text;
|
|
357
|
+
this.notify();
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Look up a run by id without removing it. */
|
|
361
|
+
findRun(id: number): RunView | undefined {
|
|
362
|
+
return this.find(id);
|
|
363
|
+
}
|
|
364
|
+
|
|
225
365
|
/** Remove a run (finished runs leave the widget). Returns the removed run. */
|
|
226
366
|
removeRun(id: number): RunView | undefined {
|
|
227
367
|
const index = this.runs.findIndex((r) => r.id === id);
|
package/src/spawn.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { existsSync, mkdirSync, unlinkSync, rmdirSync, writeFileSync } from "nod
|
|
|
15
15
|
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
|
16
16
|
import { tmpdir } from "node:os";
|
|
17
17
|
import { basename, join } from "node:path";
|
|
18
|
+
import { StringDecoder } from "node:string_decoder";
|
|
18
19
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
19
20
|
import type { Message } from "@earendil-works/pi-ai";
|
|
20
21
|
import type { AgentConfig, AgentSource } from "./agents.ts";
|
|
@@ -134,7 +135,9 @@ export function writeResultArtifact(output: string, agentName: string): string {
|
|
|
134
135
|
const dir = join(tmpdir(), "pi-subagents-results");
|
|
135
136
|
mkdirSync(dir, { recursive: true });
|
|
136
137
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
137
|
-
|
|
138
|
+
// A random suffix keeps same-millisecond writes from clobbering each other.
|
|
139
|
+
const unique = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
|
140
|
+
const filePath = join(dir, `${unique}-${safeName}.md`);
|
|
138
141
|
writeFileSync(filePath, output, "utf8");
|
|
139
142
|
return filePath;
|
|
140
143
|
}
|
|
@@ -457,8 +460,13 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
457
460
|
proc.stdin?.on("error", () => undefined);
|
|
458
461
|
proc.stdin?.end(`Task: ${task}`);
|
|
459
462
|
|
|
463
|
+
// Decode stdout through a StringDecoder so multi-byte UTF-8 characters
|
|
464
|
+
// (CJK, emoji) split across chunk boundaries never produce U+FFFD
|
|
465
|
+
// replacement characters — a corrupted JSON line would drop the whole
|
|
466
|
+
// message (including a reviewer's verdict line) from parsing.
|
|
467
|
+
const stdoutDecoder = new StringDecoder("utf8");
|
|
460
468
|
proc.stdout.on("data", (data) => {
|
|
461
|
-
buffer +=
|
|
469
|
+
buffer += stdoutDecoder.write(data);
|
|
462
470
|
const lines = buffer.split("\n");
|
|
463
471
|
buffer = lines.pop() || "";
|
|
464
472
|
for (const line of lines) processLine(line);
|
|
@@ -469,6 +477,9 @@ export async function runSingleAgent(options: RunSingleOptions): Promise<SingleR
|
|
|
469
477
|
});
|
|
470
478
|
|
|
471
479
|
proc.on("close", (code) => {
|
|
480
|
+
// Flush any bytes still held by the decoder (a trailing incomplete
|
|
481
|
+
// multi-byte sequence) before processing the final buffer.
|
|
482
|
+
buffer += stdoutDecoder.end();
|
|
472
483
|
if (buffer.trim()) processLine(buffer);
|
|
473
484
|
// A null exit code means the process was terminated by a signal and
|
|
474
485
|
// must be reported as failure, never as a false clean completion.
|