@ferris1225/pi-subagents 4.1.7 → 4.1.9

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
@@ -18,8 +18,8 @@ import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
18
18
  // Types
19
19
  // ---------------------------------------------------------------------------
20
20
 
21
- export type RunStatus = "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed";
22
- export type ContinuationKind = "resume-retained" | "resume-appended" | "fork-retained" | "fork-appended" | "retarget";
21
+ export type RunStatus = "queued" | "running" | "interrupting" | "parked" | "done" | "failed";
22
+ export type ContinuationKind = "resume-retained" | "resume-appended";
23
23
  export type WorkflowStageStatus = "done" | "active" | "pending" | "changes" | "failed";
24
24
 
25
25
  /** Ephemeral projection of one real or currently planned managed stage. It is
@@ -31,7 +31,7 @@ export interface WorkflowStage {
31
31
  }
32
32
 
33
33
  export function isRunActiveStatus(status: RunStatus): boolean {
34
- return status === "queued" || status === "running" || status === "steering" || status === "interrupting";
34
+ return status === "queued" || status === "running" || status === "interrupting";
35
35
  }
36
36
 
37
37
  /** Durable integration projection of a worktree-isolated run: pending before
@@ -57,8 +57,6 @@ export interface RunView {
57
57
  /** Short worktree-group identity (mkdtemp suffix) shared by every run inside
58
58
  * one isolated worktree; changes when a continuation worktree is created. */
59
59
  worktreeId?: string;
60
- forkedFromRunId?: number;
61
- forkChildRunIds?: number[];
62
60
  status: RunStatus;
63
61
  usage: UsageStats;
64
62
  /** Concise current activity ("thinking", "read src/index.ts"); last writer wins. */
@@ -94,7 +92,6 @@ export interface RunChainMeta {
94
92
  parentRunId?: number;
95
93
  isolation?: IsolationMode;
96
94
  worktreeId?: string;
97
- forkedFromRunId?: number;
98
95
  continuationKind?: ContinuationKind;
99
96
  }
100
97
 
@@ -325,13 +322,10 @@ export function formatElapsed(run: RunView, now: number = Date.now()): string {
325
322
  return formatDuration(elapsedMilliseconds(run, now));
326
323
  }
327
324
 
