pi-subagents 0.42.1 → 0.44.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.
Files changed (59) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/README.md +3 -5
  3. package/package.json +1 -1
  4. package/skills/pi-subagents/SKILL.md +2 -2
  5. package/skills/pi-subagents/references/constraints-and-recipes.md +35 -32
  6. package/skills/pi-subagents/references/execution-controls.md +30 -44
  7. package/skills/pi-subagents/references/management-authoring-rpc.md +20 -3
  8. package/skills/pi-subagents/references/prompting-and-roles.md +16 -49
  9. package/src/agents/agent-refinements.ts +624 -0
  10. package/src/agents/agents.ts +0 -2
  11. package/src/agents/proactive-skills.ts +1 -1
  12. package/src/api/delegation.ts +1 -2
  13. package/src/extension/config.ts +28 -7
  14. package/src/extension/control-notices.ts +2 -2
  15. package/src/extension/fanout-child.ts +46 -22
  16. package/src/extension/index.ts +32 -13
  17. package/src/extension/public-execution.ts +71 -0
  18. package/src/extension/rpc.ts +7 -8
  19. package/src/extension/schemas.ts +14 -14
  20. package/src/extension/tool-description.ts +14 -14
  21. package/src/missions/actions.ts +47 -11
  22. package/src/missions/goal-driver.ts +162 -0
  23. package/src/missions/lifecycle.ts +44 -12
  24. package/src/missions/store.ts +68 -3
  25. package/src/missions/types.ts +25 -3
  26. package/src/missions/workflow-state.ts +250 -0
  27. package/src/profiles/profiles.ts +1 -3
  28. package/src/runs/background/async-execution.ts +5 -1
  29. package/src/runs/background/async-job-tracker.ts +2 -17
  30. package/src/runs/background/async-status.ts +2 -0
  31. package/src/runs/background/control-channel.ts +50 -6
  32. package/src/runs/background/retained-children.ts +68 -0
  33. package/src/runs/background/run-id-resolver.ts +71 -0
  34. package/src/runs/background/run-status.ts +3 -2
  35. package/src/runs/background/scheduled-runs.ts +17 -13
  36. package/src/runs/background/steering.ts +7 -5
  37. package/src/runs/background/subagent-runner.ts +50 -23
  38. package/src/runs/foreground/async-steering-action.ts +36 -10
  39. package/src/runs/foreground/chain-clarify.ts +7 -3
  40. package/src/runs/foreground/execution.ts +29 -19
  41. package/src/runs/foreground/subagent-executor.ts +282 -77
  42. package/src/runs/shared/acceptance.ts +154 -9
  43. package/src/runs/shared/subagent-prompt-runtime.ts +97 -25
  44. package/src/shared/settings.ts +10 -6
  45. package/src/shared/types.ts +22 -2
  46. package/src/slash/delegation-adapters.ts +0 -13
  47. package/src/slash/prompt-template-bridge.ts +14 -12
  48. package/src/slash/prompt-workflows.ts +10 -4
  49. package/src/slash/slash-bridge.ts +8 -6
  50. package/src/slash/slash-commands.ts +24 -9
  51. package/src/slash/slash-live-state.ts +4 -2
  52. package/src/tui/fleet-status.ts +8 -4
  53. package/src/tui/fleet.ts +15 -5
  54. package/src/tui/render.ts +10 -29
  55. package/src/workflows/scripted-workflow.ts +91 -6
  56. package/agents/context-builder.md +0 -46
  57. package/agents/planner.md +0 -56
  58. package/prompts/parallel-context-build.md +0 -55
  59. package/prompts/parallel-handoff-plan.md +0 -61
