pi-subagents 0.65.0 → 0.66.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +87 -0
- package/README.md +1 -1
- package/agents/researcher.md +23 -13
- package/docs/agents.md +17 -3
- package/docs/configuration.md +34 -0
- package/docs/extension-api.md +94 -0
- package/docs/models.md +58 -1
- package/docs/observability.md +42 -2
- package/docs/tool-reference.md +11 -5
- package/docs/workflows.md +22 -7
- package/package.json +4 -1
- package/runner-server-preload.mjs +13 -0
- package/skills/pi-subagents/SKILL.md +2 -1
- package/skills/pi-subagents/references/execution-controls.md +19 -2
- package/skills/pi-subagents/references/management-authoring-rpc.md +2 -1
- package/skills/pi-subagents/references/multi-lane-orchestration.md +2 -0
- package/src/agents/advertised-agent-prompt.ts +63 -0
- package/src/agents/agent-management.ts +14 -1
- package/src/agents/agent-serializer.ts +2 -0
- package/src/agents/agents.ts +8 -0
- package/src/api/preflight.ts +5 -1
- package/src/api/shared-types.ts +1 -1
- package/src/api/workflow-resources.ts +6 -0
- package/src/extension/config.ts +4 -2
- package/src/extension/index.ts +71 -4
- package/src/extension/public-execution.ts +0 -1
- package/src/extension/rpc.ts +4 -21
- package/src/extension/schemas.ts +9 -7
- package/src/extension/tool-description.ts +10 -5
- package/src/integrations/pi-web-session-liveness.ts +73 -0
- package/src/intercom/native-supervisor-channel.ts +102 -88
- package/src/intercom/supervisor-ui.ts +3 -2
- package/src/missions/workflow-state.ts +37 -16
- package/src/runs/background/active-async-capacity.ts +18 -18
- package/src/runs/background/async-execution.ts +8 -1
- package/src/runs/background/async-job-tracker.ts +35 -3
- package/src/runs/background/async-resume.ts +3 -1
- package/src/runs/background/async-retention.ts +9 -0
- package/src/runs/background/async-status-snapshot.ts +10 -12
- package/src/runs/background/async-status.ts +17 -9
- package/src/runs/background/auto-drain.ts +40 -29
- package/src/runs/background/chain-root-attachment.ts +8 -0
- package/src/runs/background/control-channel.ts +78 -44
- package/src/runs/background/notify.ts +88 -12
- package/src/runs/background/owned-process-tree.ts +6 -6
- package/src/runs/background/process-terminal.ts +23 -23
- package/src/runs/background/retained-nested-route-tracker.ts +96 -0
- package/src/runs/background/run-child-session.ts +62 -33
- package/src/runs/background/run-status.ts +75 -5
- package/src/runs/background/runner-aliases.ts +46 -8
- package/src/runs/background/runner-child-launch.ts +86 -0
- package/src/runs/background/stale-run-reconciler.ts +3 -1
- package/src/runs/background/subagent-runner.ts +430 -208
- package/src/runs/background/subagent-wait.ts +3 -0
- package/src/runs/background/wait-completions.ts +4 -0
- package/src/runs/foreground/async-steering-action.ts +19 -0
- package/src/runs/foreground/execution.ts +116 -26
- 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 +531 -227
- package/src/runs/foreground/workflow-detach-reconcile.ts +8 -5
- package/src/runs/foreground/workflow-foreground-steering.ts +56 -2
- package/src/runs/shared/acceptance.ts +16 -3
- package/src/runs/shared/agent-contract.ts +1 -1
- package/src/runs/shared/async-status-projection.ts +47 -47
- package/src/runs/shared/child-hooks.ts +151 -2
- package/src/runs/shared/child-launch.ts +18 -13
- package/src/runs/shared/child-session.ts +55 -24
- package/src/runs/shared/child-tool-plan.ts +2 -2
- package/src/runs/shared/completion-evidence.ts +2 -2
- package/src/runs/shared/completion-guard.ts +1 -0
- package/src/runs/shared/host-step-status.ts +11 -11
- package/src/runs/shared/llm-intent-arbiter.ts +30 -20
- package/src/runs/shared/model-exclusions.ts +2 -1
- package/src/runs/shared/model-fallback.ts +41 -8
- package/src/runs/shared/nested-events.ts +8 -8
- package/src/runs/shared/orca-progress-tabs.ts +6 -0
- package/src/runs/shared/parallel-handoff.ts +57 -12
- package/src/runs/shared/parallel-utils.ts +3 -2
- package/src/runs/shared/readonly-drain-observation.ts +42 -0
- package/src/runs/shared/readonly-model-continuation.ts +69 -0
- package/src/runs/shared/readonly-session-evidence.ts +307 -0
- package/src/runs/shared/run-fanout-budget.ts +8 -8
- package/src/runs/shared/runtime-acknowledged-extensions.ts +3 -3
- package/src/runs/shared/subagent-prompt-runtime.ts +13 -3
- package/src/runs/shared/worktree-cleanup-plan.ts +6 -3
- package/src/runs/shared/worktree-setup-command.ts +190 -0
- package/src/runs/shared/worktree.ts +403 -210
- package/src/shared/model-response-aliases.ts +13 -0
- package/src/shared/types.ts +89 -60
- package/src/shared/utils.ts +10 -2
- package/src/shared/watch-strategy.ts +2 -0
- package/src/shared/workflow-child-permit.ts +18 -13
- package/src/tui/fleet-status.ts +1 -1
- package/src/tui/fleet.ts +11 -5
- package/src/tui/render.ts +44 -15
- package/src/workflows/chat-progress.ts +3 -3
- package/src/workflows/scripted-workflow.ts +70 -16
- package/src/workflows/workflow-checklist.ts +15 -18
- package/src/workflows/workflow-child-summary.ts +57 -8
- package/src/workflows/workflow-preflight.ts +19 -19
- package/src/workflows/workflow-receipt.ts +3 -3
- package/src/workflows/workflow-resources.ts +96 -21
- package/src/workflows/workflow-settlement.ts +3 -0
|
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
3
|
import * as path from "node:path";
|
|
4
4
|
import type { AgentToolResult } from "@earendil-works/pi-agent-core";
|
|
5
|
-
import type { ExtensionAPI,
|
|
5
|
+
import type { ExtensionAPI, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import { Type } from "typebox";
|
|
7
7
|
import type { ChildSupervisorMetadata } from "../runs/shared/child-runtime-config.ts";
|
|
8
8
|
import { INTERCOM_DETACH_REQUEST_EVENT, POLL_INTERVAL_MS, TEMP_ROOT_DIR, type ControlEvent, type IntercomEventBus, type SubagentState } from "../shared/types.ts";
|
|
@@ -74,7 +74,7 @@ interface ContactSupervisorParams {
|
|
|
74
74
|
}
|
|
75
75
|
|
|
76
76
|
interface IntercomParams {
|
|
77
|
-
action: "list" | "
|
|
77
|
+
action: "list" | "pending" | "status" | "reply";
|
|
78
78
|
to?: string;
|
|
79
79
|
message?: string;
|
|
80
80
|
replyTo?: string;
|
|
@@ -83,6 +83,8 @@ interface IntercomParams {
|
|
|
83
83
|
type SupervisorWatch = (filename: fs.PathLike, listener: fs.WatchListener<string>) => fs.FSWatcher;
|
|
84
84
|
|
|
85
85
|
interface NativeSupervisorChannelDeps {
|
|
86
|
+
/** Retained scheduled states for the current runtime owner, never foreign owners. */
|
|
87
|
+
getCurrentOwnerStates?: () => Iterable<SubagentState>;
|
|
86
88
|
platform?: NodeJS.Platform;
|
|
87
89
|
watch?: SupervisorWatch;
|
|
88
90
|
timers?: Pick<typeof globalThis, "setInterval" | "clearInterval" | "setImmediate" | "clearImmediate">;
|
|
@@ -95,7 +97,7 @@ const ContactSupervisorParamsSchema = Type.Object({
|
|
|
95
97
|
}, { additionalProperties: false });
|
|
96
98
|
|
|
97
99
|
const IntercomParamsSchema = Type.Object({
|
|
98
|
-
action: Type.String({ enum: ["list", "
|
|
100
|
+
action: Type.String({ enum: ["list", "pending", "status", "reply"] }),
|
|
99
101
|
to: Type.Optional(Type.String()),
|
|
100
102
|
message: Type.Optional(Type.String()),
|
|
101
103
|
replyTo: Type.Optional(Type.String()),
|
|
@@ -128,34 +130,6 @@ function reasonHeading(reason: SupervisorReason): string {
|
|
|
128
130
|
return "Subagent needs a supervisor decision.";
|
|
129
131
|
}
|
|
130
132
|
|
|
131
|
-
function formatChildMessage(input: {
|
|
132
|
-
reason: SupervisorReason;
|
|
133
|
-
message?: string;
|
|
134
|
-
interview?: unknown;
|
|
135
|
-
runId: string;
|
|
136
|
-
agent: string;
|
|
137
|
-
childIndex: number;
|
|
138
|
-
childTarget?: string;
|
|
139
|
-
}): string {
|
|
140
|
-
const lines = [
|
|
141
|
-
reasonHeading(input.reason),
|
|
142
|
-
`Run: ${input.runId}`,
|
|
143
|
-
`Agent: ${input.agent}`,
|
|
144
|
-
`Child index: ${input.childIndex}`,
|
|
145
|
-
];
|
|
146
|
-
if (input.childTarget) lines.push(`Child intercom target: ${input.childTarget}`);
|
|
147
|
-
lines.push("");
|
|
148
|
-
if (input.message?.trim()) lines.push(input.message.trim());
|
|
149
|
-
if (input.reason === "interview_request") {
|
|
150
|
-
lines.push(
|
|
151
|
-
"",
|
|
152
|
-
"Structured response requested. Reply with JSON, optionally fenced in ```json, matching the requested interview shape.",
|
|
153
|
-
);
|
|
154
|
-
if (input.interview !== undefined) lines.push(JSON.stringify(input.interview, null, "\t"));
|
|
155
|
-
}
|
|
156
|
-
return lines.join("\n").trimEnd();
|
|
157
|
-
}
|
|
158
|
-
|
|
159
133
|
function parseStructuredReply(message: string): { value?: unknown; error?: string } {
|
|
160
134
|
const trimmed = message.trim();
|
|
161
135
|
const fenced = trimmed.match(/^```(?:json)?\s*([\s\S]*?)\s*```$/i)?.[1]?.trim();
|
|
@@ -210,8 +184,8 @@ async function waitForReply(channelDir: string, requestId: string, deadline: num
|
|
|
210
184
|
}
|
|
211
185
|
|
|
212
186
|
async function sendSupervisorRequest(params: ContactSupervisorParams, metadata: ChildSupervisorMetadata, signal?: AbortSignal, toolCallId?: string): Promise<AgentToolResult<Record<string, unknown>>> {
|
|
213
|
-
if (
|
|
214
|
-
throw new Error("message is required for supervisor decisions.");
|
|
187
|
+
if (!params.message?.trim() && params.reason !== "interview_request") {
|
|
188
|
+
throw new Error("message is required for supervisor decisions and progress updates.");
|
|
215
189
|
}
|
|
216
190
|
ensureSupervisorChannelDir(metadata.channelDir);
|
|
217
191
|
const requestId = randomUUID();
|
|
@@ -219,7 +193,7 @@ async function sendSupervisorRequest(params: ContactSupervisorParams, metadata:
|
|
|
219
193
|
const createdAt = Date.now();
|
|
220
194
|
const replyDeadline = createdAt + askTimeoutMs();
|
|
221
195
|
const expiresAt = expectsReply ? replyDeadline : undefined;
|
|
222
|
-
const message =
|
|
196
|
+
const message = params.message?.trim() ?? "";
|
|
223
197
|
const requestToolCallId = typeof toolCallId === "string" && toolCallId.length > 0 ? toolCallId : undefined;
|
|
224
198
|
const request: SupervisorRequest = {
|
|
225
199
|
type: "subagent.supervisor.request",
|
|
@@ -299,7 +273,7 @@ function parseRequestFile(file: string, channelDir: string): PendingSupervisorRe
|
|
|
299
273
|
if (parsed.type !== "subagent.supervisor.request") return undefined;
|
|
300
274
|
if (typeof parsed.id !== "string" || !parsed.id) return undefined;
|
|
301
275
|
if (parsed.reason !== "need_decision" && parsed.reason !== "interview_request" && parsed.reason !== "progress_update") return undefined;
|
|
302
|
-
if (typeof parsed.message !== "string" || !parsed.message) return undefined;
|
|
276
|
+
if (typeof parsed.message !== "string" || (!parsed.message.trim() && parsed.reason !== "interview_request")) return undefined;
|
|
303
277
|
if (typeof parsed.runId !== "string" || typeof parsed.agent !== "string" || typeof parsed.childIndex !== "number") return undefined;
|
|
304
278
|
return {
|
|
305
279
|
...parsed as SupervisorRequest,
|
|
@@ -409,18 +383,8 @@ function cleanupStaleEmptySupervisorChannels(nowMs = Date.now()): number {
|
|
|
409
383
|
return removed;
|
|
410
384
|
}
|
|
411
385
|
|
|
412
|
-
function
|
|
413
|
-
|
|
414
|
-
const sessionId = ctx.sessionManager.getSessionId();
|
|
415
|
-
if (sessionId) return sessionId;
|
|
416
|
-
} catch {
|
|
417
|
-
// Fall through to the last known identity.
|
|
418
|
-
}
|
|
419
|
-
return state.currentSessionId ?? undefined;
|
|
420
|
-
}
|
|
421
|
-
|
|
422
|
-
function requestMatchesContext(request: SupervisorRequest, state: Pick<SubagentState, "currentSessionId">, ctx: ExtensionContext): boolean {
|
|
423
|
-
const currentSessionId = currentContextSessionId(state, ctx);
|
|
386
|
+
function requestMatchesOwner(request: SupervisorRequest, state: Pick<SubagentState, "supervisorOwnerSessionId">): boolean {
|
|
387
|
+
const currentSessionId = state.supervisorOwnerSessionId;
|
|
424
388
|
return Boolean(currentSessionId && request.orchestratorSessionId === currentSessionId);
|
|
425
389
|
}
|
|
426
390
|
|
|
@@ -490,12 +454,12 @@ function requestRunInactive(request: SupervisorRequest, state: SubagentState): b
|
|
|
490
454
|
return stepStatus === "complete" || stepStatus === "completed" || stepStatus === "failed" || stepStatus === "paused";
|
|
491
455
|
}
|
|
492
456
|
|
|
493
|
-
function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState,
|
|
494
|
-
if (
|
|
457
|
+
function requestLifecycle(request: PendingSupervisorRequest, state: SubagentState, now: number, runState: SubagentState): SupervisorRequestLifecycle {
|
|
458
|
+
if (!requestMatchesOwner(request, state)) return "wrong-session";
|
|
495
459
|
if (!fs.existsSync(request.requestFile)) return "missing";
|
|
496
460
|
if (request.expectsReply && fs.existsSync(replyPath(request.channelDir, request.id))) return "resolved";
|
|
497
461
|
if (request.expectsReply && now > requestExpiresAt(request, now)) return "expired";
|
|
498
|
-
if (request.expectsReply && requestRunInactive(request,
|
|
462
|
+
if (request.expectsReply && requestRunInactive(request, runState)) return "inactive";
|
|
499
463
|
return "pending";
|
|
500
464
|
}
|
|
501
465
|
|
|
@@ -503,10 +467,10 @@ function cleanupRequestLifecycle(request: PendingSupervisorRequest, lifecycle: S
|
|
|
503
467
|
if (lifecycle === "resolved" || lifecycle === "expired" || lifecycle === "inactive") removeRequestFile(request.requestFile);
|
|
504
468
|
}
|
|
505
469
|
|
|
506
|
-
function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState,
|
|
470
|
+
function refreshPendingRequests(pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, runState: (request: SupervisorRequest) => SubagentState): void {
|
|
507
471
|
const now = Date.now();
|
|
508
472
|
for (const request of pending.values()) {
|
|
509
|
-
const lifecycle = requestLifecycle(request, state,
|
|
473
|
+
const lifecycle = requestLifecycle(request, state, now, runState(request));
|
|
510
474
|
if (lifecycle === "pending") continue;
|
|
511
475
|
pending.delete(request.id);
|
|
512
476
|
onLifecycle(request, lifecycle);
|
|
@@ -520,11 +484,24 @@ function formatPendingLine(request: PendingSupervisorRequest): string {
|
|
|
520
484
|
}
|
|
521
485
|
|
|
522
486
|
function requestVisibleText(request: PendingSupervisorRequest): string {
|
|
523
|
-
const lines = [
|
|
524
|
-
|
|
525
|
-
|
|
487
|
+
const lines = [
|
|
488
|
+
reasonHeading(request.reason),
|
|
489
|
+
`Run: ${request.runId}`,
|
|
490
|
+
`Agent: ${request.agent}`,
|
|
491
|
+
`Child index: ${request.childIndex}`,
|
|
492
|
+
];
|
|
493
|
+
lines.push("");
|
|
494
|
+
if (request.message) lines.push(request.message);
|
|
495
|
+
if (request.reason === "interview_request") {
|
|
496
|
+
lines.push(
|
|
497
|
+
"",
|
|
498
|
+
"Structured response requested. Reply with JSON, optionally fenced in ```json, matching the requested interview shape.",
|
|
499
|
+
);
|
|
500
|
+
if (request.interview !== undefined) lines.push(JSON.stringify(request.interview, null, "\t"));
|
|
526
501
|
}
|
|
527
|
-
|
|
502
|
+
if (request.expectsReply) lines.push("", `Reply with: ${supervisorReplyHint(request.id)}`);
|
|
503
|
+
lines.push("", `Live guidance: subagent({ action: "steer", id: ${JSON.stringify(request.runId)}, index: ${request.childIndex}, message: "..." })${request.expectsReply ? " (Reply to the pending request first.)" : ""}`);
|
|
504
|
+
return lines.join("\n").trimEnd();
|
|
528
505
|
}
|
|
529
506
|
|
|
530
507
|
function writeReply(request: PendingSupervisorRequest, message: string): SupervisorReply {
|
|
@@ -578,6 +555,7 @@ function resolvePendingRequest(pending: Map<string, PendingSupervisorRequest>, p
|
|
|
578
555
|
);
|
|
579
556
|
if (matches.length === 1) return matches[0]!;
|
|
580
557
|
if (matches.length > 1) throw new Error(`Multiple pending supervisor requests match '${params.to}'. Use replyTo.`);
|
|
558
|
+
throw new Error(`No pending supervisor request matches '${params.to}'. Use replyTo.`);
|
|
581
559
|
}
|
|
582
560
|
if (requests.length === 1) return requests[0]!;
|
|
583
561
|
if (requests.length === 0) throw new Error("No pending supervisor requests need a reply.");
|
|
@@ -595,14 +573,16 @@ function publicPendingRequests(pending: Map<string, PendingSupervisorRequest>):
|
|
|
595
573
|
}));
|
|
596
574
|
}
|
|
597
575
|
|
|
598
|
-
function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
|
|
576
|
+
function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, PendingSupervisorRequest>, state: SubagentState, onLifecycle: SupervisorRequestLifecycleObserver, discover: () => void, runState: (request: SupervisorRequest) => SubagentState): ToolDefinition<typeof IntercomParamsSchema, Record<string, unknown>> {
|
|
599
577
|
return {
|
|
600
578
|
name: NATIVE_SUPERVISOR_TOOL_NAME,
|
|
601
579
|
label: "Subagent Supervisor",
|
|
602
580
|
description: "Native pi-subagents supervisor channel. Use reply/pending/status to answer child subagent requests without overriding pi-intercom.",
|
|
603
581
|
parameters: IntercomParamsSchema,
|
|
604
582
|
async execute(_id, params) {
|
|
605
|
-
|
|
583
|
+
// Discover new request files even when demand-gated polling is idle.
|
|
584
|
+
discover();
|
|
585
|
+
refreshPendingRequests(pending, state, onLifecycle, runState);
|
|
606
586
|
const input = params as IntercomParams;
|
|
607
587
|
if (input.action === "status") {
|
|
608
588
|
return { content: [{ type: "text", text: `Native supervisor channel active. Pending replies: ${pending.size}.` }], details: { active: true, pending: pending.size, root: SUPERVISOR_CHANNEL_ROOT } };
|
|
@@ -620,9 +600,6 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
|
|
|
620
600
|
clearForegroundSupervisorAttention(request, pending, state);
|
|
621
601
|
return { content: [{ type: "text", text: `Replied to supervisor request ${request.id}.` }], details: { replyTo: request.id, runId: request.runId, agent: request.agent } };
|
|
622
602
|
}
|
|
623
|
-
if (input.action === "send" || input.action === "ask") {
|
|
624
|
-
throw new Error("The native subagent supervisor handles replies only. Child agents initiate asks with contact_supervisor.");
|
|
625
|
-
}
|
|
626
603
|
throw new Error(`Unsupported supervisor action: ${input.action}`);
|
|
627
604
|
},
|
|
628
605
|
};
|
|
@@ -631,12 +608,19 @@ function buildParentSupervisorTool(pi: ExtensionAPI, pending: Map<string, Pendin
|
|
|
631
608
|
export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentState, deps: NativeSupervisorChannelDeps = {}): {
|
|
632
609
|
start: () => void;
|
|
633
610
|
activateTransport: () => void;
|
|
611
|
+
findPendingAsks: (target: { runId: string; agent: string; childIndex: number }) => string[];
|
|
634
612
|
dispose: () => void;
|
|
635
613
|
pending: Map<string, PendingSupervisorRequest>;
|
|
636
614
|
getSupervisorRequestState: (event: ControlEvent) => SupervisorRequestState;
|
|
637
615
|
} {
|
|
638
616
|
const watch = deps.watch ?? fs.watch;
|
|
639
617
|
const timers = deps.timers ?? globalThis;
|
|
618
|
+
const runState = (request: SupervisorRequest): SubagentState => {
|
|
619
|
+
for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
|
|
620
|
+
if (ownerState.asyncJobs.has(request.runId)) return ownerState;
|
|
621
|
+
}
|
|
622
|
+
return state;
|
|
623
|
+
};
|
|
640
624
|
const pending = new Map<string, PendingSupervisorRequest>();
|
|
641
625
|
const requestCorrelations = new Map<string, SupervisorRequestCorrelation>();
|
|
642
626
|
const correlationKey = (request: { runId: string; agent: string; childIndex: number; toolCallId?: string }): string | undefined => {
|
|
@@ -682,7 +666,7 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
|
|
|
682
666
|
if (!fs.existsSync(correlation.request.requestFile)
|
|
683
667
|
|| fs.existsSync(replyPath(correlation.request.channelDir, correlation.request.id))
|
|
684
668
|
|| now > requestExpiresAt(correlation.request, now)
|
|
685
|
-
|| requestRunInactive(correlation.request,
|
|
669
|
+
|| requestRunInactive(correlation.request, runState(correlation.request))) {
|
|
686
670
|
rememberResolvedRequest(correlation.request);
|
|
687
671
|
return "resolved";
|
|
688
672
|
}
|
|
@@ -701,11 +685,17 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
|
|
|
701
685
|
const hasTransportDemand = () => {
|
|
702
686
|
if (pending.size > 0) return true;
|
|
703
687
|
if (state.foregroundControls.size > 0) return true;
|
|
704
|
-
|
|
688
|
+
if ([...state.asyncJobs.values()].some((job) => job.status === "queued" || job.status === "running")) return true;
|
|
689
|
+
for (const ownerState of deps.getCurrentOwnerStates?.() ?? []) {
|
|
690
|
+
for (const job of ownerState.asyncJobs.values()) {
|
|
691
|
+
if (job.status === "queued" || job.status === "running") return true;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return false;
|
|
705
695
|
};
|
|
706
696
|
|
|
707
697
|
const registerParentTools = (): void => {
|
|
708
|
-
if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle));
|
|
698
|
+
if (!hasTool(pi, NATIVE_SUPERVISOR_TOOL_NAME)) pi.registerTool(buildParentSupervisorTool(pi, pending, state, observeRequestLifecycle, () => poll(), runState));
|
|
709
699
|
};
|
|
710
700
|
|
|
711
701
|
const cleanupStaleChannelsIfDue = (): void => {
|
|
@@ -721,15 +711,14 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
|
|
|
721
711
|
|
|
722
712
|
const poll = (): void => {
|
|
723
713
|
cleanupStaleChannelsIfDue();
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
refreshPendingRequests(pending, state, ctx, observeRequestLifecycle);
|
|
714
|
+
// Only display notifications require a live UI context, not request registration.
|
|
715
|
+
refreshPendingRequests(pending, state, observeRequestLifecycle, runState);
|
|
727
716
|
const now = Date.now();
|
|
728
717
|
for (const { channelDir, file } of listRequestFiles()) {
|
|
729
718
|
if (seenFiles.has(file)) continue;
|
|
730
719
|
const request = parseRequestFile(file, channelDir);
|
|
731
|
-
if (!request || !
|
|
732
|
-
const lifecycle = requestLifecycle(request, state,
|
|
720
|
+
if (!request || !requestMatchesOwner(request, state)) continue;
|
|
721
|
+
const lifecycle = requestLifecycle(request, state, now, runState(request));
|
|
733
722
|
if (lifecycle !== "pending") {
|
|
734
723
|
seenFiles.add(file);
|
|
735
724
|
observeRequestLifecycle(request, lifecycle);
|
|
@@ -742,26 +731,34 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
|
|
|
742
731
|
pending.set(request.id, request);
|
|
743
732
|
markForegroundSupervisorAttention(request, state);
|
|
744
733
|
}
|
|
745
|
-
|
|
746
|
-
|
|
734
|
+
// The ask is already queued above. A sendMessage failure (no UI, stale context) must not
|
|
735
|
+
// lose it, and must not abort the loop before the remaining asks register.
|
|
736
|
+
try {
|
|
737
|
+
pi.sendMessage({
|
|
738
|
+
customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
|
|
739
|
+
content: requestVisibleText(request),
|
|
740
|
+
display: true,
|
|
741
|
+
details: {
|
|
742
|
+
id: request.id,
|
|
743
|
+
requestId: request.id,
|
|
744
|
+
reason: request.reason,
|
|
745
|
+
expectsReply: request.expectsReply,
|
|
746
|
+
runId: request.runId,
|
|
747
|
+
agent: request.agent,
|
|
748
|
+
childIndex: request.childIndex,
|
|
749
|
+
...(request.childTarget ? { childTarget: request.childTarget } : {}),
|
|
750
|
+
...(request.interview !== undefined ? { interview: request.interview } : {}),
|
|
751
|
+
requestBody: request.message,
|
|
752
|
+
...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
|
|
753
|
+
},
|
|
754
|
+
}, { triggerTurn: true });
|
|
755
|
+
// sendMessage accepts synchronously; one-way updates stay on disk until it returns.
|
|
756
|
+
if (!request.expectsReply) removeRequestFile(request.requestFile);
|
|
757
|
+
} catch (error) {
|
|
758
|
+
// Allow an existing later scan to retry an unaccepted one-way update.
|
|
759
|
+
if (!request.expectsReply) seenFiles.delete(file);
|
|
760
|
+
console.error(`Failed to surface supervisor request ${request.id} as a user turn:`, error);
|
|
747
761
|
}
|
|
748
|
-
pi.sendMessage({
|
|
749
|
-
customType: SUPERVISOR_REQUEST_MESSAGE_TYPE,
|
|
750
|
-
content: requestVisibleText(request),
|
|
751
|
-
display: true,
|
|
752
|
-
details: {
|
|
753
|
-
id: request.id,
|
|
754
|
-
requestId: request.id,
|
|
755
|
-
reason: request.reason,
|
|
756
|
-
expectsReply: request.expectsReply,
|
|
757
|
-
runId: request.runId,
|
|
758
|
-
agent: request.agent,
|
|
759
|
-
childIndex: request.childIndex,
|
|
760
|
-
...(request.childTarget ? { childTarget: request.childTarget } : {}),
|
|
761
|
-
...(request.interview !== undefined ? { interview: request.interview } : {}),
|
|
762
|
-
...(request.expectsReply ? { replyHint: supervisorReplyHint(request.id) } : {}),
|
|
763
|
-
},
|
|
764
|
-
}, { triggerTurn: true });
|
|
765
762
|
if (request.expectsReply) {
|
|
766
763
|
(pi as { events?: IntercomEventBus }).events?.emit(INTERCOM_DETACH_REQUEST_EVENT, {
|
|
767
764
|
requestId: request.id,
|
|
@@ -838,6 +835,23 @@ export function createNativeSupervisorChannel(pi: ExtensionAPI, state: SubagentS
|
|
|
838
835
|
poll();
|
|
839
836
|
if (!useNativeWatcher() && hasTransportDemand()) startPolling();
|
|
840
837
|
},
|
|
838
|
+
findPendingAsks: (target) => {
|
|
839
|
+
// Receipt-only discovery: no registration, notification, cleanup or reply writes.
|
|
840
|
+
const channelDir = resolveSupervisorChannelDir(target.runId, target.agent, target.childIndex);
|
|
841
|
+
let files: string[];
|
|
842
|
+
try { files = fs.readdirSync(path.join(channelDir, REQUESTS_DIR)); }
|
|
843
|
+
catch (error) {
|
|
844
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
|
|
845
|
+
throw error;
|
|
846
|
+
}
|
|
847
|
+
const now = Date.now();
|
|
848
|
+
return files.filter(file => file.endsWith(".json")).flatMap(file => {
|
|
849
|
+
const request = parseRequestFile(path.join(channelDir, REQUESTS_DIR, file), channelDir);
|
|
850
|
+
return request?.expectsReply && request.runId === target.runId
|
|
851
|
+
&& request.agent === target.agent && request.childIndex === target.childIndex
|
|
852
|
+
&& requestLifecycle(request, state, now, runState(request)) === "pending" ? [request.id] : [];
|
|
853
|
+
}).sort();
|
|
854
|
+
},
|
|
841
855
|
start: () => {
|
|
842
856
|
if (started) return;
|
|
843
857
|
started = true;
|
|
@@ -17,6 +17,7 @@ export interface SupervisorRequestMessageDetails {
|
|
|
17
17
|
childIndex?: number;
|
|
18
18
|
childTarget?: string;
|
|
19
19
|
interview?: unknown;
|
|
20
|
+
requestBody?: string;
|
|
20
21
|
replyHint?: string;
|
|
21
22
|
}
|
|
22
23
|
|
|
@@ -107,7 +108,7 @@ function optionalString(value: unknown): boolean {
|
|
|
107
108
|
|
|
108
109
|
function requestDetails(value: unknown): SupervisorRequestMessageDetails | undefined {
|
|
109
110
|
if (!isRecord(value)) return undefined;
|
|
110
|
-
if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
|
|
111
|
+
if (!optionalString(value.id) || !optionalString(value.requestId) || !optionalString(value.replyHint) || !optionalString(value.requestBody) || !optionalString(value.runId) || !optionalString(value.agent) || !optionalString(value.childTarget)) return undefined;
|
|
111
112
|
if (value.reason !== undefined && !isSupervisorReason(value.reason)) return undefined;
|
|
112
113
|
if (value.expectsReply !== undefined && typeof value.expectsReply !== "boolean") return undefined;
|
|
113
114
|
if (value.childIndex !== undefined && (typeof value.childIndex !== "number" || !Number.isFinite(value.childIndex))) return undefined;
|
|
@@ -153,7 +154,7 @@ function requestLines(message: SupervisorMessageLike, details: SupervisorRequest
|
|
|
153
154
|
if (details.childTarget) lines.push(`Child target: ${boundedField(details.childTarget)}`);
|
|
154
155
|
lines.push(`Request ID: ${requestId}`);
|
|
155
156
|
if (details.expectsReply) lines.push(`Reply with: ${displayText(details.replyHint ?? supervisorReplyHint(requestId), MAX_BODY_CHARS, expanded)}`);
|
|
156
|
-
lines.push("", "Request:", displayText(contentText(message.content) || "(no request body)", MAX_BODY_CHARS, expanded));
|
|
157
|
+
lines.push("", "Request:", displayText((details.requestBody ?? contentText(message.content)) || "(no request body)", MAX_BODY_CHARS, expanded));
|
|
157
158
|
if (details.interview !== undefined) lines.push("", "Interview shape:", interviewText(details.interview, expanded));
|
|
158
159
|
return lines;
|
|
159
160
|
}
|
|
@@ -18,6 +18,12 @@ export interface MissionWorkflowState {
|
|
|
18
18
|
set(key: string, value: unknown): void;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
+
export interface MissionWorkflowStateOptions {
|
|
22
|
+
isProcessAlive?: (pid: number) => boolean;
|
|
23
|
+
getProcessStartKey?: (pid: number) => string | undefined;
|
|
24
|
+
retryDelaysMs?: readonly number[];
|
|
25
|
+
}
|
|
26
|
+
|
|
21
27
|
export function missionStatePath(location: MissionStoreLocation, missionId: string): string {
|
|
22
28
|
return path.join(location.missionDir, validateMissionId(missionId), "state.json");
|
|
23
29
|
}
|
|
@@ -66,13 +72,22 @@ function windowsProcessStartKey(pid: number): string | undefined {
|
|
|
66
72
|
}
|
|
67
73
|
}
|
|
68
74
|
|
|
75
|
+
let currentProcessKey: string | undefined | null = null;
|
|
76
|
+
|
|
69
77
|
function processStartKey(pid: number): string | undefined {
|
|
78
|
+
// Foreign PIDs can be reused while this process lives, so resolve them afresh.
|
|
79
|
+
if (pid !== process.pid) return computeProcessStartKey(pid);
|
|
80
|
+
if (currentProcessKey === null) currentProcessKey = computeProcessStartKey(pid);
|
|
81
|
+
return currentProcessKey;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function computeProcessStartKey(pid: number): string | undefined {
|
|
70
85
|
if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
|
|
71
86
|
if (process.platform === "win32") return windowsProcessStartKey(pid);
|
|
72
87
|
return undefined;
|
|
73
88
|
}
|
|
74
89
|
|
|
75
|
-
|
|
90
|
+
type StateLockOptions = Required<MissionWorkflowStateOptions>;
|
|
76
91
|
|
|
77
92
|
function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
|
|
78
93
|
try {
|
|
@@ -91,13 +106,13 @@ function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
|
|
|
91
106
|
return undefined;
|
|
92
107
|
}
|
|
93
108
|
|
|
94
|
-
function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
|
|
109
|
+
function stateLockIsStale(lockPath: string, options: StateLockOptions, now = Date.now()): boolean {
|
|
95
110
|
const owner = readStateLockOwner(lockPath);
|
|
96
111
|
if (owner) {
|
|
97
|
-
if (!isProcessAlive(owner.pid)) return true;
|
|
112
|
+
if (!options.isProcessAlive(owner.pid)) return true;
|
|
98
113
|
if (owner.processKey) {
|
|
99
|
-
const
|
|
100
|
-
if (
|
|
114
|
+
const observedProcessKey = options.getProcessStartKey(owner.pid);
|
|
115
|
+
if (observedProcessKey) return owner.processKey !== observedProcessKey;
|
|
101
116
|
if (owner.pid === process.pid) return true;
|
|
102
117
|
}
|
|
103
118
|
return false;
|
|
@@ -140,11 +155,11 @@ function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
|
|
|
140
155
|
waitForFileSystemRetry(delayMs);
|
|
141
156
|
}
|
|
142
157
|
|
|
143
|
-
function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
|
|
144
|
-
if (!stateLockIsStale(lockPath)) return false;
|
|
158
|
+
function reclaimStaleStateLock(lockPath: string, reclaimPath: string, options: StateLockOptions): boolean {
|
|
159
|
+
if (!stateLockIsStale(lockPath, options)) return false;
|
|
145
160
|
if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
|
|
146
161
|
try {
|
|
147
|
-
if (!stateLockIsStale(lockPath)) return false;
|
|
162
|
+
if (!stateLockIsStale(lockPath, options)) return false;
|
|
148
163
|
fs.rmSync(lockPath, { recursive: true, force: true });
|
|
149
164
|
return true;
|
|
150
165
|
} finally {
|
|
@@ -152,7 +167,7 @@ function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
|
|
|
152
167
|
}
|
|
153
168
|
}
|
|
154
169
|
|
|
155
|
-
function withStateFileLock<T>(filePath: string, operation: () => T): T {
|
|
170
|
+
function withStateFileLock<T>(filePath: string, operation: () => T, options: StateLockOptions): T {
|
|
156
171
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
157
172
|
const lockPath = `${filePath}.lock`;
|
|
158
173
|
const reclaimPath = `${lockPath}.reclaim`;
|
|
@@ -163,7 +178,7 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
|
|
|
163
178
|
fs.rmSync(reclaimPath, { recursive: true, force: true });
|
|
164
179
|
continue;
|
|
165
180
|
}
|
|
166
|
-
waitForStateLock(
|
|
181
|
+
waitForStateLock(options.retryDelaysMs[attempt], lockPath);
|
|
167
182
|
continue;
|
|
168
183
|
}
|
|
169
184
|
let acquired = false;
|
|
@@ -171,17 +186,18 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
|
|
|
171
186
|
acquired = tryMakeDirectory(lockPath, 0o700);
|
|
172
187
|
} catch (error) {
|
|
173
188
|
if (isRetryableFileSystemError(error)) {
|
|
174
|
-
waitForStateLock(
|
|
189
|
+
waitForStateLock(options.retryDelaysMs[attempt], lockPath);
|
|
175
190
|
continue;
|
|
176
191
|
}
|
|
177
192
|
throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
178
193
|
}
|
|
179
194
|
if (!acquired) {
|
|
180
|
-
if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
|
|
181
|
-
waitForStateLock(
|
|
195
|
+
if (reclaimStaleStateLock(lockPath, reclaimPath, options)) continue;
|
|
196
|
+
waitForStateLock(options.retryDelaysMs[attempt], lockPath);
|
|
182
197
|
continue;
|
|
183
198
|
}
|
|
184
|
-
|
|
199
|
+
const key = options.getProcessStartKey(process.pid);
|
|
200
|
+
owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(key ? { processKey: key } : {}) };
|
|
185
201
|
try {
|
|
186
202
|
fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
|
|
187
203
|
} catch (error) {
|
|
@@ -205,8 +221,13 @@ function validateStateKey(value: unknown): string {
|
|
|
205
221
|
return value;
|
|
206
222
|
}
|
|
207
223
|
|
|
208
|
-
export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string): MissionWorkflowState {
|
|
224
|
+
export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string, options: MissionWorkflowStateOptions = {}): MissionWorkflowState {
|
|
209
225
|
const filePath = missionStatePath(location, missionId);
|
|
226
|
+
const lockOptions: StateLockOptions = {
|
|
227
|
+
isProcessAlive: options.isProcessAlive ?? isProcessAlive,
|
|
228
|
+
getProcessStartKey: options.getProcessStartKey ?? processStartKey,
|
|
229
|
+
retryDelaysMs: options.retryDelaysMs ?? DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS,
|
|
230
|
+
};
|
|
210
231
|
let loaded = false;
|
|
211
232
|
let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
212
233
|
|
|
@@ -254,7 +275,7 @@ export function createMissionWorkflowState(location: MissionStoreLocation, missi
|
|
|
254
275
|
writePrivateAtomicJson(filePath, next);
|
|
255
276
|
values = next;
|
|
256
277
|
loaded = true;
|
|
257
|
-
});
|
|
278
|
+
}, lockOptions);
|
|
258
279
|
},
|
|
259
280
|
};
|
|
260
281
|
}
|