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.
- package/CHANGELOG.md +50 -0
- package/README.md +3 -5
- package/package.json +1 -1
- package/skills/pi-subagents/SKILL.md +2 -2
- package/skills/pi-subagents/references/constraints-and-recipes.md +35 -32
- package/skills/pi-subagents/references/execution-controls.md +30 -44
- package/skills/pi-subagents/references/management-authoring-rpc.md +20 -3
- package/skills/pi-subagents/references/prompting-and-roles.md +16 -49
- package/src/agents/agent-refinements.ts +624 -0
- package/src/agents/agents.ts +0 -2
- package/src/agents/proactive-skills.ts +1 -1
- package/src/api/delegation.ts +1 -2
- package/src/extension/config.ts +28 -7
- package/src/extension/control-notices.ts +2 -2
- package/src/extension/fanout-child.ts +46 -22
- package/src/extension/index.ts +32 -13
- package/src/extension/public-execution.ts +71 -0
- package/src/extension/rpc.ts +7 -8
- package/src/extension/schemas.ts +14 -14
- package/src/extension/tool-description.ts +14 -14
- package/src/missions/actions.ts +47 -11
- package/src/missions/goal-driver.ts +162 -0
- package/src/missions/lifecycle.ts +44 -12
- package/src/missions/store.ts +68 -3
- package/src/missions/types.ts +25 -3
- package/src/missions/workflow-state.ts +250 -0
- package/src/profiles/profiles.ts +1 -3
- package/src/runs/background/async-execution.ts +5 -1
- package/src/runs/background/async-job-tracker.ts +2 -17
- package/src/runs/background/async-status.ts +2 -0
- package/src/runs/background/control-channel.ts +50 -6
- package/src/runs/background/retained-children.ts +68 -0
- package/src/runs/background/run-id-resolver.ts +71 -0
- package/src/runs/background/run-status.ts +3 -2
- package/src/runs/background/scheduled-runs.ts +17 -13
- package/src/runs/background/steering.ts +7 -5
- package/src/runs/background/subagent-runner.ts +50 -23
- package/src/runs/foreground/async-steering-action.ts +36 -10
- package/src/runs/foreground/chain-clarify.ts +7 -3
- package/src/runs/foreground/execution.ts +29 -19
- package/src/runs/foreground/subagent-executor.ts +282 -77
- package/src/runs/shared/acceptance.ts +154 -9
- package/src/runs/shared/subagent-prompt-runtime.ts +97 -25
- package/src/shared/settings.ts +10 -6
- package/src/shared/types.ts +22 -2
- package/src/slash/delegation-adapters.ts +0 -13
- package/src/slash/prompt-template-bridge.ts +14 -12
- package/src/slash/prompt-workflows.ts +10 -4
- package/src/slash/slash-bridge.ts +8 -6
- package/src/slash/slash-commands.ts +24 -9
- package/src/slash/slash-live-state.ts +4 -2
- package/src/tui/fleet-status.ts +8 -4
- package/src/tui/fleet.ts +15 -5
- package/src/tui/render.ts +10 -29
- package/src/workflows/scripted-workflow.ts +91 -6
- package/agents/context-builder.md +0 -46
- package/agents/planner.md +0 -56
- package/prompts/parallel-context-build.md +0 -55
- 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
|
+
}
|
package/src/profiles/profiles.ts
CHANGED
|
@@ -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 ["
|
|
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
|
-
"
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
}
|