@@ -0,0 +1,250 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
7
+ import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
8
+ import type { MissionStoreLocation } from "./types.ts";
9
+ import { validateMissionId } from "./store.ts";
10
+
11
+ const STATE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
12
+ const STATE_LOCK_STALE_MS = 60_000;
13
+ export const MISSION_STATE_MAX_BYTES = 256 * 1024;
14
+
15
+ export interface MissionWorkflowState {
16
+ path: string;
17
+ get(key: string): unknown;
18
+ set(key: string, value: unknown): void;
19
+ }
20
+
21
+ export function missionStatePath(location: MissionStoreLocation, missionId: string): string {
22
+ return path.join(location.missionDir, validateMissionId(missionId), "state.json");
23
+ }
24
+
25
+ function isProcessAlive(pid: number): boolean {
26
+ try {
27
+ process.kill(pid, 0);
28
+ return true;
29
+ } catch (error) {
30
+ return (error as NodeJS.ErrnoException).code === "EPERM";
31
+ }
32
+ }
33
+
34
+ interface StateLockOwner {
35
+ pid: number;
36
+ token: string;
37
+ createdAt: number;
38
+ processKey?: string;
39
+ }
40
+
41
+ function linuxProcessStartKey(pid: number): string | undefined {
42
+ try {
43
+ const raw = fs.readFileSync(`/proc/${pid}/stat`, "utf-8");
44
+ const tail = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/);
45
+ return tail[19] ? `linux:${tail[19]}` : undefined;
46
+ } catch {
47
+ return undefined;
48
+ }
49
+ }
50
+
51
+ function psProcessStartKey(pid: number): string | undefined {
52
+ try {
53
+ const raw = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
54
+ return raw ? `ps:${raw}` : undefined;
55
+ } catch {
56
+ return undefined;
57
+ }
58
+ }
59
+
60
+ function windowsProcessStartKey(pid: number): string | undefined {
61
+ try {
62
+ const raw = execFileSync("powershell.exe", ["-NoProfile", "-Command", `(Get-CimInstance Win32_Process -Filter \"ProcessId=${pid}\").CreationDate`], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"], timeout: 1000 }).trim();
63
+ return raw ? `win:${raw}` : undefined;
64
+ } catch {
65
+ return undefined;
66
+ }
67
+ }
68
+
69
+ function processStartKey(pid: number): string | undefined {
70
+ if (process.platform === "linux") return linuxProcessStartKey(pid) ?? psProcessStartKey(pid);
71
+ if (process.platform === "win32") return windowsProcessStartKey(pid);
72
+ return psProcessStartKey(pid);
73
+ }
74
+
75
+ const CURRENT_PROCESS_KEY = processStartKey(process.pid);
76
+
77
+ function readStateLockOwner(lockPath: string): StateLockOwner | undefined {
78
+ try {
79
+ const owner = JSON.parse(fs.readFileSync(path.join(lockPath, "owner.json"), "utf-8")) as { pid?: unknown; token?: unknown; createdAt?: unknown; processKey?: unknown };
80
+ if (Number.isSafeInteger(owner.pid) && (owner.pid as number) > 0 && typeof owner.token === "string" && owner.token && Number.isSafeInteger(owner.createdAt)) {
81
+ return {
82
+ pid: owner.pid as number,
83
+ token: owner.token,
84
+ createdAt: owner.createdAt as number,
85
+ ...(typeof owner.processKey === "string" && owner.processKey ? { processKey: owner.processKey } : {}),
86
+ };
87
+ }
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ return undefined;
92
+ }
93
+
94
+ function stateLockIsStale(lockPath: string, now = Date.now()): boolean {
95
+ const owner = readStateLockOwner(lockPath);
96
+ if (owner) {
97
+ if (!isProcessAlive(owner.pid)) return true;
98
+ const currentProcessKey = processStartKey(owner.pid);
99
+ return Boolean(owner.processKey && currentProcessKey && owner.processKey !== currentProcessKey);
100
+ }
101
+ try {
102
+ return now - fs.statSync(lockPath).mtimeMs > STATE_LOCK_STALE_MS;
103
+ } catch (error) {
104
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
105
+ throw error;
106
+ }
107
+ }
108
+
109
+ function removeOwnedStateLock(lockPath: string, owner: StateLockOwner): void {
110
+ const current = readStateLockOwner(lockPath);
111
+ if (current?.token !== owner.token) return;
112
+ fs.rmSync(lockPath, { recursive: true, force: true });
113
+ }
114
+
115
+ function staleDirectoryExists(dirPath: string, now = Date.now()): boolean {
116
+ try {
117
+ return now - fs.statSync(dirPath).mtimeMs > STATE_LOCK_STALE_MS;
118
+ } catch (error) {
119
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return false;
120
+ throw error;
121
+ }
122
+ }
123
+
124
+ function tryMakeDirectory(dirPath: string, mode: number): boolean {
125
+ try {
126
+ fs.mkdirSync(dirPath, { mode });
127
+ return true;
128
+ } catch (error) {
129
+ if ((error as NodeJS.ErrnoException).code === "EEXIST") return false;
130
+ throw error;
131
+ }
132
+ }
133
+
134
+ function waitForStateLock(delayMs: number | undefined, lockPath: string): void {
135
+ if (delayMs === undefined) throw new Error(`Timed out acquiring mission state lock '${lockPath}'.`);
136
+ waitForFileSystemRetry(delayMs);
137
+ }
138
+
139
+ function reclaimStaleStateLock(lockPath: string, reclaimPath: string): boolean {
140
+ if (!stateLockIsStale(lockPath)) return false;
141
+ if (!tryMakeDirectory(reclaimPath, 0o700)) return false;
142
+ try {
143
+ if (!stateLockIsStale(lockPath)) return false;
144
+ fs.rmSync(lockPath, { recursive: true, force: true });
145
+ return true;
146
+ } finally {
147
+ fs.rmSync(reclaimPath, { recursive: true, force: true });
148
+ }
149
+ }
150
+
151
+ function withStateFileLock<T>(filePath: string, operation: () => T): T {
152
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
153
+ const lockPath = `${filePath}.lock`;
154
+ const reclaimPath = `${lockPath}.reclaim`;
155
+ let owner: StateLockOwner | undefined;
156
+ for (let attempt = 0; ; attempt++) {
157
+ if (fs.existsSync(reclaimPath)) {
158
+ if (staleDirectoryExists(reclaimPath)) {
159
+ fs.rmSync(reclaimPath, { recursive: true, force: true });
160
+ continue;
161
+ }
162
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
163
+ continue;
164
+ }
165
+ try {
166
+ fs.mkdirSync(lockPath, { mode: 0o700 });
167
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
168
+ try {
169
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
170
+ } catch (error) {
171
+ removeOwnedStateLock(lockPath, owner);
172
+ owner = undefined;
173
+ throw error;
174
+ }
175
+ break;
176
+ } catch (error) {
177
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") {
178
+ throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
179
+ }
180
+ if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
182
+ }
183
+ }
184
+ try {
185
+ return operation();
186
+ } finally {
187
+ if (owner) removeOwnedStateLock(lockPath, owner);
188
+ }
189
+ }
190
+
191
+ function validateStateKey(value: unknown): string {
192
+ if (typeof value !== "string" || !STATE_KEY_PATTERN.test(value)) {
193
+ throw new Error("state key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
194
+ }
195
+ return value;
196
+ }
197
+
198
+ export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string): MissionWorkflowState {
199
+ const filePath = missionStatePath(location, missionId);
200
+ let loaded = false;
201
+ let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
202
+
203
+ const readStateFile = (): Record<string, unknown> => {
204
+ let raw: string;
205
+ try {
206
+ raw = fs.readFileSync(filePath, "utf-8");
207
+ } catch (error) {
208
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return Object.create(null) as Record<string, unknown>;
209
+ throw new Error(`Failed to read mission state '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
210
+ }
211
+ const bytes = Buffer.byteLength(raw);
212
+ if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state file '${filePath}' exceeds the 256 KiB limit (${bytes} bytes).`);
213
+ try {
214
+ const parsed: unknown = JSON.parse(raw);
215
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("root must be a JSON object");
216
+ assertWorkflowJsonValue(parsed, "mission state");
217
+ return Object.assign(Object.create(null) as Record<string, unknown>, parsed);
218
+ } catch (error) {
219
+ throw new Error(`Invalid mission state file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
220
+ }
221
+ };
222
+
223
+ const load = (): Record<string, unknown> => {
224
+ if (loaded) return values;
225
+ values = readStateFile();
226
+ loaded = true;
227
+ return values;
228
+ };
229
+
230
+ return {
231
+ path: filePath,
232
+ get(key) {
233
+ const validKey = validateStateKey(key);
234
+ const current = load();
235
+ return Object.hasOwn(current, validKey) ? current[validKey] : undefined;
236
+ },
237
+ set(key, value) {
238
+ const validKey = validateStateKey(key);
239
+ assertWorkflowJsonValue(value, `state.set('${validKey}') value`);
240
+ withStateFileLock(filePath, () => {
241
+ const next = Object.assign(Object.create(null) as Record<string, unknown>, readStateFile(), { [validKey]: value });
242
+ const bytes = Buffer.byteLength(JSON.stringify(next, null, 2));
243
+ if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state exceeds the 256 KiB limit (${bytes} bytes; maximum ${MISSION_STATE_MAX_BYTES} bytes).`);
244
+ writePrivateAtomicJson(filePath, next);
245
+ values = next;
246
+ loaded = true;
247
+ });
248
+ },
249
+ };
250
+ }
@@ -260,7 +260,7 @@ function qualityTierToRoleTier(quality: QualityTier, cost: CostTier): Recommende
260
260
 
