@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/README.md +94 -65
- package/agents/cleaner.md +13 -14
- package/agents/documenter.md +10 -17
- package/agents/explorer.md +6 -16
- package/agents/reviewer.md +28 -29
- package/agents/worker.md +14 -33
- package/package.json +1 -1
- package/src/announcements.ts +30 -67
- package/src/background.ts +25 -12
- package/src/config.ts +9 -170
- package/src/dispatch.ts +721 -747
- package/src/durable.ts +336 -0
- package/src/fixloop.ts +37 -37
- package/src/format.ts +1 -8
- package/src/index.ts +8 -1
- package/src/models.ts +16 -0
- package/src/monitor.ts +28 -29
- package/src/prompt.ts +7 -8
- package/src/rpc-run.ts +22 -228
- package/src/runtime.ts +72 -50
- package/src/session-fork.ts +7 -2
- package/src/setup.ts +0 -41
- package/src/spawn.ts +32 -29
- package/src/temp-hygiene.ts +194 -0
- package/src/thread-lifecycle.ts +1410 -1327
- package/src/tools.ts +21 -108
- package/src/widget.ts +3 -3
- package/src/worktree.ts +144 -4
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" | "
|
|
22
|
-
export type ContinuationKind = "resume-retained" | "resume-appended"
|
|
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 === "
|
|
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
|
|
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 === "
|
|
483
|
-
const isExecuting = status === "running" || status === "
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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/
|
|
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" | "
|
|
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
|
-
/**
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
179
|
-
|
|
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.
|
|
177
|
+
if (!this.stopRequested) this.setPhase("retrying");
|
|
190
178
|
}
|
|
191
179
|
|
|
192
180
|
markSettled(): void {
|
|
193
181
|
this.attempt = undefined;
|
|
194
|
-
if (!this.
|
|
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
|
-
|
|
566
|
-
|
|
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?.
|
|
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?.
|
|
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?.
|
|
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.
|