@ferris1225/pi-subagents 4.1.1 → 4.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/prompt.ts CHANGED
@@ -11,22 +11,42 @@ function bullets(lines: readonly string[]): string {
11
11
  return lines.map((line) => `- ${line}`).join("\n");
12
12
  }
13
13
 
14
- export function buildDelegationDirective(agents: AgentConfig[]): string {
14
+ export function buildDelegationDirective(
15
+ agents: AgentConfig[],
16
+ options: { maxFixRounds?: number } = {},
17
+ ): string {
15
18
  if (agents.length === 0) return "";
16
19
 
17
20
  const catalog = agents.map(formatCatalogEntry).join("\n");
18
21
  const hasExplorer = agents.some((agent) => agent.name === "explorer");
19
22
  const hasWorker = agents.some((agent) => agent.name === "worker");
20
23
  const hasCleaner = agents.some((agent) => agent.name === "cleaner");
24
+ const hasDocumenter = agents.some((agent) => agent.name === "documenter");
21
25
  const hasReviewer = agents.some((agent) => agent.name === "reviewer");
22
26
  const hasMultiple = agents.length > 1;
23
- const worktreeTargets = hasWorker && hasCleaner
24
- ? "worker, cleaner, or another"
25
- : hasWorker
26
- ? "worker or another"
27
- : hasCleaner
28
- ? "cleaner or another"
29
- : "a";
27
+ const autoFixEnabled = hasWorker && (options.maxFixRounds ?? 1) > 0;
28
+ const codeWriterNames = [
29
+ ...(hasWorker ? ["worker"] : []),
30
+ ...(hasCleaner ? ["cleaner"] : []),
31
+ ];
32
+ const reviewedWriterNames = [
33
+ ...codeWriterNames,
34
+ ...(hasDocumenter ? ["documenter"] : []),
35
+ ];
36
+ const automaticWriterRoute = [
37
+ ...(hasDocumenter ? ["documenter"] : []),
38
+ ...(hasReviewer ? ["reviewer"] : []),
39
+ ].join(" → ");
40
+ const namedWorktreeTargets = [
41
+ ...(hasWorker ? ["worker"] : []),
42
+ ...(hasCleaner ? ["cleaner"] : []),
43
+ ...(hasDocumenter ? ["documenter"] : []),
44
+ ];
45
+ const worktreeTargets = namedWorktreeTargets.length === 0
46
+ ? "a"
47
+ : namedWorktreeTargets.length === 1
48
+ ? `${namedWorktreeTargets[0]} or another`
49
+ : `${namedWorktreeTargets.slice(0, -1).join(", ")}, ${namedWorktreeTargets.at(-1)}, or another`;
30
50
 
31
51
  const dispatchRules = [
32
52
  "Handle simple work inline with direct tools: one-line lookups, known-target reads/edits, and quick questions do not justify a child process.",
@@ -40,12 +60,17 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
40
60
  : []),
41
61
  ...(hasCleaner
42
62
  ? [
43
- `Use \`cleaner\` only when the user explicitly authorizes cleanup/removal/simplification edits, including a requested maintenance pass. It gathers evidence, then applies every safe proven in-scope cut; zero edits is valid. Generic or read-only audit, inspect, report, review, code-health, plan, proposed-solution, or cleanup-candidate assessment goes to ${hasReviewer ? "`reviewer`" : "direct main-context inspection because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
63
+ `Use \`cleaner\` only for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance. Once dispatched, it applies every safe proven in-scope cut without item-by-item approval; zero edits is valid only if none is proved. Generic or read-only audit, review, code-health, plan, or cleanup-candidate assessment goes to ${hasReviewer ? "`reviewer`" : "direct main-context inspection because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
64
+ ]
65
+ : []),
66
+ ...(hasDocumenter
67
+ ? [
68
+ `Use \`documenter\` directly for explicit whole-codebase maintenance or standalone documentation work.${codeWriterNames.length > 0 ? ` Successful ${codeWriterNames.join("/")} runs already auto-sync the actual diff; never dispatch a duplicate.` : ""} Zero edits is valid and broad mode is never inferred. It changes docs/comments only and never runtime behavior, versions, release state, or ${hasReviewer ? "the final reviewer gate" : "direct final verification"}.`,
44
69
  ]
45
70
  : []),
46
71
  ...(hasReviewer
47
72
  ? [
48
- `Use \`reviewer\` for generic/read-only assessments and as the fresh independent pre-commit gate for non-trivial diffs${hasCleaner ? ", including cleaner edits" : ""}. Advisory findings do not authorize follow-up edits; only gate verdicts can enter auto-fix.`,
73
+ `Use \`reviewer\` for read-only assessments or an explicit gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get a fresh read-only reviewer gate.` : ""} Advisory output has no VERDICT: it stays read-only and does not authorize follow-up edits.`,
49
74
  ]
50
75
  : []),
51
76
  "Brief every child with the complete goal, exact paths, constraints, and expected output. It has no memory of this conversation.",
@@ -55,7 +80,7 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
55
80
  "Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
56
81
  ]
57
82
  : []),
58
- `Filesystem isolation: single tasks default to shared${hasWorker ? "; parallel worker tasks default to detached Git worktrees" : ""}${hasCleaner ? "; cleaner defaults to shared" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repository with committed HEAD. Read-only agents reject it, and setup/integration failure never falls back silently to shared.`,
83
+ `Filesystem isolation: single tasks default to shared${hasWorker ? "; parallel worker tasks default to detached Git worktrees" : ""}${hasCleaner ? "; cleaner defaults to shared" : ""}${hasDocumenter ? "; documenter defaults to shared" : ""}. Request \`isolation: "worktree"\` only for ${worktreeTargets} write-capable agent in a Git repository with committed HEAD. Read-only agents reject it, and setup/integration failure never falls back silently to shared.`,
59
84
  "A configured child model/provider failure automatically continues the same retained session on the current main model; do not redispatch. Ordinary tool/task failures stay on the selected model.",
60
85
  "Trust but verify: inspect actual changes/results before reporting completion.",
61
86
  ];
@@ -69,9 +94,19 @@ export function buildDelegationDirective(agents: AgentConfig[]): string {
69
94
 
70
95
  const verificationRules = [
71
96
  "Never report an unrun check as passed; identify unavailable checks and pre-existing failures honestly.",
97
+ ...(automaticWriterRoute && reviewedWriterNames.length > 0
98
+ ? [
99
+ `Successful top-level write roles automatically continue through enabled downstream roles (${automaticWriterRoute}) to one final delivery; never duplicate stages.`,
100
+ ]
101
+ : []),
72
102
  ...(hasReviewer
73
103
  ? [
74
- "Send every non-trivial diff through one fresh read-only `reviewer` gate before reporting done. Resolve every finding; do not bypass the configured auto-fix/re-review cap.",
104
+ ...(hasDocumenter
105
+ ? [
106
+ `A direct REVIEW_PASS is preliminary: runtime runs documenter on the pending diff, then a fresh reviewer. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps auto-fix; maxFixRounds limits worker fixes only, not initial docs/review." : "cannot start fixes while worker/fix rounds are disabled."}`,
107
+ ]
108
+ : []),
109
+ "Resolve every gate finding; do not bypass the configured auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
75
110
  "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
76
111
  ]
77
112
  : []),
package/src/recovery.ts CHANGED
@@ -1,145 +1,145 @@
1
- /** Durable handoff for worktree integration/cleanup failures across sessions. */
2
-
3
- import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
- import { existsSync } from "node:fs";
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join } from "node:path";
7
- import { stripVTControlCharacters } from "node:util";
8
- import type { WorktreeFinalization } from "./worktree.ts";
9
-
10
- export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
- const RECOVERY_MANIFEST_VERSION = 1;
12
-
13
- export interface RecoveryRecord {
14
- runId: number;
15
- createdAt: number;
16
- integrated: boolean;
17
- worktreePath?: string;
18
- patchPath?: string;
19
- error?: string;
20
- }
21
-
22
- interface RecoveryManifest {
23
- version: number;
24
- records: RecoveryRecord[];
25
- }
26
-
27
- export function getRecoveryManifestPath(configPath: string): string {
28
- return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
29
- }
30
-
31
- function normalizeRecord(value: unknown): RecoveryRecord | undefined {
32
- if (!value || typeof value !== "object") return undefined;
33
- const raw = value as Record<string, unknown>;
34
- if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
35
- if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
36
- return {
37
- runId: raw.runId,
38
- createdAt: raw.createdAt,
39
- integrated: raw.integrated === true,
40
- ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
- ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
- ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
43
- };
44
- }
45
-
46
- export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
47
- try {
48
- const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
49
- records?: unknown;
50
- };
51
- if (!Array.isArray(parsed.records)) return [];
52
- return parsed.records.flatMap((record) => {
53
- const normalized = normalizeRecord(record);
54
- return normalized ? [normalized] : [];
55
- });
56
- } catch {
57
- return [];
58
- }
59
- }
60
-
61
- function recoveryKey(record: RecoveryRecord): string {
62
- return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
63
- }
64
-
65
- async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
66
- if (records.length === 0) {
67
- await rm(path, { force: true });
68
- return;
69
- }
70
- await mkdir(dirname(path), { recursive: true });
71
- const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
72
- try {
73
- const manifest: RecoveryManifest = {
74
- version: RECOVERY_MANIFEST_VERSION,
75
- records: [...records],
76
- };
77
- await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
78
- await rename(temporaryPath, path);
79
- } finally {
80
- await rm(temporaryPath, { force: true }).catch(() => undefined);
81
- }
82
- }
83
-
84
- /** Merge retained artifacts into the durable manifest. */
85
- export async function persistRecoveryRecords(
86
- configPath: string,
87
- records: readonly RecoveryRecord[],
88
- ): Promise<void> {
89
- if (records.length === 0) return;
90
- const path = getRecoveryManifestPath(configPath);
91
- await withFileMutationQueue(path, async () => {
92
- const merged = new Map<string, RecoveryRecord>();
93
- for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
94
- for (const record of records) merged.set(recoveryKey(record), record);
95
- await writeManifest(path, [...merged.values()]);
96
- });
97
- }
98
-
99
- export function recoveryRecordFromFinalization(
100
- runId: number,
101
- finalization: WorktreeFinalization,
102
- now = Date.now(),
103
- ): RecoveryRecord {
104
- return {
105
- runId,
106
- createdAt: now,
107
- integrated: finalization.integrated,
108
- ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
- ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
- ...(finalization.error ? { error: finalization.error } : {}),
111
- };
112
- }
113
-
114
- /** Show retained recovery paths on every later session start until the user
115
- * removes the artifacts. Stale records are pruned automatically. */
116
- export async function announceRecoveryRecords(
117
- configPath: string,
118
- ctx: {
119
- hasUI?: boolean;
120
- ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
121
- },
122
- ): Promise<void> {
123
- if (ctx.hasUI === false) return;
124
- const records = await readRecoveryRecords(configPath);
125
- if (records.length === 0) return;
126
- const live = records.filter((record) =>
127
- (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
- (record.patchPath ? existsSync(record.patchPath) : false),
129
- );
130
- if (live.length !== records.length) {
131
- const path = getRecoveryManifestPath(configPath);
132
- await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
133
- }
134
- for (const record of live) {
135
- const paths = [
136
- record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
137
- record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
138
- ].filter(Boolean).join(" · ");
139
- const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
140
- ctx.ui.notify(
141
- `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
142
- "error",
143
- );
144
- }
145
- }
1
+ /** Durable handoff for worktree integration/cleanup failures across sessions. */
2
+
3
+ import { withFileMutationQueue } from "@earendil-works/pi-coding-agent";
4
+ import { existsSync } from "node:fs";
5
+ import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
+ import { dirname, join } from "node:path";
7
+ import { stripVTControlCharacters } from "node:util";
8
+ import type { WorktreeFinalization } from "./worktree.ts";
9
+
10
+ export const RECOVERY_MANIFEST_FILE_NAME = "pi-subagents-recovery.json";
11
+ const RECOVERY_MANIFEST_VERSION = 1;
12
+
13
+ export interface RecoveryRecord {
14
+ runId: number;
15
+ createdAt: number;
16
+ integrated: boolean;
17
+ worktreePath?: string;
18
+ patchPath?: string;
19
+ error?: string;
20
+ }
21
+
22
+ interface RecoveryManifest {
23
+ version: number;
24
+ records: RecoveryRecord[];
25
+ }
26
+
27
+ export function getRecoveryManifestPath(configPath: string): string {
28
+ return join(dirname(configPath), RECOVERY_MANIFEST_FILE_NAME);
29
+ }
30
+
31
+ function normalizeRecord(value: unknown): RecoveryRecord | undefined {
32
+ if (!value || typeof value !== "object") return undefined;
33
+ const raw = value as Record<string, unknown>;
34
+ if (typeof raw.runId !== "number" || !Number.isInteger(raw.runId) || raw.runId < 1) return undefined;
35
+ if (typeof raw.createdAt !== "number" || !Number.isFinite(raw.createdAt)) return undefined;
36
+ return {
37
+ runId: raw.runId,
38
+ createdAt: raw.createdAt,
39
+ integrated: raw.integrated === true,
40
+ ...(typeof raw.worktreePath === "string" && raw.worktreePath ? { worktreePath: raw.worktreePath } : {}),
41
+ ...(typeof raw.patchPath === "string" && raw.patchPath ? { patchPath: raw.patchPath } : {}),
42
+ ...(typeof raw.error === "string" && raw.error ? { error: raw.error } : {}),
43
+ };
44
+ }
45
+
46
+ export async function readRecoveryRecords(configPath: string): Promise<RecoveryRecord[]> {
47
+ try {
48
+ const parsed = JSON.parse(await readFile(getRecoveryManifestPath(configPath), "utf8")) as {
49
+ records?: unknown;
50
+ };
51
+ if (!Array.isArray(parsed.records)) return [];
52
+ return parsed.records.flatMap((record) => {
53
+ const normalized = normalizeRecord(record);
54
+ return normalized ? [normalized] : [];
55
+ });
56
+ } catch {
57
+ return [];
58
+ }
59
+ }
60
+
61
+ function recoveryKey(record: RecoveryRecord): string {
62
+ return `${record.runId}\0${record.worktreePath ?? ""}\0${record.patchPath ?? ""}\0${record.error ?? ""}`;
63
+ }
64
+
65
+ async function writeManifest(path: string, records: readonly RecoveryRecord[]): Promise<void> {
66
+ if (records.length === 0) {
67
+ await rm(path, { force: true });
68
+ return;
69
+ }
70
+ await mkdir(dirname(path), { recursive: true });
71
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
72
+ try {
73
+ const manifest: RecoveryManifest = {
74
+ version: RECOVERY_MANIFEST_VERSION,
75
+ records: [...records],
76
+ };
77
+ await writeFile(temporaryPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
78
+ await rename(temporaryPath, path);
79
+ } finally {
80
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
81
+ }
82
+ }
83
+
84
+ /** Merge retained artifacts into the durable manifest. */
85
+ export async function persistRecoveryRecords(
86
+ configPath: string,
87
+ records: readonly RecoveryRecord[],
88
+ ): Promise<void> {
89
+ if (records.length === 0) return;
90
+ const path = getRecoveryManifestPath(configPath);
91
+ await withFileMutationQueue(path, async () => {
92
+ const merged = new Map<string, RecoveryRecord>();
93
+ for (const record of await readRecoveryRecords(configPath)) merged.set(recoveryKey(record), record);
94
+ for (const record of records) merged.set(recoveryKey(record), record);
95
+ await writeManifest(path, [...merged.values()]);
96
+ });
97
+ }
98
+
99
+ export function recoveryRecordFromFinalization(
100
+ runId: number,
101
+ finalization: WorktreeFinalization,
102
+ now = Date.now(),
103
+ ): RecoveryRecord {
104
+ return {
105
+ runId,
106
+ createdAt: now,
107
+ integrated: finalization.integrated,
108
+ ...(finalization.worktreePath ? { worktreePath: finalization.worktreePath } : {}),
109
+ ...(finalization.patchPath ? { patchPath: finalization.patchPath } : {}),
110
+ ...(finalization.error ? { error: finalization.error } : {}),
111
+ };
112
+ }
113
+
114
+ /** Show retained recovery paths on every later session start until the user
115
+ * removes the artifacts. Stale records are pruned automatically. */
116
+ export async function announceRecoveryRecords(
117
+ configPath: string,
118
+ ctx: {
119
+ hasUI?: boolean;
120
+ ui: { notify(message: string, kind: "info" | "warning" | "error"): void };
121
+ },
122
+ ): Promise<void> {
123
+ if (ctx.hasUI === false) return;
124
+ const records = await readRecoveryRecords(configPath);
125
+ if (records.length === 0) return;
126
+ const live = records.filter((record) =>
127
+ (record.worktreePath ? existsSync(record.worktreePath) : false) ||
128
+ (record.patchPath ? existsSync(record.patchPath) : false),
129
+ );
130
+ if (live.length !== records.length) {
131
+ const path = getRecoveryManifestPath(configPath);
132
+ await withFileMutationQueue(path, () => writeManifest(path, live)).catch(() => undefined);
133
+ }
134
+ for (const record of live) {
135
+ const paths = [
136
+ record.worktreePath ? `worktree ${stripVTControlCharacters(record.worktreePath)}` : undefined,
137
+ record.patchPath ? `patch ${stripVTControlCharacters(record.patchPath)}` : undefined,
138
+ ].filter(Boolean).join(" · ");
139
+ const reason = record.error ? ` · ${stripVTControlCharacters(record.error)}` : "";
140
+ ctx.ui.notify(
141
+ `pi-subagents recovery for run #${record.runId}: ${record.integrated ? "changes were applied but cleanup failed" : "integration failed"}${paths ? ` · retained ${paths}` : ""}${reason}`,
142
+ "error",
143
+ );
144
+ }
145
+ }
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 } from "./agents.ts";
17
+ import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
18
18
  import type { ThinkingLevel } from "./config.ts";
19
19
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
20
 
@@ -66,10 +66,13 @@ export interface RpcSingleResult {
66
66
  * model execution. This remains main-model handoff eligible even when an
67
67
  * earlier, aborted objective left assistant text in the session. */
68
68
  rpcPromptRejected?: boolean;
69
- /** Handshake or initial prompt ACK never came back. This is a startup/
70
- * transport miss, not a model/provider failure. */
69
+ /** Startup handshake failed before the initial prompt was dispatched. This
70
+ * transport miss is safe to retry and is not a model/provider failure. */
71
71
  rpcStartupFailed?: boolean;
72
- /** The child accepted a prompt; startup retries must never duplicate it. */
72
+ /** The parent dispatched the initial prompt command. Until its response is
73
+ * observed, Pi may already be running it, so startup retries must not replay it. */
74
+ rpcPromptDispatched?: boolean;
75
+ /** The child confirmed prompt acceptance (or emitted agent activity). */
73
76
  rpcPromptAccepted?: boolean;
74
77
  /** Pi emitted agent/turn/model/tool activity for this attempt. */
75
78
  rpcActivity?: boolean;
@@ -415,6 +418,13 @@ interface RpcResponse {
415
418
  data?: unknown;
416
419
  }
417
420
 
421
+ class RpcCommandRejectedError extends Error {
422
+ constructor(message: string) {
423
+ super(message);
424
+ this.name = "RpcCommandRejectedError";
425
+ }
426
+ }
427
+
418
428
  interface PendingRequest {
419
429
  resolve: (response: RpcResponse) => void;
420
430
  reject: (error: Error) => void;
@@ -459,7 +469,7 @@ export interface RunRpcAttemptOptions {
459
469
  /** Run one persistent RPC child until a stable `agent_settled` or control action. */
460
470
  export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise<RpcSingleResult> {
461
471
  const { agent, agentName, task, thinkingLevel, idleTimeoutMs, signal, onLive, control } = options;
462
- const args: string[] = ["--mode", "rpc", "--exclude-tools", "subagent,subagent_control"];
472
+ const args: string[] = ["--mode", "rpc", "--exclude-tools", SUBAGENT_TOOL_NAMES.join(",")];
463
473
  if (options.sessionDir && options.sessionId) {
464
474
  args.push("--session-dir", options.sessionDir);
465
475
  args.push(sessionExists(options.sessionDir, options.sessionId) ? "--session" : "--session-id", options.sessionId);
@@ -468,7 +478,10 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
468
478
  }
469
479
  if (agent.model) args.push("--model", agent.model);
470
480
  args.push("--thinking", thinkingLevel);
471
- if (agent.tools && agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
481
+ if (agent.tools) {
482
+ if (agent.tools.length > 0) args.push("--tools", agent.tools.join(","));
483
+ else args.push("--no-tools");
484
+ }
472
485
 
473
486
  let tmpPromptDir: string | null = null;
474
487
  let tmpPromptPath: string | null = null;
@@ -642,7 +655,9 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
642
655
  },
643
656
  );
644
657
  }).then((response) => {
645
- if (!response.success) throw new Error(response.error || `RPC ${response.command} failed.`);
658
+ if (!response.success) {
659
+ throw new RpcCommandRejectedError(response.error || `RPC ${response.command} failed.`);
660
+ }
646
661
  return response;
647
662
  });
648
663
  };
@@ -734,7 +749,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
734
749
  result.exitCode = 1;
735
750
  result.stopReason = "error";
736
751
  result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
737
- if (!isRpcCommandTimeoutError(promptError.message)) result.rpcPromptRejected = true;
752
+ if (promptError instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
738
753
  finish();
739
754
  terminate();
740
755
  if (!closed) await processClosed.promise;
@@ -1076,7 +1091,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1076
1091
  result.stopReason = "error";
1077
1092
  result.errorMessage = error.message;
1078
1093
  if (startup) result.rpcStartupFailed = true;
1079
- else result.rpcPromptRejected = true;
1094
+ else if (error instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
1080
1095
  finish();
1081
1096
  terminate();
1082
1097
  };
@@ -1091,11 +1106,15 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1091
1106
  }
1092
1107
  }
