@ferris1225/pi-subagents 1.0.0 → 2.0.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/LICENSE +2 -0
- package/README.md +188 -95
- package/agents/cleaner.md +51 -0
- package/agents/explore.md +6 -4
- package/agents/reviewer.md +2 -0
- package/package.json +9 -7
- package/src/agents.ts +2 -7
- package/src/announcements.ts +12 -1
- package/src/completion.ts +7 -36
- package/src/config.ts +310 -364
- package/src/dispatch.ts +145 -275
- package/src/fixloop.ts +0 -16
- package/src/format.ts +28 -30
- package/src/index.ts +6 -3
- package/src/models.ts +89 -106
- package/src/monitor.ts +57 -101
- package/src/prompt.ts +13 -8
- package/src/rpc-run.ts +90 -21
- package/src/runtime.ts +3 -17
- package/src/session-fork.ts +0 -4
- package/src/setup.ts +437 -639
- package/src/spawn.ts +587 -557
- package/src/tools.ts +27 -35
- package/src/ui.ts +3 -7
- package/src/widget.ts +144 -0
- package/src/worktree.ts +1 -1
- package/src/trajectory.ts +0 -312
package/src/monitor.ts
CHANGED
|
@@ -11,8 +11,7 @@
|
|
|
11
11
|
import { stripVTControlCharacters } from "node:util";
|
|
12
12
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
13
13
|
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
14
|
-
import type
|
|
15
|
-
import { redactSensitiveText } from "./trajectory.ts";
|
|
14
|
+
import { emptyUsage, type UsageStats } from "./rpc-run.ts";
|
|
16
15
|
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
17
16
|
|
|
18
17
|
// ---------------------------------------------------------------------------
|
|
@@ -34,7 +33,7 @@ export interface RunView {
|
|
|
34
33
|
* are doing, not just their run id. */
|
|
35
34
|
label?: string;
|
|
36
35
|
model?: string;
|
|
37
|
-
/**
|
|
36
|
+
/** Selected model ref when the run handed off to current main. */
|
|
38
37
|
modelFallbackFrom?: string;
|
|
39
38
|
/** Effective thinking strength this run was launched with (frontmatter/config/global). */
|
|
40
39
|
thinking?: string;
|
|
@@ -46,14 +45,6 @@ export interface RunView {
|
|
|
46
45
|
usage: UsageStats;
|
|
47
46
|
/** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
|
|
48
47
|
activity?: string;
|
|
49
|
-
/** Total tool calls started by the run so far (a progress signal). */
|
|
50
|
-
toolCount?: number;
|
|
51
|
-
/** Tool currently executing (set on tool_start, cleared on tool_end). When set,
|
|
52
|
-
* the run is NOT idle for needs-attention purposes. */
|
|
53
|
-
currentTool?: string;
|
|
54
|
-
/** Epoch ms of the last live activity (tool, usage, status). Used to derive
|
|
55
|
-
* the needs-attention state: no tool running AND now - lastActivityAt > threshold. */
|
|
56
|
-
lastActivityAt?: number;
|
|
57
48
|
/** Epoch ms when the run started executing (set on first "running" status). */
|
|
58
49
|
startedAt?: number;
|
|
59
50
|
/** Epoch ms when the run finished (set on "done"/"failed"). */
|
|
@@ -62,16 +53,6 @@ export interface RunView {
|
|
|
62
53
|
groupId?: string;
|
|
63
54
|
/** Human-readable role within a chain, e.g. "fix round 1" or "re-review round 1". */
|
|
64
55
|
relationLabel?: string;
|
|
65
|
-
/** Free-form orchestration note (e.g. "auto-fix chain running"). */
|
|
66
|
-
annotation?: string;
|
|
67
|
-
/** One-line outcome summary of a finished chain run: a reviewer reports its verdict
|
|
68
|
-
* plus key fragments of what it found ("fail · src/index.ts · render()"), a
|
|
69
|
-
* worker the fragments of what it changed. Unset for non-chain runs. */
|
|
70
|
-
summary?: string;
|
|
71
|
-
/** True when a finished run remains in monitor state (e.g. an auto-fix
|
|
72
|
-
* parent whose chain is still running). beginTurn preserves
|
|
73
|
-
* retained runs so they are not swept between turns. */
|
|
74
|
-
retained?: boolean;
|
|
75
56
|
}
|
|
76
57
|
|
|
77
58
|
/** Optional chain metadata for runs spawned by an auto-fix loop. */
|
|
@@ -280,18 +261,40 @@ export function formatDuration(ms: number): string {
|
|
|
280
261
|
/** Elapsed wall time of a run: live while running, final once finished. */
|
|
281
262
|
export function formatElapsed(run: RunView, now: number = Date.now()): string {
|
|
282
263
|
if (run.startedAt === undefined) return "";
|
|
283
|
-
|
|
284
|
-
// must keep ticking: its `endedAt` was stamped when the review itself
|
|
285
|
-
// finished, but the work is ongoing, so show live elapsed until the chain
|
|
286
|
-
// resolves and the row is removed. Without this, subagent_status would show a
|
|
287
|
-
// frozen elapsed for a run the UI otherwise presents as still active.
|
|
288
|
-
const end = run.retained ? now : (run.endedAt ?? now);
|
|
289
|
-
return formatDuration(end - run.startedAt);
|
|
264
|
+
return formatDuration((run.endedAt ?? now) - run.startedAt);
|
|
290
265
|
}
|
|
291
266
|
|
|
292
267
|
/** Max length of the argument target inside a formatted activity line. */
|
|
293
268
|
export const ACTIVITY_TARGET_MAX = 60;
|
|
294
269
|
|
|
270
|
+
const REDACTED = "<redacted>";
|
|
271
|
+
|
|
272
|
+
/** Remove credentials embedded in otherwise ordinary activity strings such as
|
|
273
|
+
* shell commands and HTTP headers. */
|
|
274
|
+
function redactSensitiveText(value: string): string {
|
|
275
|
+
let text = stripVTControlCharacters(value);
|
|
276
|
+
text = text.replace(
|
|
277
|
+
/(\bauthorization\s*:\s*(?:bearer|basic)\s+)([^\s'"`;,]+)/giu,
|
|
278
|
+
`$1${REDACTED}`,
|
|
279
|
+
);
|
|
280
|
+
text = text.replace(
|
|
281
|
+
/(\bbearer\s+)([A-Za-z0-9._~+/=-]{6,})/giu,
|
|
282
|
+
`$1${REDACTED}`,
|
|
283
|
+
);
|
|
284
|
+
text = text.replace(
|
|
285
|
+
/(\b(?:api[-_]?key|apikey|access[-_]?token|refresh[-_]?token|token|password|passwd|secret|credential|cookie)\b\s*(?:=|:)\s*)(?:"[^"]*"|'[^']*'|[^\s;&,]+)/giu,
|
|
286
|
+
`$1${REDACTED}`,
|
|
287
|
+
);
|
|
288
|
+
text = text.replace(
|
|
289
|
+
/((?:--?(?:api[-_]?key|access[-_]?token|token|password|secret|credential))\s+)(?:"[^"]*"|'[^']*'|\S+)/giu,
|
|
290
|
+
`$1${REDACTED}`,
|
|
291
|
+
);
|
|
292
|
+
return text.replace(
|
|
293
|
+
/\b(?:sk-[A-Za-z0-9_-]{8,}|gh[pousr]_[A-Za-z0-9]{8,}|AKIA[A-Z0-9]{16})\b/gu,
|
|
294
|
+
REDACTED,
|
|
295
|
+
);
|
|
296
|
+
}
|
|
297
|
+
|
|
295
298
|
/** Monitor activity is returned to the parent model and rendered in the terminal,
|
|
296
299
|
* so treat every live string as untrusted before it reaches store state. */
|
|
297
300
|
function sanitizeActivityText(value: string): string {
|
|
@@ -363,19 +366,22 @@ export class MonitorStore {
|
|
|
363
366
|
private subscribers = new Set<() => void>();
|
|
364
367
|
|
|
365
368
|
beginTurn(): void {
|
|
366
|
-
// Clear finished runs from a previous turn, but keep
|
|
367
|
-
//
|
|
368
|
-
// Retained runs (e.g. an auto-fix chain parent whose chain is still
|
|
369
|
-
// running) are also preserved — their status is "done" but they must
|
|
370
|
-
// stay visible until the chain resolves.
|
|
369
|
+
// Clear finished runs from a previous turn, but keep active and parked
|
|
370
|
+
// threads so concurrent work is not wiped between parent turns.
|
|
371
371
|
this.runs = this.runs.filter(
|
|
372
|
-
(r) => isRunActiveStatus(r.status) || r.status === "parked"
|
|
372
|
+
(r) => isRunActiveStatus(r.status) || r.status === "parked",
|
|
373
373
|
);
|
|
374
374
|
this.notify();
|
|
375
375
|
}
|
|
376
376
|
|
|
377
|
+
/** Reserve a stable id for a durable result that must remain independently
|
|
378
|
+
* addressable without appearing as a live monitor row. */
|
|
379
|
+
reserveRunId(): number {
|
|
380
|
+
return this.nextId++;
|
|
381
|
+
}
|
|
382
|
+
|
|
377
383
|
addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
|
|
378
|
-
const id = this.
|
|
384
|
+
const id = this.reserveRunId();
|
|
379
385
|
this.runs.push({
|
|
380
386
|
id,
|
|
381
387
|
agent,
|
|
@@ -384,7 +390,7 @@ export class MonitorStore {
|
|
|
384
390
|
model,
|
|
385
391
|
thinking,
|
|
386
392
|
status: "queued",
|
|
387
|
-
usage:
|
|
393
|
+
usage: emptyUsage(),
|
|
388
394
|
...(meta?.groupId ? { groupId: meta.groupId } : {}),
|
|
389
395
|
...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
|
|
390
396
|
...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined } : {}),
|
|
@@ -400,10 +406,9 @@ export class MonitorStore {
|
|
|
400
406
|
run.status = status;
|
|
401
407
|
if (status === "running" || status === "steering" || status === "interrupting") {
|
|
402
408
|
if (run.startedAt === undefined) run.startedAt = Date.now();
|
|
403
|
-
// A
|
|
409
|
+
// A selected-to-main handoff or resumed generation restarts the clock; a
|
|
404
410
|
// stale endedAt would freeze the elapsed display at the first attempt.
|
|
405
411
|
if (run.endedAt !== undefined) run.endedAt = undefined;
|
|
406
|
-
run.lastActivityAt = Date.now();
|
|
407
412
|
} else if ((status === "parked" || status === "done" || status === "failed") && run.endedAt === undefined) {
|
|
408
413
|
run.endedAt = Date.now();
|
|
409
414
|
}
|
|
@@ -414,11 +419,10 @@ export class MonitorStore {
|
|
|
414
419
|
if (!run) return;
|
|
415
420
|
run.usage = { ...usage };
|
|
416
421
|
if (model) run.model = model;
|
|
417
|
-
run.lastActivityAt = Date.now();
|
|
418
422
|
this.notify();
|
|
419
423
|
}
|
|
420
424
|
|
|
421
|
-
/** Record the final actual model and
|
|
425
|
+
/** Record the final actual model and selected-to-main transition. */
|
|
422
426
|
setModel(id: number, model?: string, fallbackFrom?: string): void {
|
|
423
427
|
const run = this.find(id);
|
|
424
428
|
if (!run) return;
|
|
@@ -427,64 +431,40 @@ export class MonitorStore {
|
|
|
427
431
|
this.notify();
|
|
428
432
|
}
|
|
429
433
|
|
|
434
|
+
/** Record the capability-clamped thinking level for the active model. */
|
|
435
|
+
setThinking(id: number, thinking?: string): void {
|
|
436
|
+
const run = this.find(id);
|
|
437
|
+
if (!run || !thinking) return;
|
|
438
|
+
run.thinking = thinking;
|
|
439
|
+
this.notify();
|
|
440
|
+
}
|
|
441
|
+
|
|
430
442
|
/** Update the run's current one-line activity (what it is doing now). */
|
|
431
443
|
setActivity(id: number, text: string): void {
|
|
432
444
|
const run = this.find(id);
|
|
433
445
|
if (!run) return;
|
|
434
446
|
run.activity = sanitizeActivityText(text) || undefined;
|
|
435
|
-
run.lastActivityAt = Date.now();
|
|
436
447
|
this.notify();
|
|
437
448
|
}
|
|
438
449
|
|
|
439
|
-
/** Record a tool starting
|
|
440
|
-
* A running tool means the run is NOT idle, so needs-attention is suppressed
|
|
441
|
-
* while it stays current. */
|
|
450
|
+
/** Record a tool starting and update the run's visible activity. */
|
|
442
451
|
recordToolStart(id: number, toolName: string, activity: string): void {
|
|
443
452
|
const run = this.find(id);
|
|
444
453
|
if (!run) return;
|
|
445
454
|
const safeToolName = sanitizeActivityText(toolName) || "tool";
|
|
446
|
-
run.toolCount = (run.toolCount ?? 0) + 1;
|
|
447
|
-
run.currentTool = safeToolName;
|
|
448
455
|
run.activity = sanitizeActivityText(activity) || safeToolName;
|
|
449
|
-
run.lastActivityAt = Date.now();
|
|
450
456
|
this.notify();
|
|
451
457
|
}
|
|
452
458
|
|
|
453
|
-
/** Record a tool
|
|
454
|
-
*
|
|
459
|
+
/** Record a failed tool; successful completions keep their last activity
|
|
460
|
+
* until the next model event supplies a more useful description. */
|
|
455
461
|
recordToolEnd(id: number, toolName: string, isError: boolean): void {
|
|
456
462
|
const run = this.find(id);
|
|
457
463
|
if (!run) return;
|
|
458
|
-
run.currentTool = undefined;
|
|
459
|
-
run.lastActivityAt = Date.now();
|
|
460
464
|
if (isError) run.activity = `✗ ${sanitizeActivityText(toolName) || "tool"} failed`;
|
|
461
465
|
this.notify();
|
|
462
466
|
}
|
|
463
467
|
|
|
464
|
-
/** Set an orchestration note on the run (e.g. auto-fix chain running). */
|
|
465
|
-
setAnnotation(id: number, text: string): void {
|
|
466
|
-
const run = this.find(id);
|
|
467
|
-
if (!run) return;
|
|
468
|
-
run.annotation = text;
|
|
469
|
-
this.notify();
|
|
470
|
-
}
|
|
471
|
-
|
|
472
|
-
/** Set the run's one-line outcome summary (what a finished chain round did). */
|
|
473
|
-
setSummary(id: number, text: string | undefined): void {
|
|
474
|
-
const run = this.find(id);
|
|
475
|
-
if (!run) return;
|
|
476
|
-
run.summary = text;
|
|
477
|
-
this.notify();
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** Keep a finished chain step in status state until its group settles. */
|
|
481
|
-
setRetained(id: number, retained: boolean): void {
|
|
482
|
-
const run = this.find(id);
|
|
483
|
-
if (!run) return;
|
|
484
|
-
run.retained = retained;
|
|
485
|
-
this.notify();
|
|
486
|
-
}
|
|
487
|
-
|
|
488
468
|
setIsolation(id: number, isolation: IsolationMode, integrationStatus?: "pending" | WorktreeFinalizationStatus): void {
|
|
489
469
|
const run = this.find(id);
|
|
490
470
|
if (!run) return;
|
|
@@ -526,7 +506,7 @@ export class MonitorStore {
|
|
|
526
506
|
thinking,
|
|
527
507
|
...(isolation ? { isolation, integrationStatus: isolation === "worktree" ? "pending" as const : undefined } : {}),
|
|
528
508
|
status: "queued",
|
|
529
|
-
usage:
|
|
509
|
+
usage: emptyUsage(),
|
|
530
510
|
});
|
|
531
511
|
this.notify();
|
|
532
512
|
return;
|
|
@@ -539,16 +519,10 @@ export class MonitorStore {
|
|
|
539
519
|
if (isolation) run.isolation = isolation;
|
|
540
520
|
run.integrationStatus = isolation === "worktree" ? "pending" : undefined;
|
|
541
521
|
run.status = "queued";
|
|
542
|
-
run.usage =
|
|
522
|
+
run.usage = emptyUsage();
|
|
543
523
|
run.activity = undefined;
|
|
544
|
-
run.toolCount = undefined;
|
|
545
|
-
run.currentTool = undefined;
|
|
546
|
-
run.lastActivityAt = undefined;
|
|
547
524
|
run.startedAt = undefined;
|
|
548
525
|
run.endedAt = undefined;
|
|
549
|
-
run.annotation = undefined;
|
|
550
|
-
run.summary = undefined;
|
|
551
|
-
run.retained = undefined;
|
|
552
526
|
this.notify();
|
|
553
527
|
}
|
|
554
528
|
|
|
@@ -589,7 +563,6 @@ export class MonitorStore {
|
|
|
589
563
|
const usage = formatUsageCompact(run.usage);
|
|
590
564
|
const parts = [run.agent];
|
|
591
565
|
if (run.relationLabel) parts.push(run.relationLabel);
|
|
592
|
-
if (run.summary) parts.push(run.summary);
|
|
593
566
|
if (run.model) parts.push(run.model);
|
|
594
567
|
if (run.thinking) parts.push(`thinking ${run.thinking}`);
|
|
595
568
|
if (run.isolation === "worktree") parts.push(`worktree ${run.integrationStatus ?? "active"}`);
|
|
@@ -658,20 +631,3 @@ export function statusLabel(status: RunStatus): string {
|
|
|
658
631
|
return "stopped";
|
|
659
632
|
}
|
|
660
633
|
}
|
|
661
|
-
|
|
662
|
-
/** Theme color matching the status label. */
|
|
663
|
-
export function statusColor(status: RunStatus): "accent" | "success" | "error" | "warning" | "dim" {
|
|
664
|
-
switch (status) {
|
|
665
|
-
case "running":
|
|
666
|
-
case "steering":
|
|
667
|
-
return "accent";
|
|
668
|
-
case "interrupting":
|
|
669
|
-
return "warning";
|
|
670
|
-
case "done":
|
|
671
|
-
return "success";
|
|
672
|
-
case "failed":
|
|
673
|
-
return "error";
|
|
674
|
-
default:
|
|
675
|
-
return "dim";
|
|
676
|
-
}
|
|
677
|
-
}
|
package/src/prompt.ts
CHANGED
|
@@ -16,8 +16,9 @@ import { formatCatalogEntry } from "./agents.ts";
|
|
|
16
16
|
|
|
17
17
|
/** Compact role routing hints, emitted only for roles that are enabled. */
|
|
18
18
|
const ROLE_ROUTING: Record<string, string> = {
|
|
19
|
-
explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only,
|
|
19
|
+
explore: "explore — codebase reconnaissance: broad/open-ended search, multi-file lookups, mapping unfamiliar code, tracing symbols/dependencies (read-only, competent fast model); NOT for one-line lookups.",
|
|
20
20
|
worker: "worker — implement/fix/refactor/test a self-contained task worth a separate context (full tools; plans internally).",
|
|
21
|
+
cleaner: "cleaner — evidence-first cleanup for explicit cleanup intent in any language (for example dead code, redundancy, simplification, or over-engineering) or a requested periodic cleanup pass; audit/find/inspect/report is read-only, while explicit remove/clean/simplify/refactor wording permits verified edits; never PR-count or pre-commit driven (reviewer remains the gate).",
|
|
21
22
|
reviewer: "reviewer — adversarial pre-commit review of a diff (read-only; independent context).",
|
|
22
23
|
};
|
|
23
24
|
|
|
@@ -30,6 +31,8 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
|
|
|
30
31
|
.filter((line): line is string => Boolean(line))
|
|
31
32
|
.map((line) => `- ${line}`)
|
|
32
33
|
.join("\n");
|
|
34
|
+
const hasExplore = agents.some((a) => a.name === "explore");
|
|
35
|
+
const hasCleaner = agents.some((a) => a.name === "cleaner");
|
|
33
36
|
const hasReviewer = agents.some((a) => a.name === "reviewer");
|
|
34
37
|
const hasMultiple = agents.length > 1;
|
|
35
38
|
|
|
@@ -54,18 +57,18 @@ ${catalog}
|
|
|
54
57
|
|
|
55
58
|
${routing ? `Routing:\n${routing}\n` : ""}Dispatch discipline:
|
|
56
59
|
- Handle SIMPLE work INLINE with direct tools: a one-line lookup, single edit, or quick question is a grep/read/edit in the main context — never a sub-agent. Sub-agents cost startup time, tokens, and a context switch.
|
|
57
|
-
- Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It
|
|
58
|
-
- Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker), or a fresh-context review gate (reviewer).
|
|
59
|
-
- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
|
|
60
|
+
- Use \`explore\` PROACTIVELY for codebase reconnaissance: mapping an unfamiliar area, multi-file lookups, tracing symbols across modules, or any "where is X / which files reference Y" question that would take several greps or reading multiple files. It should run on a competent fast code model and returns compressed findings, saving main-context space.
|
|
61
|
+
- Delegate only when isolation genuinely pays: a self-contained implementation/fix with its own validation (worker)${hasCleaner ? ", explicit evidence-first cleanup (cleaner)" : ""}, or a fresh-context review gate (reviewer).
|
|
62
|
+
${hasCleaner ? "- Route explicit cleanup intent in any language to `cleaner` (for example dead code, redundancy, simplification, or over-engineering), including a requested periodic maintenance pass. Audit/find/inspect/report wording means read-only evidence; apply only for explicit remove/clean/simplify/refactor wording. Generic code review without cleanup intent goes to `reviewer`. Never dispatch cleaner by PR count or automatically as the pre-commit gate; `reviewer` separately reviews cleaner edits.\n" : ""}- When in doubt, start with a direct tool call in the main context; escalate to \`explore\` as soon as the search turns broad or crosses multiple files.
|
|
60
63
|
- For an already-known or trivial target, use a direct search/read tool (e.g. grep/find/read) — do not over-delegate a one-line lookup.
|
|
61
|
-
${hasMultiple ?
|
|
64
|
+
${hasMultiple ? `- Run INDEPENDENT tasks in parallel: one subagent call with a \`tasks\` array, and track them with your todo list. Parallel worker items default to detached Git worktree isolation; pass \`isolation: "shared"\` only when a worker intentionally needs the caller's live uncommitted tree.${hasCleaner ? " Cleaner is also write-capable and may use explicit worktree isolation." : ""} Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then ${hasCleaner ? "worker/cleaner" : "worker"}, then reviewer).\n` : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for ${hasCleaner ? "worker, cleaner, or another" : "worker or another"} write-capable agent in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
|
|
62
65
|
- Brief each sub-agent as self-contained: goal, exact paths, constraints, expected output. It has NO memory of this conversation.
|
|
63
66
|
- Treat delegated agents as leaf workers: do not ask a sub-agent to dispatch another sub-agent; child processes do not have this tool. Use \`subagent_control fork\` on a parked/settled retained thread when you need an independent continuation with preserved context and a new run id.
|
|
64
67
|
- Trust but verify: a sub-agent's summary describes intent, not outcome. Check the actual changes/results before reporting work done.
|
|
65
|
-
|
|
68
|
+
${hasExplore ? "- Treat `explore` findings as a retrieval index, never as sole proof for edits, deletion, security, compatibility, persistence, or dynamic reachability. Re-read load-bearing files before acting. An underpowered model can be false economy on complex dynamic, concurrent, migration, or security-sensitive code; use a stronger model or specialist there.\n" : ""}
|
|
66
69
|
Vision tasks:
|
|
67
70
|
- Judge whether a delegated task may require viewing images (frontend screenshots, mockups, design files, visual regression comparisons). If it might, pass \`vision: true\` in the subagent call and give the sub-agent the exact image paths — it reads them with its read tool.
|
|
68
|
-
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast
|
|
71
|
+
- \`vision: true\` runs the sub-agent on the vision-capable model configured in /subagents-setup; when none is configured it falls back to the main session's current model. Do not skip the flag because the agent's default model looks fast — a non-vision model cannot see the images.
|
|
69
72
|
|
|
70
73
|
Result handoff (do not re-state):
|
|
71
74
|
- A sub-agent's result arrives as a message that is already shown to the user. Do NOT restate, paraphrase, or re-summarize its findings in your reply — that just burns tokens duplicating what is already visible. The user can read the result above.
|
|
@@ -75,5 +78,7 @@ Result handoff (do not re-state):
|
|
|
75
78
|
|
|
76
79
|
Review & verification:
|
|
77
80
|
- Never report an unrun check as passed; report it as unavailable or as a pre-existing failure.
|
|
78
|
-
${hasReviewer ?
|
|
81
|
+
${hasReviewer ? `- For non-trivial diffs${hasCleaner ? " (including cleaner edits)" : ""}, run one fresh read-only \`reviewer\` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.
|
|
82
|
+
- Use multi-model cross-review only when explicitly requested or for genuinely high-risk changes (security, unsafe/FFI, persistence-migration, concurrency). Reviewers are read-only; only the main agent edits.
|
|
83
|
+
` : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
|
|
79
84
|
}
|
package/src/rpc-run.ts
CHANGED
|
@@ -14,7 +14,7 @@ import { tmpdir } from "node:os";
|
|
|
14
14
|
import { basename, join } from "node:path";
|
|
15
15
|
import { StringDecoder } from "node:string_decoder";
|
|
16
16
|
import type { Message } from "@earendil-works/pi-ai";
|
|
17
|
-
import type { AgentConfig
|
|
17
|
+
import type { AgentConfig } from "./agents.ts";
|
|
18
18
|
import type { ThinkingLevel } from "./config.ts";
|
|
19
19
|
import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
|
|
20
20
|
|
|
@@ -43,7 +43,6 @@ export interface UsageStats {
|
|
|
43
43
|
|
|
44
44
|
export interface RpcSingleResult {
|
|
45
45
|
agent: string;
|
|
46
|
-
agentSource: AgentSource | "unknown";
|
|
47
46
|
task: string;
|
|
48
47
|
exitCode: number;
|
|
49
48
|
messages: Message[];
|
|
@@ -53,28 +52,29 @@ export interface RpcSingleResult {
|
|
|
53
52
|
thinking?: string;
|
|
54
53
|
stopReason?: string;
|
|
55
54
|
errorMessage?: string;
|
|
56
|
-
/**
|
|
55
|
+
/** Selected model when this result handed off to the current main model. */
|
|
57
56
|
modelFallbackFrom?: string;
|
|
58
57
|
dispatchFailed?: boolean;
|
|
59
58
|
/** An accepted generation failed because an RPC prompt was rejected before
|
|
60
|
-
* model execution. This remains model
|
|
61
|
-
*
|
|
59
|
+
* model execution. This remains main-model handoff eligible even when an
|
|
60
|
+
* earlier, aborted objective left assistant text in the session. */
|
|
62
61
|
rpcPromptRejected?: boolean;
|
|
62
|
+
/** The child accepted a prompt; startup retries must never duplicate it. */
|
|
63
|
+
rpcPromptAccepted?: boolean;
|
|
64
|
+
/** Pi emitted agent/turn/model/tool activity for this attempt. */
|
|
65
|
+
rpcActivity?: boolean;
|
|
63
66
|
startupRetries?: number;
|
|
64
|
-
modelRetries?: number;
|
|
65
67
|
failedTools?: Array<{ toolName: string; error: string }>;
|
|
66
68
|
sessionId?: string;
|
|
67
69
|
sessionDir?: string;
|
|
68
|
-
|
|
70
|
+
/** Original task/project cwd used for result-artifact retention buckets. */
|
|
71
|
+
projectCwd?: string;
|
|
69
72
|
/** Internal disposition: dispatch suppresses completion delivery for parks. */
|
|
70
73
|
parked?: boolean;
|
|
71
74
|
/** Stable logical run id assigned by dispatch (also present on queued results). */
|
|
72
75
|
runId?: number;
|
|
73
76
|
/** Filesystem isolation selected for this logical thread. */
|
|
74
77
|
isolation?: IsolationMode;
|
|
75
|
-
/** Original parent cwd; isolated children execute at isolationCwd instead. */
|
|
76
|
-
originalCwd?: string;
|
|
77
|
-
isolationCwd?: string;
|
|
78
78
|
/** Final integration state for a worktree-isolated settlement. */
|
|
79
79
|
integrationStatus?: "pending" | WorktreeFinalizationStatus;
|
|
80
80
|
integrationApplied?: boolean;
|
|
@@ -89,7 +89,7 @@ export interface RpcSingleResult {
|
|
|
89
89
|
|
|
90
90
|
export type SubagentLiveEvent =
|
|
91
91
|
| { kind: "status"; status: "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed" }
|
|
92
|
-
| { kind: "model"; model?: string; fallbackFrom?: string }
|
|
92
|
+
| { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
|
|
93
93
|
| { kind: "usage"; usage: UsageStats; model?: string }
|
|
94
94
|
| { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
|
|
95
95
|
| { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
|
|
@@ -115,7 +115,7 @@ interface AttemptControl {
|
|
|
115
115
|
}
|
|
116
116
|
|
|
117
117
|
/**
|
|
118
|
-
* Stable control surface for a logical run generation.
|
|
118
|
+
* Stable control surface for a logical run generation. Startup/main-handoff attempts
|
|
119
119
|
* attach and detach beneath it, so callers never retain a stale child handle.
|
|
120
120
|
* Control calls are serialized to prevent overlapping abort/settle/prompt flows.
|
|
121
121
|
*/
|
|
@@ -168,10 +168,6 @@ export class RpcRunControl {
|
|
|
168
168
|
this.setPhase("parked");
|
|
169
169
|
}
|
|
170
170
|
|
|
171
|
-
markQueued(): void {
|
|
172
|
-
this.setPhase("queued");
|
|
173
|
-
}
|
|
174
|
-
|
|
175
171
|
markStarting(): void {
|
|
176
172
|
this.setPhase("starting");
|
|
177
173
|
}
|
|
@@ -347,6 +343,46 @@ export function extractToolErrorText(content: unknown): string {
|
|
|
347
343
|
.join("\n");
|
|
348
344
|
}
|
|
349
345
|
|
|
346
|
+
interface ChildRetryPolicyExtension {
|
|
347
|
+
dir: string;
|
|
348
|
+
filePath: string;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Build a child-only Pi extension that replaces the selected provider's
|
|
352
|
+
* stream adapter with its registered API implementation while forcing
|
|
353
|
+
* maxRetries=0. It uses Pi's public extension and pi-ai compatibility APIs, so
|
|
354
|
+
* it works in Node and standalone/Bun builds without touching user settings. */
|
|
355
|
+
export async function writeChildRetryPolicyExtension(
|
|
356
|
+
modelRef?: string,
|
|
357
|
+
): Promise<ChildRetryPolicyExtension> {
|
|
358
|
+
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
|
|
359
|
+
const filePath = join(dir, "no-provider-retries.mjs");
|
|
360
|
+
const slash = modelRef?.indexOf("/") ?? -1;
|
|
361
|
+
const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
|
|
362
|
+
const source = `import { getApiProvider } from "@earendil-works/pi-ai/compat";\n`
|
|
363
|
+
+ `const selectedProvider = ${JSON.stringify(selectedProvider)};\n`
|
|
364
|
+
+ `export default function noProviderRetries(pi) {\n`
|
|
365
|
+
+ ` pi.on("before_provider_request", (_event, ctx) => {\n`
|
|
366
|
+
+ ` const providerId = ctx.model?.provider ?? selectedProvider;\n`
|
|
367
|
+
+ ` if (!providerId) return;\n`
|
|
368
|
+
+ ` pi.registerProvider(providerId, {\n`
|
|
369
|
+
+ ` streamSimple(model, context, options) {\n`
|
|
370
|
+
+ ` const api = getApiProvider(model.api);\n`
|
|
371
|
+
+ ` if (!api) throw new Error(\`No API stream implementation is registered for \${model.api}.\`);\n`
|
|
372
|
+
+ ` return api.streamSimple(model, context, { ...options, maxRetries: 0 });\n`
|
|
373
|
+
+ ` },\n`
|
|
374
|
+
+ ` });\n`
|
|
375
|
+
+ ` });\n`
|
|
376
|
+
+ `}\n`;
|
|
377
|
+
try {
|
|
378
|
+
await writeFile(filePath, source, "utf8");
|
|
379
|
+
return { dir, filePath };
|
|
380
|
+
} catch (error) {
|
|
381
|
+
await rm(dir, { recursive: true, force: true }).catch(() => undefined);
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
350
386
|
async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
|
|
351
387
|
const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
|
|
352
388
|
const safeName = agentName.replace(/[^\w.-]+/g, "_");
|
|
@@ -431,12 +467,17 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
431
467
|
args.push("--append-system-prompt", tmpPromptPath);
|
|
432
468
|
}
|
|
433
469
|
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
470
|
+
let retryPolicy: ChildRetryPolicyExtension;
|
|
471
|
+
try {
|
|
472
|
+
retryPolicy = await writeChildRetryPolicyExtension(agent.model);
|
|
473
|
+
args.push("--extension", retryPolicy.filePath);
|
|
474
|
+
} catch (error) {
|
|
475
|
+
if (tmpPromptDir) await rm(tmpPromptDir, { recursive: true, force: true }).catch(() => undefined);
|
|
476
|
+
throw error;
|
|
477
|
+
}
|
|
478
|
+
|
|
437
479
|
const result: RpcSingleResult = {
|
|
438
480
|
agent: agentName,
|
|
439
|
-
agentSource: agent.source,
|
|
440
481
|
task,
|
|
441
482
|
exitCode: 0,
|
|
442
483
|
messages: [],
|
|
@@ -446,7 +487,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
446
487
|
thinking: thinkingLevel,
|
|
447
488
|
sessionId: options.sessionId,
|
|
448
489
|
sessionDir: options.sessionDir,
|
|
449
|
-
resumed,
|
|
450
490
|
};
|
|
451
491
|
|
|
452
492
|
const childDepth = currentSubagentDepth(options.env) + 1;
|
|
@@ -526,6 +566,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
526
566
|
const resolveInitialPrompt = (accepted: boolean, error?: Error): void => {
|
|
527
567
|
if (initialPromptResolved) return;
|
|
528
568
|
initialPromptResolved = true;
|
|
569
|
+
if (accepted) result.rpcPromptAccepted = true;
|
|
529
570
|
initialPrompt.resolve({ accepted, error });
|
|
530
571
|
};
|
|
531
572
|
|
|
@@ -752,6 +793,33 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
752
793
|
}
|
|
753
794
|
if (finished) return;
|
|
754
795
|
|
|
796
|
+
if (
|
|
797
|
+
[
|
|
798
|
+
"agent_start",
|
|
799
|
+
"agent_end",
|
|
800
|
+
"turn_start",
|
|
801
|
+
"turn_end",
|
|
802
|
+
"message_start",
|
|
803
|
+
"message_update",
|
|
804
|
+
"message_end",
|
|
805
|
+
"tool_execution_start",
|
|
806
|
+
"tool_execution_update",
|
|
807
|
+
"tool_execution_end",
|
|
808
|
+
"auto_retry_start",
|
|
809
|
+
"auto_retry_end",
|
|
810
|
+
"agent_settled",
|
|
811
|
+
].includes(event.type)
|
|
812
|
+
) {
|
|
813
|
+
result.rpcActivity = true;
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// The child provider adapter disables request-level retries. Cancel Pi's
|
|
817
|
+
// separate outer turn retry the instant it is scheduled, before another
|
|
818
|
+
// same-model provider call can begin.
|
|
819
|
+
if (event.type === "auto_retry_start") {
|
|
820
|
+
void send({ type: "abort_retry" }).catch(() => undefined);
|
|
821
|
+
}
|
|
822
|
+
|
|
755
823
|
// Child RPC mode exposes extension dialogs. Sub-agents are non-interactive:
|
|
756
824
|
// cancel blocking dialogs so an unrelated child extension cannot deadlock.
|
|
757
825
|
if (
|
|
@@ -987,5 +1055,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
|
|
|
987
1055
|
/* ignore */
|
|
988
1056
|
}
|
|
989
1057
|
}
|
|
1058
|
+
await rm(retryPolicy.dir, { recursive: true, force: true }).catch(() => undefined);
|
|
990
1059
|
}
|
|
991
1060
|
}
|
package/src/runtime.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
type RecoveryRecord,
|
|
28
28
|
} from "./recovery.ts";
|
|
29
29
|
import type { RpcRunControl } from "./rpc-run.ts";
|
|
30
|
-
import { trajectoryStore } from "./trajectory.ts";
|
|
31
30
|
import type { SingleResult } from "./spawn.ts";
|
|
32
31
|
import type { IsolationMode, WorktreeFinalization, WorktreeIsolation } from "./worktree.ts";
|
|
33
32
|
|
|
@@ -54,8 +53,6 @@ export interface SubagentThread {
|
|
|
54
53
|
/** Actual child cwd (the equivalent path inside an isolated worktree). */
|
|
55
54
|
executionCwd: string;
|
|
56
55
|
vision: boolean;
|
|
57
|
-
/** Exact primary→fallback refs inherited by a session fork. */
|
|
58
|
-
modelPool: string[];
|
|
59
56
|
thinkingLevel?: ThinkingLevel;
|
|
60
57
|
isolation: IsolationMode;
|
|
61
58
|
worktree?: WorktreeIsolation;
|
|
@@ -118,9 +115,6 @@ export interface SubagentRuntime {
|
|
|
118
115
|
sessionDirs: Set<string>;
|
|
119
116
|
retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
|
|
120
117
|
retireThreadSession: (thread: SubagentThread) => void;
|
|
121
|
-
/** Worktree/patch paths intentionally retained after a failed integration. */
|
|
122
|
-
retainedArtifactPaths: Set<string>;
|
|
123
|
-
retainWorktreeArtifacts: (finalization: WorktreeFinalization) => void;
|
|
124
118
|
/** Flip sessionActive off and release all session-scoped resources. */
|
|
125
119
|
shutdown: () => Promise<void>;
|
|
126
120
|
}
|
|
@@ -141,9 +135,11 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
141
135
|
// Computing this at delivery (emit) time — not when the item was
|
|
142
136
|
// pushed — reflects the current monitor state, since finishing runs
|
|
143
137
|
// are removed from the monitor before their completion is pushed.
|
|
138
|
+
// Auto-fix parents are flipped back to "running" while their chain
|
|
139
|
+
// owns the logical run, so they are included without a special case.
|
|
144
140
|
const active = monitor
|
|
145
141
|
.getRuns()
|
|
146
|
-
.filter((run) => isRunActiveStatus(run.status)
|
|
142
|
+
.filter((run) => isRunActiveStatus(run.status))
|
|
147
143
|
.map((run) => ({ id: run.id, agent: run.agent, label: run.label }));
|
|
148
144
|
const message = {
|
|
149
145
|
customType: "subagent-result",
|
|
@@ -171,11 +167,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
171
167
|
threads: new Map<number, SubagentThread>(),
|
|
172
168
|
preflightOperations: new Set<Promise<void>>(),
|
|
173
169
|
sessionDirs: new Set<string>(),
|
|
174
|
-
retainedArtifactPaths: new Set<string>(),
|
|
175
|
-
retainWorktreeArtifacts: (finalization) => {
|
|
176
|
-
if (finalization.worktreePath) runtime.retainedArtifactPaths.add(finalization.worktreePath);
|
|
177
|
-
if (finalization.patchPath) runtime.retainedArtifactPaths.add(finalization.patchPath);
|
|
178
|
-
},
|
|
179
170
|
retainSession: (result) => {
|
|
180
171
|
if (result.sessionDir) runtime.sessionDirs.add(result.sessionDir);
|
|
181
172
|
},
|
|
@@ -241,7 +232,6 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
241
232
|
for (const thread of runtime.threads.values()) {
|
|
242
233
|
const finalization = await thread.finalizeIsolation(thread.generation).catch(() => undefined);
|
|
243
234
|
if (finalization?.status === "retained") {
|
|
244
|
-
runtime.retainWorktreeArtifacts(finalization);
|
|
245
235
|
recoveryRecords.push(recoveryRecordFromFinalization(thread.id, finalization));
|
|
246
236
|
if (!thread.isolationFailureNotified) {
|
|
247
237
|
thread.isolationFailureNotified = true;
|
|
@@ -268,12 +258,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
|
|
|
268
258
|
}
|
|
269
259
|
runtime.sessionDirs.clear();
|
|
270
260
|
runtime.preflightOperations.clear();
|
|
271
|
-
// Deliberately do not remove retainedArtifactPaths: they are the recovery
|
|
272
|
-
// path after a failed patch apply/cleanup.
|
|
273
261
|
runtime.threads.clear();
|
|
274
262
|
monitor.clear();
|
|
275
|
-
// Lifecycle trajectories are parent-session scoped.
|
|
276
|
-
trajectoryStore.clearAll();
|
|
277
263
|
},
|
|
278
264
|
};
|
|
279
265
|
|
package/src/session-fork.ts
CHANGED
|
@@ -7,7 +7,6 @@ import { tmpdir } from "node:os";
|
|
|
7
7
|
import { join } from "node:path";
|
|
8
8
|
|
|
9
9
|
export interface ForkedSession {
|
|
10
|
-
sourceSessionFile: string;
|
|
11
10
|
sessionDir: string;
|
|
12
11
|
sessionId: string;
|
|
13
12
|
sessionFile: string;
|
|
@@ -15,7 +14,6 @@ export interface ForkedSession {
|
|
|
15
14
|
|
|
16
15
|
/** Locate one retained session by its authoritative header id. */
|
|
17
16
|
export async function findRetainedSessionFile(
|
|
18
|
-
cwd: string,
|
|
19
17
|
sessionDir: string,
|
|
20
18
|
sessionId: string,
|
|
21
19
|
): Promise<string> {
|
|
@@ -47,7 +45,6 @@ export async function forkRetainedSession(options: {
|
|
|
47
45
|
sessionId: string;
|
|
48
46
|
}): Promise<ForkedSession> {
|
|
49
47
|
const sourceSessionFile = await findRetainedSessionFile(
|
|
50
|
-
options.cwd,
|
|
51
48
|
options.sessionDir,
|
|
52
49
|
options.sessionId,
|
|
53
50
|
);
|
|
@@ -72,7 +69,6 @@ export async function forkRetainedSession(options: {
|
|
|
72
69
|
throw new Error(`Forked session branch has no persisted assistant checkpoint at ${sessionFile}.`);
|
|
73
70
|
}
|
|
74
71
|
return {
|
|
75
|
-
sourceSessionFile,
|
|
76
72
|
sessionDir,
|
|
77
73
|
sessionId: manager.getSessionId(),
|
|
78
74
|
sessionFile,
|