pi-subagents 0.42.0 → 0.43.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 +35 -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 +27 -43
- 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/control-notices.ts +2 -2
- package/src/extension/fanout-child.ts +46 -22
- package/src/extension/index.ts +29 -12
- package/src/extension/public-execution.ts +71 -0
- package/src/extension/rpc.ts +7 -8
- package/src/extension/schemas.ts +17 -17
- 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 +77 -0
- package/src/profiles/profiles.ts +1 -3
- package/src/runs/background/async-execution.ts +3 -0
- package/src/runs/background/async-job-tracker.ts +2 -17
- package/src/runs/background/control-channel.ts +50 -6
- package/src/runs/background/retained-children.ts +68 -0
- package/src/runs/background/scheduled-runs.ts +17 -13
- package/src/runs/background/steering.ts +7 -5
- package/src/runs/background/subagent-runner.ts +10 -4
- package/src/runs/foreground/async-steering-action.ts +36 -10
- package/src/runs/foreground/chain-clarify.ts +13 -16
- package/src/runs/foreground/execution.ts +4 -0
- package/src/runs/foreground/subagent-executor.ts +200 -59
- package/src/runs/shared/acceptance.ts +154 -9
- package/src/runs/shared/subagent-prompt-runtime.ts +94 -25
- package/src/shared/types.ts +17 -3
- 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 +27 -12
- 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 +13 -31
- package/src/workflows/chat-progress.ts +2 -2
- package/src/workflows/scripted-workflow.ts +97 -10
- 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,77 @@
|
|
|
1
|
+
import * as fs from "node:fs";
|
|
2
|
+
import * as path from "node:path";
|
|
3
|
+
import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
|
|
4
|
+
import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
|
|
5
|
+
import type { MissionStoreLocation } from "./types.ts";
|
|
6
|
+
import { validateMissionId } from "./store.ts";
|
|
7
|
+
|
|
8
|
+
const STATE_KEY_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
9
|
+
export const MISSION_STATE_MAX_BYTES = 256 * 1024;
|
|
10
|
+
|
|
11
|
+
export interface MissionWorkflowState {
|
|
12
|
+
path: string;
|
|
13
|
+
get(key: string): unknown;
|
|
14
|
+
set(key: string, value: unknown): void;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function missionStatePath(location: MissionStoreLocation, missionId: string): string {
|
|
18
|
+
return path.join(location.missionDir, validateMissionId(missionId), "state.json");
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function validateStateKey(value: unknown): string {
|
|
22
|
+
if (typeof value !== "string" || !STATE_KEY_PATTERN.test(value)) {
|
|
23
|
+
throw new Error("state key must be 1-128 characters using letters, numbers, '.', '_' or '-', and start with a letter or number.");
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function createMissionWorkflowState(location: MissionStoreLocation, missionId: string): MissionWorkflowState {
|
|
29
|
+
const filePath = missionStatePath(location, missionId);
|
|
30
|
+
let loaded = false;
|
|
31
|
+
let values: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
|
|
32
|
+
|
|
33
|
+
const load = (): Record<string, unknown> => {
|
|
34
|
+
if (loaded) return values;
|
|
35
|
+
let raw: string;
|
|
36
|
+
try {
|
|
37
|
+
raw = fs.readFileSync(filePath, "utf-8");
|
|
38
|
+
} catch (error) {
|
|
39
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") {
|
|
40
|
+
loaded = true;
|
|
41
|
+
return values;
|
|
42
|
+
}
|
|
43
|
+
throw new Error(`Failed to read mission state '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
44
|
+
}
|
|
45
|
+
const bytes = Buffer.byteLength(raw);
|
|
46
|
+
if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state file '${filePath}' exceeds the 256 KiB limit (${bytes} bytes).`);
|
|
47
|
+
try {
|
|
48
|
+
const parsed: unknown = JSON.parse(raw);
|
|
49
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("root must be a JSON object");
|
|
50
|
+
assertWorkflowJsonValue(parsed, "mission state");
|
|
51
|
+
values = Object.assign(Object.create(null) as Record<string, unknown>, parsed);
|
|
52
|
+
loaded = true;
|
|
53
|
+
return values;
|
|
54
|
+
} catch (error) {
|
|
55
|
+
throw new Error(`Invalid mission state file '${filePath}': ${error instanceof Error ? error.message : String(error)}`);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
path: filePath,
|
|
61
|
+
get(key) {
|
|
62
|
+
const validKey = validateStateKey(key);
|
|
63
|
+
const current = load();
|
|
64
|
+
return Object.hasOwn(current, validKey) ? current[validKey] : undefined;
|
|
65
|
+
},
|
|
66
|
+
set(key, value) {
|
|
67
|
+
const validKey = validateStateKey(key);
|
|
68
|
+
assertWorkflowJsonValue(value, `state.set('${validKey}') value`);
|
|
69
|
+
const next = Object.assign(Object.create(null) as Record<string, unknown>, load(), { [validKey]: value });
|
|
70
|
+
const bytes = Buffer.byteLength(JSON.stringify(next, null, 2));
|
|
71
|
+
if (bytes > MISSION_STATE_MAX_BYTES) throw new Error(`Mission state exceeds the 256 KiB limit (${bytes} bytes; maximum ${MISSION_STATE_MAX_BYTES} bytes).`);
|
|
72
|
+
writePrivateAtomicJson(filePath, next);
|
|
73
|
+
values = next;
|
|
74
|
+
loaded = true;
|
|
75
|
+
},
|
|
76
|
+
};
|
|
77
|
+
}
|
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";
|
|
@@ -693,6 +694,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
|
|
|
693
694
|
if (memoryInjection) {
|
|
694
695
|
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
|
|
695
696
|
}
|
|
697
|
+
systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: stepCwd, agentName: a.name });
|
|
696
698
|
|
|
697
699
|
const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
|
|
698
700
|
const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
|
|
@@ -1260,6 +1262,7 @@ export function executeAsyncSingle(
|
|
|
1260
1262
|
if (memoryInjection) {
|
|
1261
1263
|
systemPrompt = systemPrompt ? `${systemPrompt}\n\n${memoryInjection}` : memoryInjection;
|
|
1262
1264
|
}
|
|
1265
|
+
systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: runnerCwd, agentName: agentConfig.name });
|
|
1263
1266
|
|
|
1264
1267
|
const inheritedNestedRoute = resolveInheritedNestedRouteFromEnv();
|
|
1265
1268
|
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
|
};
|
|
@@ -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
|
+
}
|
|
@@ -9,6 +9,7 @@ import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.t
|
|
|
9
9
|
import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
|
|
10
10
|
import { validateExecutionAcceptance } from "../shared/acceptance.ts";
|
|
11
11
|
import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
|
|
12
|
+
import { previewSimpleWorkflowRun } from "../../workflows/scripted-workflow.ts";
|
|
12
13
|
|
|
13
14
|
export const SCHEDULED_RUN_ACTIONS = [
|
|
14
15
|
"schedule.create",
|
|
@@ -34,7 +35,7 @@ export type ScheduleRunState = "running" | "skipped" | "missed" | "completed" |
|
|
|
34
35
|
export type ScheduleTrigger =
|
|
35
36
|
| { kind: "once"; at: string; nextRunAt?: string }
|
|
36
37
|
| { kind: "interval"; every: string; everyMs: number; anchorAt: string; nextRunAt: string };
|
|
37
|
-
export type ScheduleTarget = { workflowScript: string }
|
|
38
|
+
export type ScheduleTarget = { workflowScript: string };
|
|
38
39
|
|
|
39
40
|
export interface ScheduleRecord {
|
|
40
41
|
schemaVersion: 1;
|
|
@@ -188,6 +189,14 @@ function readJson(file: string, label: string): unknown {
|
|
|
188
189
|
}
|
|
189
190
|
}
|
|
190
191
|
|
|
192
|
+
function parseScheduleTarget(value: unknown, file: string): ScheduleTarget {
|
|
193
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Schedule record '${file}' has invalid trigger or target.`);
|
|
194
|
+
const target = value as { workflowScript?: unknown; agent?: unknown; task?: unknown };
|
|
195
|
+
if (typeof target.workflowScript === "string" && target.workflowScript.trim()) return { workflowScript: target.workflowScript.trim() };
|
|
196
|
+
if (target.agent !== undefined || target.task !== undefined) throw new Error(`Schedule record '${file}' uses a removed legacy agent target; recreate it with target.workflowScript.`);
|
|
197
|
+
throw new Error(`Schedule record '${file}' requires a workflowScript target.`);
|
|
198
|
+
}
|
|
199
|
+
|
|
191
200
|
function parseSchedule(value: unknown, file: string): ScheduleRecord {
|
|
192
201
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Schedule record '${file}' must be a JSON object.`);
|
|
193
202
|
const record = value as Partial<ScheduleRecord>;
|
|
@@ -200,10 +209,7 @@ function parseSchedule(value: unknown, file: string): ScheduleRecord {
|
|
|
200
209
|
} else if (record.trigger.kind === "interval") {
|
|
201
210
|
if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
|
|
202
211
|
} else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
|
|
203
|
-
|
|
204
|
-
const agent = "agent" in record.target && typeof record.target.agent === "string";
|
|
205
|
-
if (workflow === agent) throw new Error(`Schedule record '${file}' has an invalid target.`);
|
|
206
|
-
return record as ScheduleRecord;
|
|
212
|
+
return { ...record, target: parseScheduleTarget(record.target, file) } as ScheduleRecord;
|
|
207
213
|
}
|
|
208
214
|
|
|
209
215
|
class ScheduleStore {
|
|
@@ -304,27 +310,25 @@ function textResult(text: string, schedules?: ScheduleRecord[], runs?: ScheduleR
|
|
|
304
310
|
}
|
|
305
311
|
|
|
306
312
|
function targetLabel(target: ScheduleTarget): string {
|
|
307
|
-
|
|
313
|
+
const preview = previewSimpleWorkflowRun(target.workflowScript);
|
|
314
|
+
return preview?.agent ? `workflowScript -> agent ${preview.agent}` : "workflowScript (dynamic)";
|
|
308
315
|
}
|
|
309
316
|
|
|
310
317
|
function sanitizeTarget(params: SubagentParamsLike): { target?: ScheduleTarget; error?: string } {
|
|
311
|
-
if (params.tasks || params.chain) return { error: "Recurring schedules
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
if (hasWorkflow === hasAgent) return { error: "schedule.create requires exactly one target: workflowScript or agent with optional task." };
|
|
318
|
+
if (params.tasks || params.chain) return { error: "Recurring schedules require workflowScript; legacy tasks and chain inputs are unsupported." };
|
|
319
|
+
if (params.agent !== undefined || params.task !== undefined) return { error: "schedule.create requires workflowScript. Use workflowScript: \"return runs.run('main', { agent, task })\"." };
|
|
320
|
+
if (typeof params.workflowScript !== "string" || !params.workflowScript.trim()) return { error: "schedule.create requires a non-empty workflowScript." };
|
|
315
321
|
if (params.context === "fork") return { error: "Scheduled runs require fresh context." };
|
|
316
322
|
if (params.async === false) return { error: "Scheduled runs are always async." };
|
|
317
|
-
if (params.clarify === true) return { error: "Scheduled runs cannot open clarify UI." };
|
|
318
323
|
const acceptanceErrors = validateExecutionAcceptance(params as Parameters<typeof validateExecutionAcceptance>[0]);
|
|
319
324
|
if (acceptanceErrors.length) return { error: acceptanceErrors.join(" ") };
|
|
320
|
-
return
|
|
325
|
+
return { target: { workflowScript: params.workflowScript.trim() } };
|
|
321
326
|
}
|
|
322
327
|
|
|
323
328
|
function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
|
|
324
329
|
return {
|
|
325
330
|
...schedule.target,
|
|
326
331
|
async: true,
|
|
327
|
-
clarify: false,
|
|
328
332
|
context: "fresh",
|
|
329
333
|
cwd: schedule.cwd,
|
|
330
334
|
mission: false,
|
|
@@ -47,7 +47,7 @@ export function recordSteeringRequest(
|
|
|
47
47
|
|
|
48
48
|
function incrementStateCount(status: SteeringStatus, state: SteeringTargetState): void {
|
|
49
49
|
if (state === "scheduled") status.scheduled++;
|
|
50
|
-
else if (state === "routed") status.pending++;
|
|
50
|
+
else if (state === "routed" || state === "queued") status.pending++;
|
|
51
51
|
else if (state === "delivered" || state === "late") status.delivered++;
|
|
52
52
|
else if (state === "failed") status.failed++;
|
|
53
53
|
else if (state === "recovered") status.recovered++;
|
|
@@ -81,7 +81,7 @@ export function updateSteeringTarget(
|
|
|
81
81
|
if (fields.replacementRunId) target.replacementRunId = fields.replacementRunId;
|
|
82
82
|
return target;
|
|
83
83
|
}
|
|
84
|
-
if (target.state === "routed" && state !== "routed") status.pending = Math.max(0, status.pending - 1);
|
|
84
|
+
if ((target.state === "routed" || target.state === "queued") && state !== "routed" && state !== "queued") status.pending = Math.max(0, status.pending - 1);
|
|
85
85
|
target.state = state;
|
|
86
86
|
if (state === "routed") target.routedAt = now;
|
|
87
87
|
if (state === "delivered") {
|
|
@@ -123,16 +123,18 @@ export function actionResultFromSteeringStatus(status: SteeringStatus, sourceRun
|
|
|
123
123
|
else if (states.length > 0 && states.every((candidate) => candidate === "failed" || candidate === "late")) state = "failed";
|
|
124
124
|
else if (states.some((candidate) => candidate === "failed" || candidate === "late") && states.some((candidate) => candidate !== "failed" && candidate !== "late")) state = "partial";
|
|
125
125
|
const effectiveReplacementRunId = replacementRunId ?? request.targets.find((target) => target.replacementRunId)?.replacementRunId;
|
|
126
|
-
|
|
126
|
+
const deliveryStatus = states.length > 0 && states.every((candidate) => candidate === "delivered" || candidate === "recovered") ? "delivered" as const : "queued" as const;
|
|
127
|
+
return { requestId, state, deliveryStatus, sourceRunId, ...(effectiveReplacementRunId ? { replacementRunId: effectiveReplacementRunId } : {}), targets };
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
export function steeringActionIsTerminal(result: SteerActionResult | undefined): boolean {
|
|
130
|
-
return result?.
|
|
131
|
+
return (result?.targets.length ?? 0) > 0 && result?.targets.every((target) => target.state === "queued" || target.state === "delivered") === true
|
|
132
|
+
|| result?.state === "delivered" || result?.state === "scheduled" || result?.state === "partial" || result?.state === "recovered" || result?.state === "failed";
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
export function terminalSteeringNoticeState(status: SteeringStatus, requestId: string): "failed" | "partial" | undefined {
|
|
134
136
|
const request = status.recent.find((candidate) => candidate.id === requestId);
|
|
135
|
-
if (!request || request.targets.some((target) => target.state === "routed" || target.state === "scheduled")) return undefined;
|
|
137
|
+
if (!request || request.targets.some((target) => target.state === "routed" || target.state === "queued" || target.state === "scheduled")) return undefined;
|
|
136
138
|
const hasSuccess = request.targets.some((target) => target.state === "delivered" || target.state === "recovered");
|
|
137
139
|
const hasFailure = request.targets.some((target) => target.state === "failed" || target.state === "late");
|
|
138
140
|
if (hasSuccess && hasFailure) return "partial";
|
|
@@ -1635,6 +1635,8 @@ async function runSingleStep(
|
|
|
1635
1635
|
signal: combinedAbortSignal([ctx.timeoutSignal, ctx.stopSignal]),
|
|
1636
1636
|
abortMessage: ctx.stopSignal?.aborted ? ctx.stopMessage ?? "Subagent stopped by user." : ctx.timeoutMessage ?? "Subagent timed out.",
|
|
1637
1637
|
reportOptional: isAgentContractV1(step.agentContract),
|
|
1638
|
+
artifactsDir: ctx.artifactsDir,
|
|
1639
|
+
runId: ctx.id,
|
|
1638
1640
|
}))
|
|
1639
1641
|
: undefined;
|
|
1640
1642
|
const stoppedAfterAcceptance = finalResult?.stopped === true || ctx.stopSignal?.aborted === true;
|
|
@@ -2611,7 +2613,10 @@ async function runSubagent(
|
|
|
2611
2613
|
const now = Date.now();
|
|
2612
2614
|
if (ack.state === "delivered") {
|
|
2613
2615
|
updateSteeringLifecycleTarget(ack.requestId, ack.index, late ? "late" : "delivered", now, omitUndefinedProperties({ reason: late ? "acknowledged after recovery commit" : undefined }));
|
|
2614
|
-
emitSteeringEvent("subagent.steer.delivered", { type: "steer", id: ack.requestId, ts: now, message: ack.message }, ack.index, { late, message: ack.message });
|
|
2616
|
+
emitSteeringEvent("subagent.steer.delivered", { type: "steer", id: ack.requestId, ts: now, message: ack.message }, ack.index, { late, deliveryStatus: "delivered", message: ack.message });
|
|
2617
|
+
} else if (ack.state === "queued") {
|
|
2618
|
+
updateSteeringLifecycleTarget(ack.requestId, ack.index, "queued", now);
|
|
2619
|
+
emitSteeringEvent("subagent.steer.queued", { type: "steer", id: ack.requestId, ts: now, message: ack.message }, ack.index, { deliveryStatus: "queued", message: ack.message });
|
|
2615
2620
|
} else {
|
|
2616
2621
|
markSteeringAttention(ack.index);
|
|
2617
2622
|
updateSteeringLifecycleTarget(ack.requestId, ack.index, "failed", now, { reason: ack.message });
|
|
@@ -4370,11 +4375,12 @@ async function runSubagent(
|
|
|
4370
4375
|
for (const request of steeringLifecycle.recent) {
|
|
4371
4376
|
let changed = false;
|
|
4372
4377
|
for (const target of request.targets) {
|
|
4373
|
-
if (target.state !== "scheduled" && target.state !== "routed") continue;
|
|
4378
|
+
if (target.state !== "scheduled" && target.state !== "routed" && target.state !== "queued") continue;
|
|
4374
4379
|
changed = true;
|
|
4375
|
-
|
|
4380
|
+
const reason = target.state === "queued" ? "run ended before queued follow-up delivery" : "child terminated before steering delivery";
|
|
4381
|
+
updateSteeringLifecycleTarget(request.id, target.index, "failed", Date.now(), { reason });
|
|
4376
4382
|
markSteeringAttention(target.index);
|
|
4377
|
-
emitSteeringEvent("subagent.steer.failed", { type: "steer", id: request.id, ts: request.requestedAt, message:
|
|
4383
|
+
emitSteeringEvent("subagent.steer.failed", { type: "steer", id: request.id, ts: request.requestedAt, message: reason }, target.index, { reason });
|
|
4378
4384
|
}
|
|
4379
4385
|
if (changed) emitTerminalSteeringNotice(request.id, `Steering failed for run ${id}: child terminated before delivery.`);
|
|
4380
4386
|
}
|