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.
Files changed (55) hide show
  1. package/CHANGELOG.md +35 -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 +27 -43
  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/control-notices.ts +2 -2
  14. package/src/extension/fanout-child.ts +46 -22
  15. package/src/extension/index.ts +29 -12
  16. package/src/extension/public-execution.ts +71 -0
  17. package/src/extension/rpc.ts +7 -8
  18. package/src/extension/schemas.ts +17 -17
  19. package/src/extension/tool-description.ts +14 -14
  20. package/src/missions/actions.ts +47 -11
  21. package/src/missions/goal-driver.ts +162 -0
  22. package/src/missions/lifecycle.ts +44 -12
  23. package/src/missions/store.ts +68 -3
  24. package/src/missions/types.ts +25 -3
  25. package/src/missions/workflow-state.ts +77 -0
  26. package/src/profiles/profiles.ts +1 -3
  27. package/src/runs/background/async-execution.ts +3 -0
  28. package/src/runs/background/async-job-tracker.ts +2 -17
  29. package/src/runs/background/control-channel.ts +50 -6
  30. package/src/runs/background/retained-children.ts +68 -0
  31. package/src/runs/background/scheduled-runs.ts +17 -13
  32. package/src/runs/background/steering.ts +7 -5
  33. package/src/runs/background/subagent-runner.ts +10 -4
  34. package/src/runs/foreground/async-steering-action.ts +36 -10
  35. package/src/runs/foreground/chain-clarify.ts +13 -16
  36. package/src/runs/foreground/execution.ts +4 -0
  37. package/src/runs/foreground/subagent-executor.ts +200 -59
  38. package/src/runs/shared/acceptance.ts +154 -9
  39. package/src/runs/shared/subagent-prompt-runtime.ts +94 -25
  40. package/src/shared/types.ts +17 -3
  41. package/src/slash/delegation-adapters.ts +0 -13
  42. package/src/slash/prompt-template-bridge.ts +14 -12
  43. package/src/slash/prompt-workflows.ts +10 -4
  44. package/src/slash/slash-bridge.ts +8 -6
  45. package/src/slash/slash-commands.ts +27 -12
  46. package/src/slash/slash-live-state.ts +4 -2
  47. package/src/tui/fleet-status.ts +8 -4
  48. package/src/tui/fleet.ts +15 -5
  49. package/src/tui/render.ts +13 -31
  50. package/src/workflows/chat-progress.ts +2 -2
  51. package/src/workflows/scripted-workflow.ts +97 -10
  52. package/agents/context-builder.md +0 -46
  53. package/agents/planner.md +0 -56
  54. package/prompts/parallel-context-build.md +0 -55
  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
+ }
@@ -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";
@@ -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, 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
  };
@@ -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
+ }
@@ -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 } | { agent: string; task?: 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
- const workflow = "workflowScript" in record.target && typeof record.target.workflowScript === "string";
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
- return "workflowScript" in target ? "workflowScript" : `agent ${target.agent}`;
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 support workflowScript or one agent/task target, not legacy tasks or chain inputs." };
312
- const hasWorkflow = typeof params.workflowScript === "string" && params.workflowScript.trim().length > 0;
313
- const hasAgent = typeof params.agent === "string" && params.agent.trim().length > 0;
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 hasWorkflow ? { target: { workflowScript: params.workflowScript!.trim() } } : { target: { agent: params.agent!.trim(), ...(params.task === undefined ? {} : { task: params.task }) } };
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
- return { requestId, state, sourceRunId, ...(effectiveReplacementRunId ? { replacementRunId: effectiveReplacementRunId } : {}), targets };
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?.state === "delivered" || result?.state === "scheduled" || result?.state === "partial" || result?.state === "recovered" || result?.state === "failed";
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
- updateSteeringLifecycleTarget(request.id, target.index, "failed", Date.now(), { reason: "child terminated before steering delivery" });
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: "child terminated before steering delivery" }, target.index, { reason: "child terminated before steering delivery" });
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
  }