pi-subagents 0.64.0 → 0.65.1
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/CHANGELOG.md +65 -0
- package/README.md +2 -2
- package/agents/reviewer.md +1 -1
- package/agents/scout.md +1 -1
- package/docs/agents.md +19 -17
- package/docs/configuration.md +23 -15
- package/docs/extension-api.md +18 -6
- package/docs/missions.md +2 -0
- package/docs/observability.md +10 -12
- package/docs/tool-reference.md +15 -8
- package/docs/watchdog.md +1 -1
- package/docs/workflows.md +13 -3
- package/package.json +5 -4
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/constraints-and-recipes.md +1 -1
- package/skills/pi-subagents/references/execution-controls.md +7 -3
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/agents/agent-management.ts +47 -15
- package/src/api/capability-ceiling.ts +0 -1
- package/src/api/{pi-args.ts → child-tool-plan.ts} +1 -1
- package/src/api/preflight.ts +7 -4
- package/src/extension/config.ts +4 -2
- package/src/extension/doctor.ts +2 -10
- package/src/extension/fanout-child.ts +9 -11
- package/src/extension/index.ts +58 -7
- package/src/extension/public-execution.ts +14 -0
- package/src/extension/rpc.ts +3 -2
- package/src/extension/schemas.ts +3 -2
- package/src/extension/tool-description.ts +13 -8
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +159 -95
- package/src/intercom/supervisor-ui.ts +244 -0
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/async-execution.ts +51 -25
- package/src/runs/background/async-job-tracker.ts +11 -0
- package/src/runs/background/async-resume.ts +14 -3
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/control-channel.ts +2 -204
- package/src/runs/background/notify.ts +2 -0
- package/src/runs/background/process-terminal.ts +1 -1
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +614 -0
- package/src/runs/background/run-status.ts +0 -1
- package/src/runs/background/runner-aliases.ts +152 -0
- package/src/runs/background/runner-child-sessions.ts +31 -0
- package/src/runs/background/scheduled-runs.ts +18 -4
- package/src/runs/background/subagent-runner.ts +203 -901
- package/src/runs/foreground/async-steering-action.ts +1 -17
- package/src/runs/foreground/execution.ts +204 -378
- package/src/runs/foreground/foreground-control.ts +4 -0
- package/src/runs/foreground/foreground-history.ts +3 -1
- package/src/runs/foreground/prompt-audit.ts +9 -5
- package/src/runs/foreground/subagent-executor.ts +161 -91
- package/src/runs/foreground/workflow-foreground-steering.ts +24 -98
- package/src/runs/shared/abort-recovery.ts +3 -3
- package/src/runs/shared/acceptance.ts +14 -1
- package/src/runs/shared/capability-ceiling.ts +1 -2
- package/src/runs/shared/child-hooks.ts +25 -0
- package/src/runs/shared/child-identity.ts +13 -2
- package/src/runs/shared/child-launch.ts +314 -0
- package/src/runs/shared/child-lifecycle.ts +25 -0
- package/src/runs/shared/child-runtime-config.ts +126 -0
- package/src/runs/shared/child-session.ts +337 -0
- package/src/runs/shared/child-tool-plan.ts +530 -0
- package/src/runs/shared/claude-code-adapter.ts +5 -1
- package/src/runs/shared/completion-guard.ts +1 -1
- package/src/runs/shared/external-cli-preflight.ts +16 -0
- package/src/runs/shared/llm-intent-arbiter.ts +20 -11
- package/src/runs/shared/mcp-direct-tool-allowlist.ts +5 -4
- package/src/runs/shared/model-exclusions.ts +84 -15
- package/src/runs/shared/model-fallback.ts +78 -6
- package/src/runs/shared/nested-events.ts +32 -48
- package/src/runs/shared/nested-path.ts +0 -14
- package/src/runs/shared/orca-progress-tabs.ts +12 -7
- package/src/runs/shared/parallel-utils.ts +1 -2
- package/src/runs/shared/permissions.ts +0 -13
- package/src/runs/shared/process-signal.ts +4 -1
- package/src/runs/shared/run-fanout-budget.ts +0 -13
- package/src/runs/shared/runtime-acknowledged-extensions.ts +0 -27
- package/src/runs/shared/structured-output.ts +17 -4
- package/src/runs/shared/subagent-control.ts +2 -0
- package/src/runs/shared/subagent-prompt-runtime.ts +87 -384
- package/src/runs/shared/tool-availability.ts +18 -62
- package/src/runs/shared/tool-budget.ts +0 -14
- package/src/runs/shared/worktree-cleanup-plan.ts +31 -9
- package/src/runs/shared/worktree.ts +192 -40
- package/src/shared/child-session-name.ts +1 -1
- package/src/shared/jsonl-writer.ts +11 -0
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/thinking-ceiling.ts +0 -6
- package/src/shared/types.ts +63 -28
- package/src/shared/utils.ts +6 -4
- package/src/shared/watch-strategy.ts +2 -0
- package/src/slash/slash-commands.ts +0 -6
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +0 -1
- package/src/tui/render.ts +254 -41
- package/src/watchdog/child-status.ts +0 -1
- package/src/watchdog/register-child.ts +12 -12
- package/src/workflows/scripted-workflow.ts +80 -7
- package/src/workflows/workflow-checklist.ts +4 -3
- package/src/runs/shared/child-protocol.ts +0 -415
- package/src/runs/shared/pi-args.ts +0 -1059
- package/src/runs/shared/subagent-startup-retry.ts +0 -116
- package/src/shared/post-exit-stdio-guard.ts +0 -85
|
@@ -39,6 +39,8 @@ interface AsyncJobTrackerOptions {
|
|
|
39
39
|
watch?: typeof fs.watch;
|
|
40
40
|
kill?: (pid: number, signal?: NodeJS.Signals | 0) => boolean;
|
|
41
41
|
now?: () => number;
|
|
42
|
+
/** Resolve native supervisor requests without scanning supervisor mailboxes. */
|
|
43
|
+
supervisorRequestState?: (event: ControlEvent) => "pending" | "resolved" | "unknown";
|
|
42
44
|
}
|
|
43
45
|
|
|
44
46
|
const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
|
|
@@ -293,6 +295,15 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
|
|
|
293
295
|
if ((parsed as { type?: unknown }).type !== "subagent.control") return;
|
|
294
296
|
const record = parsed as { event?: ControlEvent; channels?: string[]; childIntercomTarget?: string; noticeText?: string; intercom?: { to?: string; message?: string } };
|
|
295
297
|
if (!record.event || !Array.isArray(record.channels)) return;
|
|
298
|
+
if (record.event.type === "needs_attention" && record.event.reason === "supervisor_request" && options.supervisorRequestState) {
|
|
299
|
+
let requestState: "pending" | "resolved" | "unknown" = "unknown";
|
|
300
|
+
try {
|
|
301
|
+
requestState = options.supervisorRequestState(record.event);
|
|
302
|
+
} catch (error) {
|
|
303
|
+
console.error(`Failed to resolve supervisor request state for async control event in '${job.asyncDir}':`, error);
|
|
304
|
+
}
|
|
305
|
+
if (requestState === "resolved") return;
|
|
306
|
+
}
|
|
296
307
|
const payload = {
|
|
297
308
|
event: record.event,
|
|
298
309
|
source: "async" as const,
|
|
@@ -12,8 +12,10 @@ import { reconcileAsyncRun } from "./stale-run-reconciler.ts";
|
|
|
12
12
|
import { resultFilePath, resultPayloadPathForIndexedRun } from "./result-files.ts";
|
|
13
13
|
import { canScanAsyncRunPrefix, MIN_SAFE_ASYNC_RUN_PREFIX_LENGTH } from "./run-id-query.ts";
|
|
14
14
|
import { parallelHandoffPath, resolveRetainedWorktreeCwd } from "../shared/parallel-handoff.ts";
|
|
15
|
+
import { normalizeWorktreeBaseRef } from "../shared/worktree.ts";
|
|
15
16
|
import { intersectThinkingCeilings, parseThinkingLevel, type ThinkingLevel } from "../../shared/thinking-ceiling.ts";
|
|
16
17
|
import { assertWorkflowGraphHostSteps } from "../shared/host-step-status.ts";
|
|
18
|
+
import { validateModelResponseAliases } from "../../shared/model-response-aliases.ts";
|
|
17
19
|
|
|
18
20
|
export interface AsyncResumeParams {
|
|
19
21
|
id?: string;
|
|
@@ -316,11 +318,11 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
316
318
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': expected an object.`);
|
|
317
319
|
const parsed = value as Record<string, unknown>;
|
|
318
320
|
const allowedFields = new Set([
|
|
319
|
-
"version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
321
|
+
"modelResponseAliases", "version", "launchContractDigest", "sourceRunId", "agentContract", "agent", "sessionFile", "cwd", "model", "modelProvider", "modelOverrideFromParent", "modelOrigin", "fallbackModels", "thinking", "thinkingCeiling", "tools", "allowNestedSubagents", "extensions",
|
|
320
322
|
"subagentOnlyExtensions", "mcpDirectTools", "excludeTools", "mutationTools", "systemPrompt", "systemPromptMode", "inheritProjectContext", "inheritGlobalContext", "inheritSkills", "skills",
|
|
321
323
|
"skillPath", "agentFilePath", "completionGuard", "memory", "outputPath", "outputMode", "structuredOutputSchema", "acceptance", "sessionDir", "artifactConfig",
|
|
322
324
|
"artifactsDir", "maxOutput", "controlConfig", "context", "intercomBridge", "absoluteDeadlineAt", "initialTurnBudget", "initialToolBudget", "maxSubagentDepth", "share", "capabilityCeiling",
|
|
323
|
-
"launchResolvedExtensions", "runFanoutBudget", "lane",
|
|
325
|
+
"launchResolvedExtensions", "runFanoutBudget", "lane", "baseRef",
|
|
324
326
|
"extensionBindings",
|
|
325
327
|
]);
|
|
326
328
|
for (const field of Object.keys(parsed)) {
|
|
@@ -336,6 +338,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
336
338
|
} catch (error) {
|
|
337
339
|
throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
338
340
|
}
|
|
341
|
+
validateModelResponseAliases(parsed.modelResponseAliases, `async recovery descriptor '${descriptorPath}' modelResponseAliases`);
|
|
339
342
|
if (parsed.capabilityCeiling !== undefined) parsed.capabilityCeiling = parseSubagentCapabilityCeiling(parsed.capabilityCeiling, `async recovery descriptor '${descriptorPath}' capabilityCeiling`);
|
|
340
343
|
if (parsed.thinkingCeiling !== undefined) parsed.thinkingCeiling = parseThinkingLevel(parsed.thinkingCeiling, `async recovery descriptor '${descriptorPath}' thinkingCeiling`);
|
|
341
344
|
if (parsed.extensionBindings !== undefined) parsed.extensionBindings = normalizeExtensionBindings(parsed.extensionBindings)!.value;
|
|
@@ -366,6 +369,14 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
|
|
|
366
369
|
for (const field of ["launchContractDigest", "sessionFile", "model", "modelProvider", "thinking", "agentFilePath", "outputPath", "sessionDir", "artifactsDir"] as const) {
|
|
367
370
|
if (parsed[field] !== undefined && (typeof parsed[field] !== "string" || !(parsed[field] as string).trim())) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${field} must be a non-empty string.`);
|
|
368
371
|
}
|
|
372
|
+
if (parsed.baseRef !== undefined) {
|
|
373
|
+
if (typeof parsed.baseRef !== "string") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': baseRef must be a string.`);
|
|
374
|
+
try {
|
|
375
|
+
parsed.baseRef = normalizeWorktreeBaseRef(parsed.baseRef);
|
|
376
|
+
} catch (error) {
|
|
377
|
+
throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
369
380
|
if (parsed.completionGuard !== undefined && typeof parsed.completionGuard !== "boolean") throw new Error(`Invalid async recovery descriptor '${descriptorPath}': completionGuard must be a boolean.`);
|
|
370
381
|
if (parsed.structuredOutputSchema !== undefined && (!parsed.structuredOutputSchema || typeof parsed.structuredOutputSchema !== "object" || Array.isArray(parsed.structuredOutputSchema))) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': structuredOutputSchema must be an object.`);
|
|
371
382
|
if (parsed.memory !== undefined) {
|
|
@@ -623,7 +634,7 @@ export function buildRevivedAsyncTask(target: AsyncResumeTarget, message: string
|
|
|
623
634
|
`Original agent: ${target.agent}`,
|
|
624
635
|
target.sessionFile ? `Original session file: ${target.sessionFile}` : undefined,
|
|
625
636
|
"",
|
|
626
|
-
"Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child
|
|
637
|
+
"Use the stored session context as background. Answer the orchestrator's follow-up below. Do not assume the original child session is still running.",
|
|
627
638
|
"",
|
|
628
639
|
"Follow-up:",
|
|
629
640
|
message,
|
|
@@ -320,7 +320,16 @@ function readCursor(root: string): RetentionCursor {
|
|
|
320
320
|
return value?.version === 1 ? value as unknown as RetentionCursor : { version: 1 };
|
|
321
321
|
}
|
|
322
322
|
|
|
323
|
+
let currentProcessStartIdentity: string | undefined | null = null;
|
|
324
|
+
|
|
323
325
|
function processStartIdentity(pid: number): string | undefined {
|
|
326
|
+
// Foreign PIDs can be reused while this process lives, so resolve them afresh.
|
|
327
|
+
if (pid !== process.pid) return computeProcessStartIdentity(pid);
|
|
328
|
+
if (currentProcessStartIdentity === null) currentProcessStartIdentity = computeProcessStartIdentity(pid);
|
|
329
|
+
return currentProcessStartIdentity;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function computeProcessStartIdentity(pid: number): string | undefined {
|
|
324
333
|
if (process.platform === "linux") {
|
|
325
334
|
try {
|
|
326
335
|
const stat = fs.readFileSync(`/proc/${pid}/stat`).toString("utf8");
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Cross-OS control channel for async subagent runs.
|
|
3
3
|
*
|
|
4
|
-
* Background runs use
|
|
4
|
+
* Background runs use a detached runner process. Unix detaches it from the parent process.
|
|
5
5
|
* The original control path delivered an interrupt with
|
|
6
6
|
* `process.kill(pid, SIGUSR2|SIGBREAK)`, but Windows cannot
|
|
7
7
|
* deliver those signals cross-process via `process.kill` and throws `ENOSYS`,
|
|
@@ -74,33 +74,10 @@ export interface SteerRequest {
|
|
|
74
74
|
source?: string;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
export interface SteerCapability {
|
|
78
|
-
type: "steer-capability";
|
|
79
|
-
protocolVersion: 1;
|
|
80
|
-
index: number;
|
|
81
|
-
pid: number;
|
|
82
|
-
readyAt: number;
|
|
83
|
-
supported: boolean;
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface SteerAck {
|
|
87
|
-
type: "steer-ack";
|
|
88
|
-
protocolVersion: 1;
|
|
89
|
-
requestId: string;
|
|
90
|
-
index: number;
|
|
91
|
-
ts: number;
|
|
92
|
-
state: "delivered" | "queued" | "failed";
|
|
93
|
-
deliveryStatus?: SteerDeliveryStatus;
|
|
94
|
-
message: string;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
77
|
const STEER_REQUESTS_DIR = "steer-requests";
|
|
98
78
|
const STOP_REQUESTS_DIR = "stop-requests";
|
|
99
79
|
const REVIVAL_BRIEFS_DIR = "revival-briefs";
|
|
100
80
|
export const MAX_STEER_QUEUE_SIZE = 20;
|
|
101
|
-
const STEER_TARGETS_DIR = "steer-targets";
|
|
102
|
-
const STEER_CAPABILITIES_DIR = "steer-capabilities";
|
|
103
|
-
const STEER_ACKS_DIR = "steer-acks";
|
|
104
81
|
const STEER_INBOX_CLOSED_FILE = "steer-inbox-closed.json";
|
|
105
82
|
const MAX_STEER_MESSAGE_BYTES = 128 * 1024;
|
|
106
83
|
const MAX_STEER_REQUEST_ID_LENGTH = 256;
|
|
@@ -143,35 +120,6 @@ export function closeSteerInbox(asyncDir: string, state: string, write: (filePat
|
|
|
143
120
|
write(steerInboxClosedPath(asyncDir), { version: 1, closedAt: Date.now(), state });
|
|
144
121
|
}
|
|
145
122
|
|
|
146
|
-
/** Per-child inbox consumed by the child prompt runtime inside the Pi process. */
|
|
147
|
-
export function stepSteerInboxDir(asyncDir: string, index: number): string {
|
|
148
|
-
assertChildIndex(index);
|
|
149
|
-
return path.join(controlInboxDir(asyncDir), STEER_TARGETS_DIR, String(index));
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
export function steerCapabilitiesDir(asyncDir: string): string {
|
|
153
|
-
return path.join(controlInboxDir(asyncDir), STEER_CAPABILITIES_DIR);
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
export function steerCapabilityPath(asyncDir: string, index: number): string {
|
|
157
|
-
assertChildIndex(index);
|
|
158
|
-
return path.join(steerCapabilitiesDir(asyncDir), `${index}.json`);
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
export function steerAcksDir(asyncDir: string, index: number): string {
|
|
162
|
-
assertChildIndex(index);
|
|
163
|
-
return path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR, String(index));
|
|
164
|
-
}
|
|
165
|
-
|
|
166
|
-
function steerAckFileName(requestId: string): string {
|
|
167
|
-
return `${Buffer.from(requestId).toString("base64url")}.json`;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
export function steerAckPathFromDir(dir: string, requestId: string): string {
|
|
171
|
-
if (!/^[^\s]+$/.test(requestId) || requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
|
|
172
|
-
return path.join(dir, steerAckFileName(requestId));
|
|
173
|
-
}
|
|
174
|
-
|
|
175
123
|
function assertChildIndex(index: number): void {
|
|
176
124
|
if (!Number.isInteger(index) || index < 0 || index > 1_000_000) throw new Error("child index must be a non-negative integer.");
|
|
177
125
|
}
|
|
@@ -229,45 +177,6 @@ export function writeSteerRequestToExistingDir(dir: string, request: SteerReques
|
|
|
229
177
|
return requestPath;
|
|
230
178
|
}
|
|
231
179
|
|
|
232
|
-
export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
|
|
233
|
-
assertChildIndex(capability.index);
|
|
234
|
-
if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
|
|
235
|
-
if (!Number.isFinite(capability.readyAt) || capability.readyAt <= 0) throw new Error("steer capability readyAt must be a finite timestamp.");
|
|
236
|
-
const record: SteerCapability = { type: "steer-capability", protocolVersion: 1, ...capability };
|
|
237
|
-
writeAtomicJson(filePath, record);
|
|
238
|
-
return filePath;
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
export function writeSteerCapability(asyncDir: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
|
|
242
|
-
return writeSteerCapabilityAt(steerCapabilityPath(asyncDir, capability.index), capability);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function steerAckWritePath(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
|
|
246
|
-
const parsed = path.parse(filePath);
|
|
247
|
-
const stateOrder = ack.state === "queued" ? "0" : ack.state === "delivered" ? "1" : "2";
|
|
248
|
-
const timestamp = String(Math.trunc(ack.ts)).padStart(13, "0");
|
|
249
|
-
for (let suffix = 0; suffix < 1_000; suffix += 1) {
|
|
250
|
-
const candidate = path.join(parsed.dir, `${parsed.name}-${timestamp}-${stateOrder}-${ack.state}${suffix === 0 ? "" : `-${suffix}`}${parsed.ext}`);
|
|
251
|
-
if (!fs.existsSync(candidate)) return candidate;
|
|
252
|
-
}
|
|
253
|
-
throw new Error("steer acknowledgment queue is full.");
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
export function writeSteerAckAt(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
|
|
257
|
-
assertChildIndex(ack.index);
|
|
258
|
-
if (!/^[^\s]+$/.test(ack.requestId) || ack.requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
|
|
259
|
-
if (!Number.isFinite(ack.ts) || ack.ts <= 0) throw new Error("steer acknowledgment ts must be a finite timestamp.");
|
|
260
|
-
if (!ack.message.trim() || ack.message.length > 1000) throw new Error("steer acknowledgment message is invalid.");
|
|
261
|
-
const record: SteerAck = { type: "steer-ack", protocolVersion: 1, ...ack, message: ack.message.trim() };
|
|
262
|
-
const ackPath = steerAckWritePath(filePath, ack);
|
|
263
|
-
writeAtomicJson(ackPath, record);
|
|
264
|
-
return ackPath;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
export function writeSteerAck(asyncDir: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
|
|
268
|
-
return writeSteerAckAt(path.join(steerAcksDir(asyncDir, ack.index), steerAckFileName(ack.requestId)), ack);
|
|
269
|
-
}
|
|
270
|
-
|
|
271
180
|
/**
|
|
272
181
|
* Parent side: drop a portable interrupt request the runner's inbox watcher will
|
|
273
182
|
* pick up regardless of OS. Written atomically (temp + rename), dir auto-created.
|
|
@@ -350,97 +259,6 @@ export function requestAsyncSteer(
|
|
|
350
259
|
return requestPath;
|
|
351
260
|
}
|
|
352
261
|
|
|
353
|
-
export function enqueueStepSteer(asyncDir: string, index: number, request: SteerRequest): string {
|
|
354
|
-
assertChildIndex(index);
|
|
355
|
-
const { targetIndexes: _targetIndexes, ...singleTargetRequest } = request;
|
|
356
|
-
return writeSteerRequestToDir(stepSteerInboxDir(asyncDir, index), { ...singleTargetRequest, targetIndex: index, type: "steer" });
|
|
357
|
-
}
|
|
358
|
-
|
|
359
|
-
function parseSteerCapability(raw: unknown): SteerCapability | undefined {
|
|
360
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
361
|
-
const input = raw as Partial<SteerCapability>;
|
|
362
|
-
if (input.type !== "steer-capability" || input.protocolVersion !== 1) return undefined;
|
|
363
|
-
const { index, pid, readyAt, supported } = input;
|
|
364
|
-
if (typeof index !== "number" || !Number.isInteger(index) || index < 0 || index > 1_000_000) return undefined;
|
|
365
|
-
if (typeof pid !== "number" || typeof readyAt !== "number" || !Number.isInteger(pid) || pid <= 0 || !Number.isFinite(readyAt) || readyAt <= 0 || typeof supported !== "boolean") return undefined;
|
|
366
|
-
return { type: "steer-capability", protocolVersion: 1, index, pid, readyAt, supported };
|
|
367
|
-
}
|
|
368
|
-
|
|
369
|
-
function parseSteerAck(raw: unknown): SteerAck | undefined {
|
|
370
|
-
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
371
|
-
const input = raw as Partial<SteerAck>;
|
|
372
|
-
if (input.type !== "steer-ack" || input.protocolVersion !== 1 || typeof input.requestId !== "string" || !/^[^\s]+$/.test(input.requestId) || input.requestId.length > 256) return undefined;
|
|
373
|
-
const { index, ts, state, message } = input;
|
|
374
|
-
if (typeof index !== "number" || typeof ts !== "number" || !Number.isInteger(index) || index < 0 || index > 1_000_000 || !Number.isFinite(ts) || ts <= 0) return undefined;
|
|
375
|
-
if (state !== "delivered" && state !== "queued" && state !== "failed") return undefined;
|
|
376
|
-
if (input.deliveryStatus !== undefined && input.deliveryStatus !== "delivered" && input.deliveryStatus !== "queued") return undefined;
|
|
377
|
-
if (typeof message !== "string" || !message.trim() || message.length > 1000) return undefined;
|
|
378
|
-
return { type: "steer-ack", protocolVersion: 1, requestId: input.requestId, index, ts, state, ...(input.deliveryStatus ? { deliveryStatus: input.deliveryStatus } : {}), message: message.trim() };
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
export function readSteerCapability(asyncDir: string, index: number): SteerCapability | undefined {
|
|
382
|
-
try {
|
|
383
|
-
return parseSteerCapability(JSON.parse(fs.readFileSync(steerCapabilityPath(asyncDir, index), "utf-8")));
|
|
384
|
-
} catch {
|
|
385
|
-
return undefined;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync"> = fs): SteerCapability[] {
|
|
390
|
-
const dir = steerCapabilitiesDir(asyncDir);
|
|
391
|
-
if (!fsImpl.existsSync(dir)) return [];
|
|
392
|
-
const capabilities: SteerCapability[] = [];
|
|
393
|
-
for (const entry of fsImpl.readdirSync(dir).filter((name) => /^\d+\.json$/.test(name)).sort()) {
|
|
394
|
-
try {
|
|
395
|
-
const capability = parseSteerCapability(JSON.parse(fsImpl.readFileSync(path.join(dir, entry), "utf-8")));
|
|
396
|
-
if (capability) capabilities.push(capability);
|
|
397
|
-
} catch {
|
|
398
|
-
// A partially written or malformed capability is ignored until a valid one arrives.
|
|
399
|
-
}
|
|
400
|
-
}
|
|
401
|
-
return capabilities;
|
|
402
|
-
}
|
|
403
|
-
|
|
404
|
-
export function consumeSteerAckFromDir(
|
|
405
|
-
dir: string,
|
|
406
|
-
requestId: string,
|
|
407
|
-
fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
|
|
408
|
-
): SteerAck | undefined {
|
|
409
|
-
if (!fsImpl.existsSync(dir)) return undefined;
|
|
410
|
-
let entries: string[];
|
|
411
|
-
try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
|
|
412
|
-
for (const entry of entries) {
|
|
413
|
-
const target = path.join(dir, entry);
|
|
414
|
-
let ack: SteerAck | undefined;
|
|
415
|
-
try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
|
|
416
|
-
if (ack?.requestId !== requestId) continue;
|
|
417
|
-
try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
|
|
418
|
-
return ack;
|
|
419
|
-
}
|
|
420
|
-
return undefined;
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
|
|
424
|
-
const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
|
|
425
|
-
if (!fsImpl.existsSync(root)) return [];
|
|
426
|
-
const acks: SteerAck[] = [];
|
|
427
|
-
let indexNames: string[];
|
|
428
|
-
try { indexNames = fsImpl.readdirSync(root).filter((name) => /^\d+$/.test(name)); } catch { return []; }
|
|
429
|
-
for (const indexName of indexNames) {
|
|
430
|
-
const dir = path.join(root, indexName);
|
|
431
|
-
let entries: string[];
|
|
432
|
-
try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { continue; }
|
|
433
|
-
for (const entry of entries) {
|
|
434
|
-
const target = path.join(dir, entry);
|
|
435
|
-
let ack: SteerAck | undefined;
|
|
436
|
-
try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
|
|
437
|
-
try { fsImpl.rmSync(target, { force: true }); } catch { continue; }
|
|
438
|
-
if (ack) acks.push(ack);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
return acks;
|
|
442
|
-
}
|
|
443
|
-
|
|
444
262
|
function parseSteerRequest(raw: unknown): SteerRequest | undefined {
|
|
445
263
|
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return undefined;
|
|
446
264
|
const input = raw as Partial<SteerRequest>;
|
|
@@ -665,8 +483,6 @@ export function watchAsyncControlInbox(
|
|
|
665
483
|
onTimeout?: () => void;
|
|
666
484
|
onStop?: (request: StopRequest) => void;
|
|
667
485
|
onSteer?: (request: SteerRequest) => void;
|
|
668
|
-
onSteerCapability?: (capability: SteerCapability) => void;
|
|
669
|
-
onSteerAck?: (ack: SteerAck) => void;
|
|
670
486
|
pollIntervalMs?: number;
|
|
671
487
|
safetyPollIntervalMs?: number;
|
|
672
488
|
platform?: NodeJS.Platform;
|
|
@@ -691,8 +507,6 @@ export function watchAsyncControlInbox(
|
|
|
691
507
|
if (consumeTimeoutRequest(asyncDir, fsImpl)) opts.onTimeout?.();
|
|
692
508
|
if (consumeInterruptRequest(asyncDir, fsImpl)) opts.onInterrupt();
|
|
693
509
|
for (const request of consumeSteerRequests(asyncDir, fsImpl)) opts.onSteer?.(request);
|
|
694
|
-
for (const capability of consumeSteerCapabilities(asyncDir, fsImpl)) opts.onSteerCapability?.(capability);
|
|
695
|
-
for (const ack of consumeSteerAcks(asyncDir, fsImpl)) opts.onSteerAck?.(ack);
|
|
696
510
|
} catch {
|
|
697
511
|
// Never let inbox errors crash the runner.
|
|
698
512
|
}
|
|
@@ -718,32 +532,16 @@ export function watchAsyncControlInbox(
|
|
|
718
532
|
const watchDir = (target: string, create = false): void => {
|
|
719
533
|
if (disposed || watchedDirs.has(target)) return;
|
|
720
534
|
if (create) fsImpl.mkdirSync(target, { recursive: true });
|
|
721
|
-
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () =>
|
|
722
|
-
watchExistingSteerAckDirs();
|
|
723
|
-
check();
|
|
724
|
-
});
|
|
535
|
+
const watcher = fsImpl.watch(resolveWatchPath(target, fsImpl.realpathSync.native), () => check());
|
|
725
536
|
watcher.on?.("error", startPolling);
|
|
726
537
|
watchers.push(watcher);
|
|
727
538
|
watchedDirs.add(target);
|
|
728
539
|
};
|
|
729
|
-
const watchExistingSteerAckDirs = (): void => {
|
|
730
|
-
const ackRoot = path.join(dir, STEER_ACKS_DIR);
|
|
731
|
-
let entries: string[];
|
|
732
|
-
try {
|
|
733
|
-
entries = fsImpl.readdirSync(ackRoot).filter((name) => /^\d+$/.test(name));
|
|
734
|
-
} catch {
|
|
735
|
-
return;
|
|
736
|
-
}
|
|
737
|
-
for (const entry of entries) watchDir(path.join(ackRoot, entry));
|
|
738
|
-
};
|
|
739
540
|
try {
|
|
740
541
|
if (shouldUseNativeFsWatch("runner-control-inbox", opts.platform)) {
|
|
741
542
|
watchDir(dir);
|
|
742
543
|
watchDir(stopRequestsDir(asyncDir), true);
|
|
743
544
|
watchDir(steerRequestsDir(asyncDir), true);
|
|
744
|
-
watchDir(steerCapabilitiesDir(asyncDir), true);
|
|
745
|
-
watchDir(path.join(dir, STEER_ACKS_DIR), true);
|
|
746
|
-
watchExistingSteerAckDirs();
|
|
747
545
|
startSafetyPolling();
|
|
748
546
|
} else {
|
|
749
547
|
startPolling();
|
|
@@ -123,6 +123,7 @@ export interface RegisterSubagentNotifyOptions {
|
|
|
123
123
|
|
|
124
124
|
export interface CompletionNotifier {
|
|
125
125
|
deliver(result: CompletionNotification): Promise<boolean>;
|
|
126
|
+
hasPendingDelivery(): boolean;
|
|
126
127
|
dispose(): void;
|
|
127
128
|
}
|
|
128
129
|
|
|
@@ -628,6 +629,7 @@ export default function registerSubagentNotify(
|
|
|
628
629
|
|
|
629
630
|
return {
|
|
630
631
|
deliver,
|
|
632
|
+
hasPendingDelivery: () => pending.size > 0,
|
|
631
633
|
dispose() {
|
|
632
634
|
if (disposed) return;
|
|
633
635
|
disposed = true;
|
|
@@ -115,7 +115,7 @@ export function writeProcessTerminalCandidate(asyncDir: string, candidate: Proce
|
|
|
115
115
|
writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
-
/** Establish ownership before authorizing a runner to
|
|
118
|
+
/** Establish ownership before authorizing a runner to start any child session. */
|
|
119
119
|
export function initializeProcessTerminal(asyncDir: string, runId: string, runnerProcessInstanceId: string): void {
|
|
120
120
|
writeProcessTerminalCandidate(asyncDir, {
|
|
121
121
|
version: 1,
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import type { SubagentState } from "../../shared/types.ts";
|
|
3
|
+
import { shouldUseNativeFsWatch } from "../../shared/watch-strategy.ts";
|
|
4
|
+
import { hasLiveNestedDescendants, projectNestedEvents } from "../shared/nested-events.ts";
|
|
5
|
+
|
|
6
|
+
interface RetainedNestedRouteTrackerOptions {
|
|
7
|
+
pollIntervalMs?: number;
|
|
8
|
+
platform?: NodeJS.Platform;
|
|
9
|
+
watch?: typeof fs.watch;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const DEFAULT_POLL_INTERVAL_MS = 5000;
|
|
13
|
+
const REFRESH_DEBOUNCE_MS = 25;
|
|
14
|
+
|
|
15
|
+
export function createRetainedNestedRouteTracker(
|
|
16
|
+
state: Pick<SubagentState, "retainedForegroundNestedRoutes">,
|
|
17
|
+
options: RetainedNestedRouteTrackerOptions = {},
|
|
18
|
+
): { track: (rootRunId: string) => void; clear: () => void } {
|
|
19
|
+
const pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
|
|
20
|
+
const watch = options.watch ?? fs.watch;
|
|
21
|
+
const watchers = new Map<string, fs.FSWatcher>();
|
|
22
|
+
const refreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
|
23
|
+
let poller: ReturnType<typeof setInterval> | undefined;
|
|
24
|
+
|
|
25
|
+
const close = (rootRunId: string): void => {
|
|
26
|
+
watchers.get(rootRunId)?.close();
|
|
27
|
+
watchers.delete(rootRunId);
|
|
28
|
+
const timer = refreshTimers.get(rootRunId);
|
|
29
|
+
if (timer) clearTimeout(timer);
|
|
30
|
+
refreshTimers.delete(rootRunId);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const refresh = (rootRunId: string): void => {
|
|
34
|
+
const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
|
|
35
|
+
if (!retained) {
|
|
36
|
+
close(rootRunId);
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
try {
|
|
40
|
+
if (hasLiveNestedDescendants(projectNestedEvents(retained).children)) return;
|
|
41
|
+
state.retainedForegroundNestedRoutes?.delete(rootRunId);
|
|
42
|
+
close(rootRunId);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
console.error(`Failed to refresh retained nested descendants for foreground run '${rootRunId}':`, error);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const scheduleRefresh = (rootRunId: string): void => {
|
|
49
|
+
if (refreshTimers.has(rootRunId)) return;
|
|
50
|
+
const timer = setTimeout(() => {
|
|
51
|
+
refreshTimers.delete(rootRunId);
|
|
52
|
+
refresh(rootRunId);
|
|
53
|
+
}, REFRESH_DEBOUNCE_MS);
|
|
54
|
+
timer.unref?.();
|
|
55
|
+
refreshTimers.set(rootRunId, timer);
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const ensurePoller = (): void => {
|
|
59
|
+
if (poller || (state.retainedForegroundNestedRoutes?.size ?? 0) === 0) return;
|
|
60
|
+
poller = setInterval(() => {
|
|
61
|
+
for (const rootRunId of state.retainedForegroundNestedRoutes?.keys() ?? []) refresh(rootRunId);
|
|
62
|
+
if ((state.retainedForegroundNestedRoutes?.size ?? 0) > 0) return;
|
|
63
|
+
if (poller) clearInterval(poller);
|
|
64
|
+
poller = undefined;
|
|
65
|
+
}, pollIntervalMs);
|
|
66
|
+
poller.unref?.();
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const track = (rootRunId: string): void => {
|
|
70
|
+
const retained = state.retainedForegroundNestedRoutes?.get(rootRunId);
|
|
71
|
+
if (!retained) return;
|
|
72
|
+
if (shouldUseNativeFsWatch("retained-nested-route-tracker", options.platform) && !watchers.has(rootRunId)) {
|
|
73
|
+
try {
|
|
74
|
+
const watcher = watch(retained.eventSink, () => scheduleRefresh(rootRunId));
|
|
75
|
+
watcher.on("error", () => close(rootRunId));
|
|
76
|
+
watcher.unref?.();
|
|
77
|
+
watchers.set(rootRunId, watcher);
|
|
78
|
+
} catch {
|
|
79
|
+
// The bounded safety poll below covers unsupported or failed watchers.
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
scheduleRefresh(rootRunId);
|
|
83
|
+
ensurePoller();
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const clear = (): void => {
|
|
87
|
+
if (poller) clearInterval(poller);
|
|
88
|
+
poller = undefined;
|
|
89
|
+
for (const rootRunId of watchers.keys()) close(rootRunId);
|
|
90
|
+
for (const timer of refreshTimers.values()) clearTimeout(timer);
|
|
91
|
+
refreshTimers.clear();
|
|
92
|
+
state.retainedForegroundNestedRoutes?.clear();
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return { track, clear };
|
|
96
|
+
}
|