328
- export function continuationLabel(kind: ContinuationKind | undefined, sourceRunId?: number): string | undefined {
325
+ export function continuationLabel(kind: ContinuationKind | undefined): string | undefined {
329
326
  switch (kind) {
330
327
  case "resume-retained": return "resume: current objective";
331
328
  case "resume-appended": return "resume: appended objective";
332
- case "fork-retained": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: current objective`;
333
- case "fork-appended": return `fork${sourceRunId === undefined ? "" : ` #${sourceRunId}`}: appended objective`;
334
- case "retarget": return "retarget: replacement objective";
335
329
  default: return undefined;
336
330
  }
337
331
  }
@@ -452,6 +446,26 @@ export class MonitorStore {
452
446
  return this.nextId++;
453
447
  }
454
448
 
449
+ /** Keep newly allocated ids above a restored id so reload-restored threads
450
+ * never collide with runs started in the current process. */
451
+ ensureNextIdAbove(id: number): void {
452
+ if (id >= this.nextId) this.nextId = id + 1;
453
+ }
454
+
455
+ /** Re-register a durable thread restored from a previous process. The row
456
+ * keeps its stable id and historical elapsed time. */
457
+ restoreRun(view: Pick<RunView, "id" | "agent" | "task" | "status"> & Partial<RunView>): void {
458
+ if (this.find(view.id)) return;
459
+ this.runs.push({
460
+ label: runLabel(view.task),
461
+ usage: emptyUsage(),
462
+ elapsedMs: 0,
463
+ ...view,
464
+ });
465
+ this.ensureNextIdAbove(view.id);
466
+ this.notify();
467
+ }
468
+
455
469
  addRun(agent: string, task: string, model?: string, thinking?: string, meta?: RunChainMeta): number {
456
470
  const id = this.reserveRunId();
457
471
  this.runs.push({
@@ -468,7 +482,6 @@ export class MonitorStore {
468
482
  ...(meta?.relationLabel ? { relationLabel: meta.relationLabel } : {}),
469
483
  ...(meta?.parentRunId !== undefined ? { parentRunId: meta.parentRunId } : {}),
470
484
  ...(meta?.isolation ? { isolation: meta.isolation, integrationStatus: meta.isolation === "worktree" ? "pending" : undefined, ...(meta.worktreeId ? { worktreeId: meta.worktreeId } : {}) } : {}),
471
- ...(meta?.forkedFromRunId !== undefined ? { forkedFromRunId: meta.forkedFromRunId } : {}),
472
485
  ...(meta?.continuationKind ? { continuationKind: meta.continuationKind } : {}),
473
486
  });
474
487
  this.notify();
@@ -479,8 +492,8 @@ export class MonitorStore {
479
492
  const run = this.find(id);
480
493
  if (!run) return;
481
494
  const previousStatus = run.status;
482
- const wasExecuting = previousStatus === "running" || previousStatus === "steering" || previousStatus === "interrupting";
483
- const isExecuting = status === "running" || status === "steering" || status === "interrupting";
495
+ const wasExecuting = previousStatus === "running" || previousStatus === "interrupting";
496
+ const isExecuting = status === "running" || status === "interrupting";
484
497
  const now = Date.now();
485
498
  run.status = status;
486
499
  if (isExecuting && !wasExecuting) {
@@ -582,18 +595,8 @@ export class MonitorStore {
582
595
  this.notify();
583
596
  }
584
597
 
585
- setForkRelation(sourceRunId: number, childRunId: number): void {
586
- const source = this.find(sourceRunId);
587
- if (source) {
588
- source.forkChildRunIds ??= [];
589
- if (!source.forkChildRunIds.includes(childRunId)) source.forkChildRunIds.push(childRunId);
590
- }
591
- const child = this.find(childRunId);
592
- if (child) child.forkedFromRunId = sourceRunId;
593
- this.notify();
594
- }
595
598
 
596
- /** Update the objective shown for a queued retarget or resumed generation. */
599
+ /** Update the objective shown for a resumed generation. */
597
600
  setTask(id: number, task: string): void {
598
601
  const run = this.find(id);
599
602
  if (!run) return;
@@ -706,7 +709,7 @@ export class MonitorStore {
706
709
  summarize(run: RunView): string {
707
710
  const usage = formatUsageCompact(run.usage);
708
711
  const parts = [run.managedWorkflow ? `${run.agent} workflow` : run.agent];
709
- const continuation = continuationLabel(run.continuationKind, run.forkedFromRunId);
712
+ const continuation = continuationLabel(run.continuationKind);
710
713
  if (continuation) parts.push(continuation);
711
714
  if (run.relationLabel) parts.push(run.relationLabel);
712
715
  if (!run.managedWorkflow && run.model) parts.push(run.model);
@@ -743,8 +746,6 @@ export function statusIcon(status: RunStatus, theme: Theme): string {
743
746
  switch (status) {
744
747
  case "running":
745
748
  return theme.fg("accent", "●");
746
- case "steering":
747
- return theme.fg("accent", "◆");
748
749
  case "interrupting":
749
750
  return theme.fg("warning", "◐");
750
751
  case "parked":
@@ -765,8 +766,6 @@ export function statusLabel(status: RunStatus): string {
765
766
  return "ready";
766
767
  case "running":
767
768
  return "running";
768
- case "steering":
769
- return "steering";
770
769
  case "interrupting":
771
770
  return "interrupting";
772
771
  case "parked":
package/src/prompt.ts CHANGED
@@ -13,7 +13,6 @@ function bullets(lines: readonly string[]): string {
13
13
 
14
14
  export function buildDelegationDirective(
15
15
  agents: AgentConfig[],
16
- options: { maxFixRounds?: number } = {},
17
16
  ): string {
18
17
  if (agents.length === 0) return "";
19
18
 
@@ -24,7 +23,7 @@ export function buildDelegationDirective(
24
23
  const hasDocumenter = agents.some((agent) => agent.name === "documenter");
25
24
  const hasReviewer = agents.some((agent) => agent.name === "reviewer");
26
25
  const hasMultiple = agents.length > 1;
27
- const autoFixEnabled = hasWorker && (options.maxFixRounds ?? 1) > 0;
26
+ const autoFixEnabled = hasWorker;
28
27
  const codeWriterNames = [
29
28
  ...(hasWorker ? ["worker"] : []),
30
29
  ...(hasCleaner ? ["cleaner"] : []),
@@ -62,21 +61,21 @@ export function buildDelegationDirective(
62
61
  : []),
63
62
  ...(hasCleaner
64
63
  ? [
65
- `Use \`cleaner\` only as the separate evidence-first entry for user-authorized cleanup, removal, simplification, duplicate-code consolidation, or maintenance; never substitute it for \`worker\`. It applies every safe proven in-scope cut without item-by-item approval. 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
+ `Use \`cleaner\` only for user-authorized cleanup, removal, simplification, or duplicate-code consolidation; it applies every safe proven in-scope cut without item-by-item approval. Read-only audits and code-health reviews go to ${hasReviewer ? "`reviewer`" : "the main context because `reviewer` is disabled"}. Never dispatch cleaner by PR count or as the pre-commit gate.`,
66
65
  ]
67
66
  : []),
68
67
  ...(hasDocumenter
69
68
  ? [
70
- `Use \`documenter\` directly only for explicit whole-codebase maintenance or standalone documentation/comment work; a top-level documenter delivers directly without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
69
+ `Use \`documenter\` directly only for explicit whole-codebase or standalone documentation/comment work; a top-level documenter delivers without an automatic reviewer.${codeWriterNames.length > 0 ? ` ${codeWriterNames.join("/")} must sync existing docs they directly affect; runtime runs documenter only after REVIEW_PASS with DOCUMENTATION: NEEDED or a missing marker, or as the reviewer-disabled fallback—never dispatch a duplicate.` : ""} It never changes runtime behavior, versions, or release state.`,
71
70
  ]
72
71
  : []),
73
72
  ...(hasReviewer
74
73
  ? [
75
- `Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."}`,
74
+ `Use \`reviewer\` for read-only assessments or a gate.${reviewedWriterNames.length > 0 ? ` Successful ${reviewedWriterNames.join("/")} runs already get one fresh read-only reviewer gate, independent of the writer.` : ""} Advisory output has no VERDICT and cannot authorize follow-up edits${hasDocumenter ? "; gates classify docs separately for the enabled documenter." : "."} Re-verifying your own fixes? Dispatch with \`advisory: true\`: the report returns to you and never starts the auto-fix chain.`,
76
75
  ]
77
76
  : []),
78
77
  "Brief each child with the complete goal, exact paths, constraints, and expected output; it has no conversation memory.",
79
- "Children are leaf processes without delegation tools; use `subagent_control fork` on a parked/settled thread for an independent continuation.",
78
+ "Children are leaf processes without delegation tools; use `subagent_control resume` on a parked/settled thread to continue its retained context.",
80
79
  ...(hasMultiple
81
80
  ? [
82
81
  "Dispatch independent work in one `tasks` array and let the resumed main agent start dependent work only after prerequisites finish.",
@@ -101,10 +100,10 @@ export function buildDelegationDirective(
101
100
  ? [
102
101
  ...(hasDocumenter
103
102
  ? [
104
- `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker/fix rounds are disabled."}`,
103
+ `A direct REVIEW_PASS with DOCUMENTATION: CLEAN delivers immediately; NEEDED or a missing marker runs one documentation sync. A direct REVIEW_FAIL ${autoFixEnabled ? "keeps bounded worker/reviewer auto-fix, with docs considered only after its terminal REVIEW_PASS." : "cannot start fixes while worker is disabled."}`,
105
104
  ]
106
105
  : []),
107
- "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.",
106
+ "Resolve every gate finding; do not bypass the auto-fix/re-review cap. A reviewer report without a standalone VERDICT is advisory and cannot trigger writes.",
108
107
  "Use multi-model cross-review only when explicitly requested or for genuinely high-risk security, unsafe/FFI, persistence-migration, or concurrency changes.",
109
108
  ]
110
109
  : []),
package/src/rpc-run.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /*
2
2
  * Persistent pi RPC child transport for one logical sub-agent generation.
3
3
  *
4
- * A child stays alive across prompt/steer/abort/retarget operations and speaks
4
+ * A child stays alive across prompt/abort operations and speaks
5
5
  * strict LF-delimited JSONL. The process is terminated only after the logical
6
6
  * run settles, is parked/stopped, or fails. Session files remain owned by the
7
7
  * parent runtime so a later generation can resume the same thread.
@@ -16,6 +16,7 @@ import { StringDecoder } from "node:string_decoder";
16
16
  import type { Message } from "@earendil-works/pi-ai";
17
17
  import { SUBAGENT_TOOL_NAMES, type AgentConfig } from "./agents.ts";
18
18
  import type { ThinkingLevel } from "./config.ts";
19
+ import { writeTempOwnerMarker } from "./temp-hygiene.ts";
19
20
  import type { IsolationMode, WorktreeFinalizationStatus } from "./worktree.ts";
20
21
 
21
22
  export const DEPTH_ENV_VAR = "PI_SUBAGENT_DEPTH";
@@ -82,8 +83,6 @@ export interface RpcSingleResult {
82
83
  sessionDir?: string;
83
84
  /** Original task/project cwd used for result-artifact retention buckets. */
84
85
  projectCwd?: string;
85
- /** Internal disposition: dispatch suppresses completion delivery for parks. */
86
- parked?: boolean;
87
86
  /** Stable logical run id assigned by dispatch (also present on queued results). */
88
87
  runId?: number;
89
88
  /** Filesystem isolation selected for this logical thread. */
@@ -95,15 +94,13 @@ export interface RpcSingleResult {
95
94
  /** Retained only when integration/cleanup failed; never contains patch data. */
96
95
  integrationWorktreePath?: string;
97
96
  integrationPatchPath?: string;
98
- /** Session-fork relationships between stable logical run ids. */
99
- forkedFromRunId?: number;
100
- forkChildRunIds?: number[];
101
97
  }
102
98
 
103
99
  export type SubagentLiveEvent =
104
- | { kind: "status"; status: "queued" | "running" | "steering" | "interrupting" | "parked" | "done" | "failed" }
100
+ | { kind: "status"; status: "queued" | "running" | "interrupting" | "done" | "failed" }
105
101
  | { kind: "model"; model?: string; thinking?: ThinkingLevel; fallbackFrom?: string }
106
102
  | { kind: "usage"; usage: UsageStats; model?: string }
103
+ | { kind: "session"; sessionId: string; sessionDir: string }
107
104
  | { kind: "tool_start"; toolCallId?: string; toolName: string; args: unknown }
108
105
  | { kind: "tool_end"; toolCallId?: string; toolName: string; isError: boolean }
109
106
  | { kind: "thinking" }
@@ -113,17 +110,12 @@ export type RpcControlPhase =
113
110
  | "queued"
114
111
  | "starting"
115
112
  | "running"
116
- | "steering"
117
113
  | "interrupting"
118
114
  | "retrying"
119
- | "parked"
120
115
  | "settled"
121
116
  | "stopped";
122
117
 
123
118
  interface AttemptControl {
124
- steer(instruction: string): Promise<void>;
125
- retarget(objective: string): Promise<void>;
126
- park(): Promise<void>;
127
119
  stop(reason?: string): Promise<void>;
128
120
  }
129
121
 
@@ -138,9 +130,9 @@ export class RpcRunControl {
138
130
  private attempt?: { token: number; control: AttemptControl };
139
131
  private nextToken = 1;
140
132
  private serial: Promise<void> = Promise.resolve();
141
- private parkRequested = false;
142
133
  private stopRequested = false;
143
134
  private stopMessage = "Subagent was aborted";
135
+ private childPids = new Set<number>();
144
136
 
145
137
  constructor(
146
138
  objective: string,
@@ -158,10 +150,6 @@ export class RpcRunControl {
158
150
  return this.phase;
159
151
  }
160
152
 
161
- isParkRequested(): boolean {
162
- return this.parkRequested;
163
- }
164
-
165
153
  isStopRequested(): boolean {
166
154
  return this.stopRequested;
167
155
  }
@@ -170,15 +158,15 @@ export class RpcRunControl {
170
158
  return this.stopMessage;
171
159
  }
172
160
 
173
- /** Update a not-yet-started/retrying objective without launching a process. */
174
- retargetPending(objective: string): void {
175
- this.objective = objective;
161
+ /** Pids of every child process this generation spawned. Persisted with the
162
+ * thread record so a later load can kill orphans that still hold the
163
+ * retained session. */
164
+ noteChildPid(pid: number): void {
165
+ if (Number.isInteger(pid) && pid > 0) this.childPids.add(pid);
176
166
  }
177
167
 
178
- /** Mark queued/starting work for park without waiting on an RPC abort event. */
179
- parkPending(): void {
180
- this.parkRequested = true;
181
- this.setPhase("parked");
168
+ getChildPids(): number[] {
169
+ return [...this.childPids];
182
170
  }
183
171
 
184
172
  markStarting(): void {
@@ -186,12 +174,12 @@ export class RpcRunControl {
186
174
  }
187
175
 
188
176
  markRetrying(): void {
189
- if (!this.parkRequested && !this.stopRequested) this.setPhase("retrying");
177
+ if (!this.stopRequested) this.setPhase("retrying");
190
178
  }
191
179
 
192
180
  markSettled(): void {
193
181
  this.attempt = undefined;
194
- if (!this.parkRequested && !this.stopRequested) this.setPhase("settled");
182
+ if (!this.stopRequested) this.setPhase("settled");
195
183
  }
196
184
 
197
185
  /** Allocate an attempt token used to reject state updates from old children. */
@@ -212,32 +200,6 @@ export class RpcRunControl {
212
200
  this.setPhase(phase);
213
201
  }
214
202
 
215
- async steer(instruction: string): Promise<void> {
216
- return this.serialize(async () => {
217
- const attempt = this.attempt?.control;
218
- if (!attempt) throw new Error(`Thread is ${this.phase}; steering requires a running child.`);
219
- await attempt.steer(instruction);
220
- });
221
- }
222
-
223
- async retarget(objective: string): Promise<void> {
224
- return this.serialize(async () => {
225
- this.objective = objective;
226
- const attempt = this.attempt?.control;
227
- if (!attempt) return;
228
- await attempt.retarget(objective);
229
- });
230
- }
231
-
232
- async park(): Promise<void> {
233
- return this.serialize(async () => {
234
- this.parkRequested = true;
235
- const attempt = this.attempt?.control;
236
- if (attempt) await attempt.park();
237
- this.setPhase("parked");
238
- });
239
- }
240
-
241
203
  async stop(reason = "Subagent was aborted"): Promise<void> {
242
204
  return this.serialize(async () => {
243
205
  this.stopRequested = true;
@@ -369,6 +331,7 @@ export async function writeChildRetryPolicyExtension(
369
331
  modelRef?: string,
370
332
  ): Promise<ChildRetryPolicyExtension> {
371
333
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-policy-"));
334
+ writeTempOwnerMarker(dir);
372
335
  const filePath = join(dir, "no-provider-retries.mjs");
373
336
  const slash = modelRef?.indexOf("/") ?? -1;
374
337
  const selectedProvider = slash > 0 ? modelRef!.slice(0, slash) : undefined;
@@ -398,6 +361,7 @@ export async function writeChildRetryPolicyExtension(
398
361
 
399
362
  async function writePromptToTempFile(agentName: string, prompt: string): Promise<{ dir: string; filePath: string }> {
400
363
  const dir = await mkdtemp(join(tmpdir(), "pi-subagents-"));
364
+ writeTempOwnerMarker(dir);
401
365
  const safeName = agentName.replace(/[^\w.-]+/g, "_");
402
366
  const filePath = join(dir, `prompt-${safeName}.md`);
403
367
  try {
@@ -541,11 +505,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
541
505
  let abortSettlement: Deferred<void> | undefined;
542
506
  let initialPromptResolved = false;
543
507
  const initialPrompt = deferred<{ accepted: boolean; error?: Error }>();
544
- let continuationCommandInFlight = false;
545
- let continuationAccepted = false;
546
- let continuationTurnStarted = false;
547
- let continuationTurnCompleted = false;
548
- let deferredAgentSettlement = false;
549
508
  const pendingRequests = new Map<string, PendingRequest>();
550
509
  const outcome = deferred<void>();
551
510
  const processClosed = deferred<void>();
@@ -562,13 +521,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
562
521
 
563
522
  const setAttemptPhase = (phase: RpcControlPhase): void => {
564
523
  if (attemptToken !== undefined) control?.updateAttemptPhase(attemptToken, phase);
565
- switch (phase) {
566
- case "running":
567
- case "steering":
568
- case "interrupting":
569
- case "parked":
570
- emit({ kind: "status", status: phase });
571
- break;
524
+ if (phase === "running" || phase === "interrupting") {
525
+ emit({ kind: "status", status: phase });
572
526
  }
573
527
  };
574
528
 
@@ -685,142 +639,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
685
639
  };
686
640
 
687
641
  const attemptControl: AttemptControl = {
688
- async steer(instruction: string): Promise<void> {
689
- if (finished) throw new Error("Thread already settled before it could be steered.");
690
- const acceptance = await initialPrompt.promise;
691
- if (!acceptance.accepted) throw acceptance.error ?? new Error("The initial prompt was rejected.");
692
- setAttemptPhase("steering");
693
- // Prompt+streamingBehavior performs the active→steer / idle→new-prompt
694
- // choice atomically inside Pi. Hold any old agent_settled event until this
695
- // command is accepted so an extension-handler race cannot drop the steer.
696
- continuationCommandInFlight = true;
697
- continuationAccepted = false;
698
- continuationTurnStarted = false;
699
- continuationTurnCompleted = false;
700
- deferredAgentSettlement = false;
701
- try {
702
- await send({ type: "prompt", message: asPlainTextRpcPrompt(instruction), streamingBehavior: "steer" });
703
- continuationAccepted = true;
704
- if (deferredAgentSettlement && !continuationTurnStarted) {
705
- // A handled input can succeed without starting a turn. Confirm the
706
- // server is idle before consuming the delayed settlement.
707
- const state = await send({ type: "get_state" }).catch(() => undefined);
708
- if ((state?.data as { isStreaming?: unknown } | undefined)?.isStreaming === false) {
709
- continuationAccepted = false;
710
- deferredAgentSettlement = false;
711
- settleRun();
712
- }
713
- }
714
- } catch (error) {
715
- continuationAccepted = false;
716
- if (deferredAgentSettlement) {
717
- deferredAgentSettlement = false;
718
- settleRun();
719
- }
720
- throw error;
721
- } finally {
722
- continuationCommandInFlight = false;
723
- }
724
- // Remain visibly steering until the next turn starts.
725
- },
726
- async retarget(objective: string): Promise<void> {
727
- if (finished) throw new Error("Thread already settled before it could be retargeted.");
728
- setAttemptPhase("interrupting");
729
- result.task = objective;
730
- const accepted = await abortAcceptedPrompt();
731
- if (!accepted) {
732
- if (!closed) await processClosed.promise;
733
- return;
734
- }
735
- if (finished || closed) throw new Error("Thread exited while retargeting.");
736
- // The aborted assistant message remains in the retained session/history,
737
- // but it must not classify the replacement objective as aborted.
738
- result.stopReason = undefined;
739
- result.errorMessage = undefined;
740
- result.exitCode = 0;
741
- // Tool failures belong to the abandoned objective. Keep them in session
742
- // history, but do not classify a successful replacement as failed.
743
- result.failedTools = undefined;
744
- try {
745
- await send({ type: "prompt", message: asPlainTextRpcPrompt(objective) });
746
- setAttemptPhase("running");
747
- } catch (error) {
748
- const promptError = error instanceof Error ? error : new Error(String(error));
749
- result.exitCode = 1;
750
- result.stopReason = "error";
751
- result.errorMessage = `Replacement prompt was rejected: ${promptError.message}`;
752
- if (promptError instanceof RpcCommandRejectedError) result.rpcPromptRejected = true;
753
- finish();
754
- terminate();
755
- if (!closed) await processClosed.promise;
756
- throw promptError;
757
- }
758
- },
759
- async park(): Promise<void> {
760
- const markParked = (): void => {
761
- result.parked = true;
762
- result.exitCode = 0;
763
- result.stopReason = undefined;
764
- result.errorMessage = undefined;
765
- result.rpcStartupFailed = undefined;
766
- result.rpcPromptRejected = undefined;
767
- };
768
- if (finished) {
769
- if (!closed) await processClosed.promise;
770
- if (result.parked) return;
771
- // Handshake/startup already tore the child down. Convert a pre-prompt
772
- // settlement into a park instead of throwing past the control tool.
773
- if (!result.rpcPromptAccepted) {
774
- markParked();
775
- return;
776
- }
777
- throw new Error("Thread already settled before it could be parked.");
778
- }
779
- setAttemptPhase("interrupting");
780
- if (!initialPromptResolved) {
781
- const parked = new Error("Run was parked before its initial prompt.");
782
- resolveInitialPrompt(false, parked);
783
- rejectPending(parked);
784
- markParked();
785
- setAttemptPhase("parked");
786
- finish();
787
- terminate();
788
- if (!closed) await processClosed.promise;
789
- return;
790
- }
791
- // Bound the abort settlement exactly like stop: a child that never
792
- // settles after abort must not hold the control operation forever.
793
- let parkTimer: ReturnType<typeof setTimeout> | undefined;
794
- let parkTimedOut = false;
795
- const parkDeadline = new Promise<boolean>((resolve) => {
796
- parkTimer = setTimeout(() => {
797
- parkTimedOut = true;
798
- resolve(false);
799
- }, RPC_ABORT_SETTLE_TIMEOUT_MS);
800
- if (typeof parkTimer.unref === "function") parkTimer.unref();
801
- });
802
- let accepted: boolean;
803
- try {
804
- accepted = await Promise.race([abortAcceptedPrompt(), parkDeadline]);
805
- } catch {
806
- /* a rejected abort still parks; termination below is the bounded fallback */
807
- accepted = false;
808
- } finally {
809
- if (parkTimer) clearTimeout(parkTimer);
810
- }
811
- if (abortSettlement) {
812
- const stable = abortSettlement;
813
- abortSettlement = undefined;
814
- stable.resolve();
815
- }
816
- if (!accepted && !parkTimedOut && !closed) await processClosed.promise;
817
- if (finished && accepted) throw new Error("Thread exited while parking.");
818
- markParked();
819
- setAttemptPhase("parked");
820
- finish();
821
- terminate();
822
- if (!closed) await processClosed.promise;
823
- },
824
642
  async stop(reason = "Subagent was aborted"): Promise<void> {
825
643
  if (finished) {
826
644
  if (!closed) await processClosed.promise;
@@ -862,6 +680,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
862
680
  };
863
681
 
864
682
  if (attemptToken !== undefined) control?.attach(attemptToken, attemptControl);
683
+ if (proc.pid !== undefined) control?.noteChildPid(proc.pid);
865
684
  control?.markStarting();
866
685
 
867
686
  const processLine = (rawLine: string): void => {
@@ -929,15 +748,8 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
929
748
  emit({ kind: "status", status: "running" });
930
749
  }
931
750
  if (event.type === "turn_start") {
932
- if (continuationCommandInFlight || continuationAccepted) {
933
- continuationTurnStarted = true;
934
- }
935
751
  setAttemptPhase("running");
936
752
  }
937
- if (event.type === "turn_end" && continuationTurnStarted) {
938
- continuationTurnCompleted = true;
939
- deferredAgentSettlement = false;
940
- }
941
753
 
942
754
  if (event.type === "message_update") {
943
755
  const type = event.assistantMessageEvent?.type;
@@ -998,18 +810,6 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
998
810
  stable.resolve();
999
811
  return;
1000
812
  }
1001
- if ((continuationCommandInFlight || continuationAccepted) && !continuationTurnCompleted) {
1002
- // Pi may emit an old settlement while an extension handler is yielding
1003
- // and the atomic prompt command starts the continuation. Its successful
1004
- // response guarantees a new/queued turn, so defer this stale event until
1005
- // that continuation has completed a turn.
1006
- deferredAgentSettlement = true;
1007
- return;
1008
- }
1009
- continuationAccepted = false;
1010
- continuationTurnStarted = false;
1011
- continuationTurnCompleted = false;
1012
- deferredAgentSettlement = false;
1013
813
  settleRun();
1014
814
  }
1015
815
  };
@@ -1098,13 +898,7 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1098
898
  }
1099
899
 
1100
900
  try {
1101
- if (control?.isParkRequested()) {
1102
- resolveInitialPrompt(false, new Error("Run was parked before its initial prompt."));
1103
- result.parked = true;
1104
- result.exitCode = 0;
1105
- finish();
1106
- terminate();
1107
- } else if (control?.isStopRequested()) {
901
+ if (control?.isStopRequested()) {
1108
902
  resolveInitialPrompt(false, new Error("Run was stopped before its initial prompt."));
1109
903
  await attemptControl.stop();
1110
904
  } else {
@@ -1123,13 +917,13 @@ export async function runRpcAgentAttempt(options: RunRpcAttemptOptions): Promise
1123
917
  await send({ type: "get_state" }, readyTimeoutMs);
1124
918
  } catch (error) {
1125
919
  const handshakeError = error instanceof Error ? error : new Error(String(error));
1126
- if (!control?.isParkRequested() && !control?.isStopRequested()) {
920
+ if (!control?.isStopRequested()) {
1127
921
  failBeforePrompt(handshakeError, true);
1128
922
  } else {
1129
923
  resolveInitialPrompt(false, handshakeError);
1130
924
  }
1131
925
  }
1132
- if (!finished && !initialPromptResolved && !control?.isParkRequested() && !control?.isStopRequested()) {
926
+ if (!finished && !initialPromptResolved && !control?.isStopRequested()) {
1133
927
  // Pi starts the agent immediately after prompt preflight, before its
1134
928
  // success response necessarily reaches stdout. From this point on, a
1135
929
  // missing ACK is ambiguous and must never be recovered by replay.