261
261
  function agentsForRoleTier(roleTier: RecommendedRoleTier): BuiltinAgentName[] {
262
262
  if (roleTier === "cheap") return ["scout", "delegate"];
263
- if (roleTier === "medium") return ["planner", "context-builder", "researcher"];
263
+ if (roleTier === "medium") return ["researcher", "reviewer"];
264
264
  return ["worker", "reviewer", "oracle"];
265
265
  }
266
266
 
@@ -405,8 +405,6 @@ function buildProfileFile(kind: ProfileKind, models: { cheap: string; medium: st
405
405
  agentOverrides: {
406
406
  scout: { model: models.cheap },
407
407
  delegate: { model: models.cheap },
408
- planner: { model: models.medium },
409
- "context-builder": { model: models.medium },
410
408
  researcher: { model: models.medium },
411
409
  worker: { model: models.strong },
412
410
  reviewer: { model: models.strong },
@@ -11,6 +11,7 @@ import { fileURLToPath } from "node:url";
11
11
  import { createRequire } from "node:module";
12
12
  import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
13
13
  import type { AgentConfig } from "../../agents/agents.ts";
14
+ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts";
14
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
15
16
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
16
17
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
@@ -268,7 +269,8 @@ export function formatAsyncStartedMessage(headline: string, interactive: boolean
268
269
  ? [
269
270
  "The async run is detached and running in the background.",
270
271
  "You are in an interactive session. By default, return control to the user now; Pi will wake you on completion when the run finishes or needs attention. Do NOT call subagent_wait() merely to wait, and do not run sleep/polling loops to wait for it.",
271
- "Override that default and call subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
272
+ "When you need an explicit wake for one known run but do not need same-turn results, call subagent_wait({ id: \"...\", nonBlocking: true }) to arm a subscription and return immediately.",
273
+ "Override the default and call blocking subagent_wait() before ending the turn only when the current request is run-to-completion — for example, the user asked you to report results back here before continuing, or a skill must finish in one turn. In that case, call subagent_wait() to block until the run completes so its results are delivered in this turn instead of deferred.",
272
274
  "Otherwise, continue any independent work or return control to the user. Use subagent({ action: \"status\", id: \"...\" }) for a one-shot status/result or to inspect a blocked/stale run, never as a wait loop.",
273
275
  ]
274
276
  : [
@@ -693,6 +695,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
693
695
  if (memoryInjection) {
694
696
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
695
697
  }
698
+ systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: stepCwd, agentName: a.name });
696
699
 
697
700
  const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
698
701
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
@@ -1260,6 +1263,7 @@ export function executeAsyncSingle(
1260
1263
  if (memoryInjection) {
1261
1264
  systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
1262
1265
  }
1266
+ systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: runnerCwd, agentName: agentConfig.name });
1263
1267
 
1264
1268
  const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
1265
1269
  const nestedAddress = inheritedNestedRoute ? resolveNestedParentAddressFromEnv() : undefined;
@@ -1,7 +1,7 @@
1
1
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
2
2
  import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
- import { renderWidget, requestWidgetRender, widgetRenderKey } from "../../tui/render.ts";
4
+ import { renderWidget, widgetRenderKey } from "../../tui/render.ts";
5
5
  import { formatControlNoticeMessage } from "../shared/subagent-control.ts";
6
6
  import {
7
7
  type AsyncJobState,
@@ -34,7 +34,6 @@ const CONTROL_EVENT_READ_CHUNK_BYTES = 64 * 1024;
34
34
  const MAX_CONTROL_EVENT_LINE_BYTES = 1024 * 1024;
35
35
  const CONTROL_EVENT_SCAN_WINDOW_BYTES = 2 * 1024 * 1024;
36
36
  const MAX_RECENT_FLEET_JOBS = 20;
37
- const WIDGET_ANIMATION_REFRESH_MS = 500;
38
37
 
39
38
  function rememberFleetJob(state: SubagentState, job: AsyncJobState): void {
40
39
  state.fleetJobs ??= new Map();
@@ -57,14 +56,9 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
57
56
  const pollIntervalMs = options.pollIntervalMs ?? POLL_INTERVAL_MS;
58
57
  const resultsDir = options.resultsDir ?? DIRS.results;
59
58
  const steeringNoticeSeen = new Map<string, number>();
60
- let lastWidgetAnimationAt = 0;
61
- const requestStatusRender = (ctx: ExtensionContext) => {
62
- if (requestWidgetRender()) return;
63
- (ctx.ui as { requestRender?: () => void }).requestRender?.();
64
- };
65
59
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
66
60
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
67
- requestStatusRender(ctx);
61
+ (ctx.ui as { requestRender?: () => void }).requestRender?.();
68
62
  };
69
63
  const rerenderLastWidget = (jobs = Array.from(state.asyncJobs.values())) => {
70
64
  const ctx = state.lastUiContext;
@@ -80,14 +74,6 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
80
74
  }
81
75
  };
82
76
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
83
- const hasRunningWidgetJobs = () => options.widgetEnabled !== false && [...state.asyncJobs.values()].some((job) => job.status === "running");
84
- const refreshWidgetAnimation = () => {
85
- if (!hasRunningWidgetJobs()) return;
86
- const now = Date.now();
87
- if (now - lastWidgetAnimationAt < WIDGET_ANIMATION_REFRESH_MS) return;
88
- lastWidgetAnimationAt = now;
89
- requestWidgetRender();
90
- };
91
77
  const restoredControlEventCursor = (asyncDir: string) => {
92
78
  try {
93
79
  return fs.statSync(path.join(asyncDir, "events.jsonl")).size;
@@ -414,7 +400,6 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
414
400
  }
415
401
 
416
402
  if (widgetChanged) rerenderLastWidget();
417
- else refreshWidgetAnimation();
418
403
  }, pollIntervalMs);
419
404
  state.poller.unref?.();
420
405
  };
