@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/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 { UsageStats } from "./spawn.ts";
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
- /** Primary model ref when the run advanced to another candidate in its pool. */
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
- // A retained row (e.g. an auto-fix chain parent whose chain is still running)
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 any still-active
367
- // (queued/running) ones so a concurrent sub-agent call is not wiped.
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" || r.retained,
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.nextId++;
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: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
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 model-fallback retry or resumed generation restarts the clock; 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 primary-to-backup transition. */
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: counts it, marks it current, and updates activity.
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 ending: clears the current-tool marker (so the run becomes
454
- * eligible for needs-attention again) and notes the failure in activity. */
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: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 },
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 = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, cost: 0, contextTokens: 0, turns: 0 };
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, cheap fast model); NOT for one-line lookups.",
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 runs on a fast cheap model and returns compressed findings, so delegating recon costs little.
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 ? "- 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. Let the automatically resumed main agent launch dependent work only after its prerequisite result arrives (e.g. explore, then worker, then reviewer).\n" : ""}- Single dispatch stays in the shared working tree by default. Use \`isolation: "worktree"\` only for worker/write-capable agents in a Git repository; never request it for explore/reviewer, and never silently retry shared after setup fails.
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/cheap — a non-vision model cannot see the images.
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 ? "- For non-trivial diffs, run one fresh read-only `reviewer` sub-agent before reporting done. Fix only concrete blockers and re-review at most once.\n- 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.\n" : ""}- Commit or push only when explicitly requested, applicable checks pass, and no accepted blockers remain.`;
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, AgentSource } from "./agents.ts";
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
- /** Primary model when this result came from a later candidate in its configured pool. */
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-candidate retry/fallback eligible even
61
- * when an earlier, aborted objective left assistant text in the session. */
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
- resumed?: boolean;
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. Retry/fallback attempts
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
- const resumed = Boolean(
435
- options.sessionDir && options.sessionId && sessionExists(options.sessionDir, options.sessionId),
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) || run.retained)
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
 
@@ -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,