1093
1108
  if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
1109
+ // Pi starts the agent immediately after prompt preflight, before its
1110
+ // success response necessarily reaches stdout. From this point on, a
1111
+ // missing ACK is ambiguous and must never be recovered by replay.
1112
+ result.rpcPromptDispatched = true;
1094
1113
  void send({ type: "prompt", message: asPlainTextRpcPrompt(options.prompt) }).then(
1095
1114
  () => resolveInitialPrompt(true),
1096
1115
  (error) => {
1097
1116
  const promptError = error instanceof Error ? error : new Error(String(error));
1098
- failBeforePrompt(promptError, isRpcCommandTimeoutError(promptError.message));
1117
+ failBeforePrompt(promptError, false);
1099
1118
  },
1100
1119
  );
1101
1120
  }
package/src/runtime.ts CHANGED
@@ -58,15 +58,17 @@ export interface SubagentThread {
58
58
  state: ThreadState;
59
59
  control: RpcRunControl;
60
60
  queueController?: AbortController;
61
- /** Resolves only after the current generation's queue work has fully
62
- * quiesced and released its concurrency slot. Auto-fix orchestration is part
63
- * of the parent generation and replaces/extends this promise. */
61
+ /** Resolves only after the current generation's top-level child, downstream
62
+ * managed workflow, and queue work have fully quiesced and released their
63
+ * concurrency slot. */
64
64
  generationCompletion: Promise<void>;
65
65
  /** Synchronous CAS used by lifecycle controls across their async preflight. */
66
66
  lifecycleVersion: number;
67
67
  lifecycleOperation?: ThreadLifecycleOperation;
68
68
  sessionId?: string;
69
69
  sessionDir?: string;
70
+ /** Active execution time accumulated across retained resume generations. */
71
+ elapsedMs: number;
70
72
  /** Most recent generation result, retained for parked destructive-stop output. */
71
73
  lastResult?: SingleResult;
72
74
  /** A destructive stop retires context even if the active child settles later. */
@@ -81,7 +83,8 @@ export interface SubagentThread {
81
83
  fork: (objective?: string, ctx?: ExtensionContext) => Promise<SingleResult>;
82
84
  forkedFromRunId?: number;
83
85
  forkChildRunIds: number[];
84
- /** Dispatch-owned, generation-guarded worktree settlement hook. */
86
+ /** Dispatch-owned, generation-guarded worktree settlement hook. Its apply
87
+ * runs under the canonical original-repository lane. */
85
88
  finalizeIsolation: (generation: number, result?: SingleResult) => Promise<WorktreeFinalization | undefined>;
86
89
  /** Best-effort shutdown notification for retained integration artifacts. */
87
90
  notifyIsolationFailure?: (finalization: WorktreeFinalization) => void;
@@ -91,6 +94,8 @@ export interface SubagentThread {
91
94
  export interface SubagentRuntime {
92
95
  configPath: string;
93
96
  backgroundQueue: BackgroundTaskQueue;
97
+ /** Live parent tool names from ExtensionAPI, read again for each child launch. */
98
+ getActiveTools: () => string[];
94
99
  /** False after session_shutdown; guards delivery and queue work. */
95
100
  sessionActive: boolean;
96
101
  /** Deliver a batch of completion messages to the main window, waking it only
@@ -110,7 +115,7 @@ export interface SubagentRuntime {
110
115
  * next generation. Shutdown invalidates these claims and waits for cleanup. */
111
116
  preflightOperations: Set<Promise<void>>;
112
117
  /** Every session directory retained for this parent session, including
113
- * auto-fix internals that are not directly controllable. */
118
+ * managed-workflow internals that are not directly controllable. */
114
119
  sessionDirs: Set<string>;
115
120
  retainSession: (result: Pick<SingleResult, "sessionDir">) => void;
116
121
  retireThreadSession: (thread: SubagentThread) => void;
@@ -127,6 +132,7 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
127
132
  const runtime: SubagentRuntime = {
128
133
  configPath,
129
134
  backgroundQueue,
135
+ getActiveTools: () => pi.getActiveTools(),
130
136
  sessionActive: true,
131
137
  sendCompletionGroup: (items) => {
132
138
  if (!runtime.sessionActive || items.length === 0) return;
@@ -134,8 +140,8 @@ export function createRuntime(pi: ExtensionAPI, configPath: string): SubagentRun
134
140
  // Computing this at delivery (emit) time — not when the item was
135
141
  // pushed — reflects the current monitor state, since finishing runs
136
142
  // are removed from the monitor before their completion is pushed.
137
- // Auto-fix parents are flipped back to "running" while their chain
138
- // owns the logical run, so they are included without a special case.
143
+ // Managed-workflow parents remain "running" through documenter,
144
+ // reviewer, and any fix rounds, so they are included without a special case.
139
145
  const active = monitor
140
146
  .getRuns()
141
147
  .filter((run) => isRunActiveStatus(run.status))