@@ -65,6 +65,7 @@ interface AsyncRunStepSummary {
65
65
  export interface AsyncRunSummary {
66
66
  id: string;
67
67
  asyncDir: string;
68
+ toolCallId?: string;
68
69
  sessionId?: string;
69
70
  state: "queued" | "running" | "complete" | "failed" | "paused" | "stopped" | "rejected";
70
71
  error?: string;
@@ -288,6 +289,7 @@ function statusToSummary(asyncDir: string, status: AsyncStatus & { cwd?: string
288
289
  return {
289
290
  id: status.runId || path.basename(asyncDir),
290
291
  asyncDir,
292
+ ...(status.toolCallId ? { toolCallId: status.toolCallId } : {}),
291
293
  ...(status.sessionId ? { sessionId: status.sessionId } : {}),
292
294
  state: status.state,
293
295
  ...(status.error ? { error: status.error } : {}),
@@ -60,11 +60,15 @@ export interface CheckpointDecisionRequest {
60
60
  reason?: string;
61
61
  }
62
62
 
63
+ export type SteerDeliveryMode = "steer" | "follow_up" | "auto";
64
+ export type SteerDeliveryStatus = "delivered" | "queued";
65
+
63
66
  export interface SteerRequest {
64
67
  type: "steer";
65
68
  id: string;
66
69
  ts: number;
67
70
  message: string;
71
+ mode?: SteerDeliveryMode;
68
72
  targetIndex?: number;
69
73
  targetIndexes?: number[];
70
74
  source?: string;
@@ -85,11 +89,14 @@ export interface SteerAck {
85
89
  requestId: string;
86
90
  index: number;
87
91
  ts: number;
88
- state: "delivered" | "failed";
92
+ state: "delivered" | "queued" | "failed";
93
+ deliveryStatus?: SteerDeliveryStatus;
89
94
  message: string;
90
95
  }
91
96
 
92
97
  const STEER_REQUESTS_DIR = "steer-requests";
98
+ const REVIVAL_BRIEFS_DIR = "revival-briefs";
99
+ export const MAX_STEER_QUEUE_SIZE = 20;
93
100
  const STEER_TARGETS_DIR = "steer-targets";
94
101
  const STEER_CAPABILITIES_DIR = "steer-capabilities";
95
102
  const STEER_ACKS_DIR = "steer-acks";
@@ -186,6 +193,7 @@ function validSteerRequest(request: Partial<SteerRequest>): request is SteerRequ
186
193
  && typeof request.message === "string"
187
194
  && Boolean(request.message.trim())
188
195
  && Buffer.byteLength(request.message, "utf8") <= MAX_STEER_MESSAGE_BYTES
196
+ && (request.mode === undefined || request.mode === "steer" || request.mode === "follow_up" || request.mode === "auto")
189
197
  && (request.targetIndex === undefined || (Number.isInteger(request.targetIndex) && request.targetIndex >= 0 && request.targetIndex <= 1_000_000))
190
198
  && (request.targetIndexes === undefined || (
191
199
  request.targetIndex === undefined
@@ -218,14 +226,26 @@ export function writeSteerCapability(asyncDir: string, capability: Omit<SteerCap
218
226
  return writeSteerCapabilityAt(steerCapabilityPath(asyncDir, capability.index), capability);
219
227
  }
220
228
 
229
+ function steerAckWritePath(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
230
+ const parsed = path.parse(filePath);
231
+ const stateOrder = ack.state === "queued" ? "0" : ack.state === "delivered" ? "1" : "2";
232
+ const timestamp = String(Math.trunc(ack.ts)).padStart(13, "0");
233
+ for (let suffix = 0; suffix < 1_000; suffix += 1) {
234
+ const candidate = path.join(parsed.dir, `${parsed.name}-${timestamp}-${stateOrder}-${ack.state}${suffix === 0 ? "" : `-${suffix}`}${parsed.ext}`);
235
+ if (!fs.existsSync(candidate)) return candidate;
236
+ }
237
+ throw new Error("steer acknowledgment queue is full.");
238
+ }
239
+
221
240
  export function writeSteerAckAt(filePath: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
222
241
  assertChildIndex(ack.index);
223
242
  if (!/^[^\s]+$/.test(ack.requestId) || ack.requestId.length > 256) throw new Error("steer acknowledgment requestId is invalid.");
224
243
  if (!Number.isFinite(ack.ts) || ack.ts <= 0) throw new Error("steer acknowledgment ts must be a finite timestamp.");
225
244
  if (!ack.message.trim() || ack.message.length > 1000) throw new Error("steer acknowledgment message is invalid.");
226
245
  const record: SteerAck = { type: "steer-ack", protocolVersion: 1, ...ack, message: ack.message.trim() };
227
- writeAtomicJson(filePath, record);
228
- return filePath;
246
+ const ackPath = steerAckWritePath(filePath, ack);
247
+ writeAtomicJson(ackPath, record);
248
+ return ackPath;
229
249
  }
230
250
 
231
251
  export function writeSteerAck(asyncDir: string, ack: Omit<SteerAck, "type" | "protocolVersion">): string {
@@ -283,7 +303,7 @@ export function requestAsyncCheckpointDecision(
283
303
 
284
304
  export function requestAsyncSteer(
285
305
  asyncDir: string,
286
- payload: { message: string; targetIndex?: number; targetIndexes?: number[]; source?: string; id?: string; ts?: number },
306
+ payload: { message: string; mode?: SteerDeliveryMode; targetIndex?: number; targetIndexes?: number[]; source?: string; id?: string; ts?: number },
287
307
  deps: { now?: () => number; randomId?: () => string } = {},
288
308
  ): string {
289
309
  const message = payload.message.trim();
@@ -309,6 +329,7 @@ export function requestAsyncSteer(
309
329
  id: payload.id ?? deps.randomId?.() ?? randomUUID(),
310
330
  ts: payload.ts ?? deps.now?.() ?? Date.now(),
311
331
  message,
332
+ ...(payload.mode && payload.mode !== "steer" ? { mode: payload.mode } : {}),
312
333
  ...(payload.targetIndex !== undefined ? { targetIndex: payload.targetIndex } : {}),
313
334
  ...(payload.targetIndexes !== undefined ? { targetIndexes: [...payload.targetIndexes] } : {}),
314
335
  ...(payload.source ? { source: payload.source } : {}),
@@ -343,9 +364,10 @@ function parseSteerAck(raw: unknown): SteerAck | undefined {
343
364
  if (input.type !== "steer-ack" || input.protocolVersion !== 1 || typeof input.requestId !== "string" || !/^[^\s]+$/.test(input.requestId) || input.requestId.length > 256) return undefined;
344
365
  const { index, ts, state, message } = input;
345
366
  if (typeof index !== "number" || typeof ts !== "number" || !Number.isInteger(index) || index < 0 || index > 1_000_000 || !Number.isFinite(ts) || ts <= 0) return undefined;
346
- if (state !== "delivered" && state !== "failed") return undefined;
367
+ if (state !== "delivered" && state !== "queued" && state !== "failed") return undefined;
368
+ if (input.deliveryStatus !== undefined && input.deliveryStatus !== "delivered" && input.deliveryStatus !== "queued") return undefined;
347
369
  if (typeof message !== "string" || !message.trim() || message.length > 1000) return undefined;
348
- return { type: "steer-ack", protocolVersion: 1, requestId: input.requestId, index, ts, state, message: message.trim() };
370
+ return { type: "steer-ack", protocolVersion: 1, requestId: input.requestId, index, ts, state, ...(input.deliveryStatus ? { deliveryStatus: input.deliveryStatus } : {}), message: message.trim() };
349
371
  }
350
372
 
351
373
  export function readSteerCapability(asyncDir: string, index: number): SteerCapability | undefined {
@@ -401,6 +423,7 @@ function parseSteerRequest(raw: unknown): SteerRequest | undefined {
401
423
  id: input.id.trim(),
402
424
  ts: input.ts,
403
425
  message: input.message.trim(),
426
+ ...(input.mode ? { mode: input.mode } : {}),
404
427
  ...(input.targetIndex !== undefined ? { targetIndex: input.targetIndex } : {}),
405
428
  ...(input.targetIndexes !== undefined ? { targetIndexes: [...input.targetIndexes] } : {}),
406
429
  ...(typeof input.source === "string" && input.source.trim() ? { source: input.source } : {}),
@@ -440,6 +463,27 @@ export function consumeSteerRequests(asyncDir: string, fsImpl: Pick<typeof fs, "
440
463
  return consumeSteerRequestsFromDir(steerRequestsDir(asyncDir), fsImpl);
441
464
  }
442
465
 
466
+ export function queueRevivalBrief(asyncDir: string, request: SteerRequest): string {
467
+ const dir = path.join(controlInboxDir(asyncDir), REVIVAL_BRIEFS_DIR);
468
+ const queued = fs.existsSync(dir) ? fs.readdirSync(dir).filter((entry) => entry.endsWith(".json")).length : 0;
469
+ if (queued >= MAX_STEER_QUEUE_SIZE) throw new Error(`Follow-up queue is full (${MAX_STEER_QUEUE_SIZE} messages).`);
470
+ return writeSteerRequestToDir(dir, { ...request, mode: "follow_up" });
471
+ }
472
+
473
+ export function readRevivalBriefs(asyncDir: string): Array<{ request: SteerRequest; path: string }> {
474
+ const dir = path.join(controlInboxDir(asyncDir), REVIVAL_BRIEFS_DIR);
475
+ if (!fs.existsSync(dir)) return [];
476
+ return fs.readdirSync(dir).filter((entry) => entry.endsWith(".json")).sort().flatMap((entry) => {
477
+ const filePath = path.join(dir, entry);
478
+ try {
479
+ const request = parseSteerRequest(JSON.parse(fs.readFileSync(filePath, "utf-8")));
480
+ return request ? [{ request, path: filePath }] : [];
481
+ } catch {
482
+ return [];
483
+ }
484
+ });
485
+ }
486
+
443
487
  /**
444
488
  * Runner side: consume a pending interrupt request. Idempotent — removes the file
445
489
  * so each distinct request fires exactly once. Returns whether one was pending.
@@ -0,0 +1,68 @@
1
+ import * as fs from "node:fs";
2
+ import { listAsyncRuns } from "./async-status.ts";
3
+ import type { TokenUsage } from "../../shared/types.ts";
4
+
5
+ const MAX_RETAINED_CHILDREN = 10;
6
+ const MAX_TASK_SUMMARY_LENGTH = 120;
7
+
8
+ export interface RetainedChild {
9
+ runId: string;
10
+ parentRunId?: string;
11
+ agent: string;
12
+ taskSummary: string;
13
+ completedAt: number;
14
+ sessionPath: string;
15
+ tokenTotals?: TokenUsage;
16
+ }
17
+
18
+ function retainedSessionFile(sessionFile: string | undefined): sessionFile is string {
19
+ if (!sessionFile?.endsWith(".jsonl")) return false;
20
+ try {
21
+ const stat = fs.lstatSync(sessionFile);
22
+ return stat.isFile() && !stat.isSymbolicLink();
23
+ } catch {
24
+ return false;
25
+ }
26
+ }
27
+
28
+ function boundedTaskSummary(value: string | undefined): string {
29
+ const normalized = value?.replace(/\s+/g, " ").trim() ?? "";
30
+ return normalized.length > MAX_TASK_SUMMARY_LENGTH
31
+ ? `${normalized.slice(0, MAX_TASK_SUMMARY_LENGTH - 1)}…`
32
+ : normalized;
33
+ }
34
+
35
+ export function listRetainedChildren(asyncDirRoot: string, sessionId: string): RetainedChild[] {
36
+ return listAsyncRuns(asyncDirRoot, { sessionId, states: ["complete"], reconcile: false })
37
+ .flatMap((run) => {
38
+ if (!run.parentWorkflowRunId || run.steps.length !== 1) return [];
39
+ const step = run.steps[0]!;
40
+ if ((step.status !== "complete" && step.status !== "completed") || !retainedSessionFile(step.sessionFile ?? run.sessionFile)) return [];
41
+ const completedAt = run.endedAt;
42
+ if (completedAt === undefined) return [];
43
+ return [{
44
+ runId: run.id,
45
+ ...(run.parentWorkflowRunId ? { parentRunId: run.parentWorkflowRunId } : {}),
46
+ agent: step.agent,
47
+ taskSummary: boundedTaskSummary(step.description),
48
+ completedAt,
49
+ sessionPath: step.sessionFile ?? run.sessionFile!,
50
+ ...(step.tokens ?? run.totalTokens ? { tokenTotals: step.tokens ?? run.totalTokens } : {}),
51
+ }];
52
+ })
53
+ .sort((left, right) => right.completedAt - left.completedAt)
54
+ .slice(0, MAX_RETAINED_CHILDREN);
55
+ }
56
+
57
+ export function formatRetainedChildren(children: RetainedChild[]): string {
58
+ if (children.length === 0) return "No completed retained children in the active parent session.";
59
+ return [
60
+ `Completed retained children (newest first, last ${MAX_RETAINED_CHILDREN}):`,
61
+ ...children.flatMap((child) => [
62
+ `- ${child.runId} | ${child.agent} | ${new Date(child.completedAt).toISOString()}`,
63
+ ` task: ${child.taskSummary || "(no task summary)"}`,
64
+ ` session: ${child.sessionPath}`,
65
+ ...(child.tokenTotals ? [` tokens: input ${child.tokenTotals.input}, output ${child.tokenTotals.output}, total ${child.tokenTotals.total}`] : []),
66
+ ]),
67
+ ].join("\n");
68
+ }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { DIRS, type SubagentState } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
4
5
  import { findAsyncRunPrefixMatches, type AsyncRunLocation } from "./async-resume.ts";
5
6
  import { assertSafeNestedId, findNestedRunMatchesById, type NestedRoute, type NestedRunMatch, type NestedRunResolutionScope } from "../shared/nested-events.ts";
6
7
 
@@ -27,6 +28,70 @@ function exactAsyncLocation(id: string, asyncDirRoot: string, resultsDir: string
27
28
  };
28
29
  }
29
30
 
31
+ type AsyncRunMatch = { id: string; location: AsyncRunLocation };
32
+
33
+ type WorkflowResultIdentity = {
34
+ id?: string;
35
+ runId?: string;
36
+ toolCallId?: string;
37
+ };
38
+
39
+ function readWorkflowResultIdentity(resultPath: string): WorkflowResultIdentity | undefined {
40
+ let parsed: unknown;
41
+ try {
42
+ parsed = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as unknown;
43
+ } catch {
44
+ return undefined;
45
+ }
46
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return undefined;
47
+ const record = parsed as Record<string, unknown>;
48
+ return {
49
+ ...(typeof record.id === "string" ? { id: record.id } : {}),
50
+ ...(typeof record.runId === "string" ? { runId: record.runId } : {}),
51
+ ...(typeof record.toolCallId === "string" ? { toolCallId: record.toolCallId } : {}),
52
+ };
53
+ }
54
+
55
+ function directoryEntries(root: string): string[] {
56
+ try {
57
+ return fs.readdirSync(root);
58
+ } catch (error) {
59
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
60
+ throw error;
61
+ }
62
+ }
63
+
64
+ function resultPathFor(resultsDir: string, runId: string): string | null {
65
+ const resultPath = path.join(resultsDir, `${runId}.json`);
66
+ return fs.existsSync(resultPath) ? resultPath : null;
67
+ }
68
+
69
+ function toolCallIdMatches(value: string | undefined, query: string, options: { prefix?: boolean }): boolean {
70
+ if (value === undefined) return false;
71
+ return options.prefix === true ? value.startsWith(query) : value === query;
72
+ }
73
+
74
+ function toolCallIdAsyncLocations(toolCallId: string, asyncDirRoot: string, resultsDir: string, options: { prefix?: boolean } = {}): AsyncRunMatch[] {
75
+ const byId = new Map<string, AsyncRunLocation>();
76
+ for (const entry of directoryEntries(asyncDirRoot)) {
77
+ const asyncDir = path.join(asyncDirRoot, entry);
78
+ const status = readStatus(asyncDir);
79
+ if (!status || !toolCallIdMatches(status.toolCallId, toolCallId, options)) continue;
80
+ const runId = status.runId || entry;
81
+ byId.set(runId, { asyncDir, resultPath: resultPathFor(resultsDir, runId), resolvedId: runId });
82
+ }
83
+ for (const entry of directoryEntries(resultsDir)) {
84
+ if (!entry.endsWith(".json")) continue;
85
+ const resultPath = path.join(resultsDir, entry);
86
+ const identity = readWorkflowResultIdentity(resultPath);
87
+ if (!identity || !toolCallIdMatches(identity.toolCallId, toolCallId, options)) continue;
88
+ const runId = identity.runId ?? identity.id ?? entry.slice(0, -".json".length);
89
+ const asyncDir = path.join(asyncDirRoot, runId);
90
+ byId.set(runId, { asyncDir: fs.existsSync(asyncDir) ? asyncDir : null, resultPath, resolvedId: runId });
91
+ }
92
+ return [...byId.entries()].map(([id, location]) => ({ id, location }));
93
+ }
94
+
30
95
  function foregroundIds(state: SubagentState | undefined): string[] {
31
96
  if (!state) return [];
32
97
  const remembered = state.currentSessionId
@@ -73,6 +138,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
73
138
  if (hasExactForegroundId(deps.state, id)) return { kind: "foreground", id };
74
139
  const exactAsync = exactAsyncLocation(id, asyncDirRoot, resultsDir);
75
140
  if (exactAsync) return { kind: "async", id, location: exactAsync };
141
+ const exactToolCallIdMatches = toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir);
142
+ if (exactToolCallIdMatches.length > 1) throw new Error(`Subagent tool-call id '${id}' is ambiguous across async runs. Use the returned asyncId instead.`);
143
+ if (exactToolCallIdMatches[0]) return { kind: "async", id: exactToolCallIdMatches[0].id, location: exactToolCallIdMatches[0].location };
76
144
  const exactNested = findNestedRunMatchesById(id, nestedScope ? { scope: nestedScope } : {});
77
145
  if (exactNested.length > 1) throw new Error(`Nested run id '${id}' is ambiguous across authorized registries. Provide the full id after stale registries are cleaned up.`);
78
146
  if (exactNested[0]) return { kind: "nested", id, match: exactNested[0] };
@@ -84,6 +152,9 @@ export function resolveSubagentRunId(id: string, deps: ResolveSubagentRunIdDeps
84
152
  for (const match of asyncPrefixMatches(id, asyncDirRoot, resultsDir)) {
85
153
  matches.push({ kind: "async", id: match.id, location: match.location });
86
154
  }
155
+ for (const match of toolCallIdAsyncLocations(id, asyncDirRoot, resultsDir, { prefix: true })) {
156
+ matches.push({ kind: "async", id: match.id, location: match.location });
157
+ }
87
158
  for (const match of findNestedRunMatchesById(id, nestedScope ? { prefix: true, scope: nestedScope } : { prefix: true })) {
88
159
  matches.push({ kind: "nested", id: match.run.id, match });
89
160
  }