pi-subagents 0.46.0 → 0.47.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 (50) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +14 -6
  4. package/docs/extension-api.md +1 -1
  5. package/docs/missions.md +5 -3
  6. package/docs/models.md +3 -1
  7. package/docs/observability.md +3 -3
  8. package/docs/tool-reference.md +2 -2
  9. package/docs/watchdog.md +1 -1
  10. package/package.json +1 -1
  11. package/skills/pi-subagents/references/execution-controls.md +4 -4
  12. package/skills/pi-subagents/references/management-authoring-rpc.md +1 -1
  13. package/src/extension/config.ts +3 -0
  14. package/src/extension/fanout-child.ts +5 -4
  15. package/src/extension/index.ts +30 -3
  16. package/src/extension/rpc.ts +3 -6
  17. package/src/extension/schemas.ts +25 -5
  18. package/src/extension/tool-description.ts +26 -8
  19. package/src/inspectors/herdr/project-panes.ts +2 -1
  20. package/src/missions/store.ts +2 -1
  21. package/src/missions/workflow-state.ts +19 -13
  22. package/src/runs/background/async-execution.ts +10 -5
  23. package/src/runs/background/async-job-tracker.ts +15 -0
  24. package/src/runs/background/async-resume.ts +19 -3
  25. package/src/runs/background/async-status.ts +6 -1
  26. package/src/runs/background/control-channel.ts +36 -0
  27. package/src/runs/background/result-watcher.ts +16 -2
  28. package/src/runs/background/scheduled-runs.ts +2 -1
  29. package/src/runs/background/stale-run-reconciler.ts +2 -21
  30. package/src/runs/background/subagent-runner.ts +47 -6
  31. package/src/runs/foreground/async-steering-action.ts +1 -1
  32. package/src/runs/foreground/chain-execution.ts +3 -0
  33. package/src/runs/foreground/execution.ts +3 -0
  34. package/src/runs/foreground/subagent-executor.ts +95 -12
  35. package/src/runs/foreground/workflow-foreground-steering.ts +187 -0
  36. package/src/runs/shared/dynamic-fanout.ts +1 -1
  37. package/src/runs/shared/model-fallback.ts +8 -4
  38. package/src/runs/shared/model-scope.ts +12 -2
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/worktree.ts +3 -2
  41. package/src/shared/artifacts.ts +14 -14
  42. package/src/shared/display-text.ts +100 -0
  43. package/src/shared/formatters.ts +4 -6
  44. package/src/shared/settings.ts +15 -2
  45. package/src/shared/types.ts +11 -1
  46. package/src/shared/utils.ts +43 -33
  47. package/src/slash/slash-commands.ts +3 -1
  48. package/src/tui/fleet-status.ts +14 -10
  49. package/src/tui/render.ts +30 -26
  50. package/src/watchdog/change-signature.ts +4 -3
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
3
3
  import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { writePrivateAtomicJson } from "../shared/atomic-json.ts";
6
- import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
6
+ import { DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS, isRetryableFileSystemError, waitForFileSystemRetry } from "../shared/file-system-retry.ts";
7
7
  import { assertWorkflowJsonValue } from "../workflows/scripted-workflow.ts";
8
8
  import type { MissionStoreLocation } from "./types.ts";
9
9
  import { validateMissionId } from "./store.ts";
@@ -162,24 +162,30 @@ function withStateFileLock<T>(filePath: string, operation: () => T): T {
162
162
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
163
163
  continue;
164
164
  }
165
+ let acquired = false;
165
166
  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;
167
+ acquired = tryMakeDirectory(lockPath, 0o700);
176
168
  } 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)}`);
169
+ if (isRetryableFileSystemError(error)) {
170
+ waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
171
+ continue;
179
172
  }
173
+ throw new Error(`Failed to acquire mission state lock '${lockPath}': ${error instanceof Error ? error.message : String(error)}`);
174
+ }
175
+ if (!acquired) {
180
176
  if (reclaimStaleStateLock(lockPath, reclaimPath)) continue;
181
177
  waitForStateLock(DEFAULT_FILE_SYSTEM_RETRY_DELAYS_MS[attempt], lockPath);
178
+ continue;
179
+ }
180
+ owner = { pid: process.pid, token: randomUUID(), createdAt: Date.now(), ...(CURRENT_PROCESS_KEY ? { processKey: CURRENT_PROCESS_KEY } : {}) };
181
+ try {
182
+ fs.writeFileSync(path.join(lockPath, "owner.json"), JSON.stringify(owner), { encoding: "utf-8", mode: 0o600 });
183
+ } catch (error) {
184
+ fs.rmSync(lockPath, { recursive: true, force: true });
185
+ owner = undefined;
186
+ throw error;
182
187
  }
188
+ break;
183
189
  }
184
190
  try {
185
191
  return operation();
@@ -15,7 +15,7 @@ import { appendAgentRefinementOverlay } from "../../agents/agent-refinements.ts"
15
15
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
16
16
  import { applyThinkingSuffix, projectLaunchResolvedChildExtensions, resolvePiLaunchToolPlan } from "../shared/pi-args.ts";
17
17
  import { injectOutputPathSystemPrompt, injectSingleOutputInstruction, normalizeSingleOutputOverride, resolveSingleOutputPath, validateFileOnlyOutputMode } from "../shared/single-output.ts";
18
- import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
18
+ import { buildChainInstructions, isCheckpointStep, isDynamicParallelStep, isParallelStep, resolveChainPath, resolveExistingReadPaths, resolveStepBehavior, suppressProgressForReadOnlyTask, writeInitialProgressFile, type ChainStep, type ResolvedStepBehavior, type SequentialStep, type StepOverrides } from "../../shared/settings.ts";
19
19
  import type { RunnerStep } from "../shared/parallel-utils.ts";
20
20
  import type { ContextMode } from "../shared/context-mode.ts";
21
21
  import { resolvePiPackageRoot } from "../shared/pi-spawn.ts";
@@ -128,6 +128,8 @@ interface AsyncExecutionContext {
128
128
  interactive?: boolean;
129
129
  }
130
130
 
131
+ export const DEFAULT_ASYNC_TIMEOUT_MS = 30 * 60 * 1000;
132
+
131
133
  interface AsyncChainParams {
132
134
  chain: ChainStep[];
133
135
  task?: string;
@@ -668,6 +670,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
668
670
  if (resolvedToolBudget.error) throw new AsyncStartValidationError(resolvedToolBudget.error);
669
671
  const stepCwd = resolveChildCwd(runnerCwd, s.cwd);
670
672
  const instructionCwd = behaviorCwd ?? stepCwd;
673
+ const readExistenceCwd = behaviorCwd ? stepCwd : instructionCwd;
671
674
  let behavior = suppressProgressForReadOnlyTask(resolvedBehavior ?? resolveStepBehavior(a, buildStepOverrides(s), chainSkills), s.task, originalTask);
672
675
  const inheritedRelativeParallelOutput = parallelOutputNamespace && s.output === undefined && typeof behavior.output === "string" && !path.isAbsolute(behavior.output);
673
676
  if (inheritedRelativeParallelOutput && parallelOutputNamespace.taskIndex !== undefined) {
@@ -698,7 +701,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
698
701
  }
699
702
  systemPrompt = appendAgentRefinementOverlay(systemPrompt, { cwd: stepCwd, agentName: a.name });
700
703
 
701
- const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false);
704
+ const readInstructions = buildChainInstructions({ ...behavior, output: false, progress: false }, instructionCwd, false, undefined, readExistenceCwd);
702
705
  const isFirstProgressAgent = behavior.progress && !progressPrecreated && !progressInstructionCreated;
703
706
  if (behavior.progress) progressInstructionCreated = true;
704
707
  const progressInstructions = buildChainInstructions({ ...behavior, output: false, reads: false }, progressDir, isFirstProgressAgent);
@@ -776,6 +779,7 @@ export function buildAsyncRunnerSteps(id: string, params: AsyncRunnerStepBuildPa
776
779
  outputMode: behavior.outputMode,
777
780
  sessionFile,
778
781
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, a.maxSubagentDepth),
782
+ timeoutMs: a.defaultTimeoutMs ?? DEFAULT_ASYNC_TIMEOUT_MS,
779
783
  waitToolEnabled: params.waitToolEnabled,
780
784
  effectiveAcceptance: resolveEffectiveAcceptance({
781
785
  explicit: s.acceptance,
@@ -1296,8 +1300,9 @@ export function executeAsyncSingle(
1296
1300
  // Reads: caller override > agent defaultReads > none. `~`/`~/` expand to home;
1297
1301
  // absolute paths pass through; relative paths resolve against the child cwd.
1298
1302
  const reads = params.reads !== undefined ? params.reads : agentConfig.defaultReads ?? false;
1299
- const readsInstruction = Array.isArray(reads) && reads.length > 0
1300
- ? `[Read from: ${reads.map((f) => resolveChainPath(f, runnerCwd)).join(", ")}]\n\n`
1303
+ const readPaths = Array.isArray(reads) ? resolveExistingReadPaths(reads, runnerCwd) : [];
1304
+ const readsInstruction = readPaths.length > 0
1305
+ ? `[Read from: ${readPaths.join(", ")}]\n\n`
1301
1306
  : "";
1302
1307
  const taskText = readsInstruction + taskWithOutputInstruction;
1303
1308
  const primaryModel = externalRunner ? undefined : resolveSubagentModelOverride(
@@ -1399,7 +1404,7 @@ export function executeAsyncSingle(
1399
1404
  ...(params.acceptance !== undefined ? { acceptance: params.acceptance } : {}),
1400
1405
  ...(controlConfig ? { controlConfig } : {}),
1401
1406
  ...(deadlineAt !== undefined ? { absoluteDeadlineAt: deadlineAt } : {}),
1402
- ...(initialTurnBudget ? { initialTurnBudget } : {}),
1407
+ ...(initialTurnBudget ? { initialTurnBudget: { maxTurns: initialTurnBudget.maxTurns, graceTurns: initialTurnBudget.graceTurns } } : {}),
1403
1408
  ...(resolvedToolBudget.budget ? { initialToolBudget: resolvedToolBudget.budget } : {}),
1404
1409
  maxSubagentDepth: resolveChildMaxSubagentDepth(maxSubagentDepth, agentConfig.maxSubagentDepth),
1405
1410
  ...(maxOutput ? { maxOutput } : {}),
@@ -57,6 +57,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
57
57
  const resultsDir = options.resultsDir ?? DIRS.results;
58
58
  const steeringNoticeSeen = new Map<string, number>();
59
59
  const rerenderWidget = (ctx: ExtensionContext, jobs = Array.from(state.asyncJobs.values())) => {
60
+ if (state.widgetsSuspended) return;
60
61
  renderWidget(ctx, options.widgetEnabled === false ? [] : jobs);
61
62
  (ctx.ui as { requestRender?: () => void }).requestRender?.();
62
63
  };
@@ -73,6 +74,19 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
73
74
  throw error;
74
75
  }
75
76
  };
77
+ const requestLastWidgetRender = () => {
78
+ const ctx = state.lastUiContext;
79
+ if (!ctx || state.widgetsSuspended || options.widgetEnabled === false) return;
80
+ try {
81
+ if (ctx.hasUI) (ctx.ui as { requestRender?: () => void }).requestRender?.();
82
+ } catch (error) {
83
+ if (error instanceof Error && error.message.includes("extension ctx is stale")) {
84
+ state.lastUiContext = null;
85
+ return;
86
+ }
87
+ throw error;
88
+ }
89
+ };
76
90
  const refreshWidget = (ctx: ExtensionContext) => rerenderWidget(ctx);
77
91
  const restoredControlEventCursor = (asyncDir: string) => {
78
92
  try {
@@ -400,6 +414,7 @@ export function createAsyncJobTracker(pi: Pick<ExtensionAPI, "events">, state: S
400
414
  }
401
415
 
402
416
  if (widgetChanged) rerenderLastWidget();
417
+ else if (Array.from(state.asyncJobs.values()).some((job) => job.status === "running")) requestLastWidgetRender();
403
418
  }, pollIntervalMs);
404
419
  state.poller.unref?.();
405
420
  };
@@ -1,6 +1,6 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
- import { DIRS, type AcceptanceInput, type AsyncStatus, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
3
+ import { DIRS, type AcceptanceInput, type AsyncStatus, type ResolvedTurnBudget, type SteeringRecoveryDescriptor } from "../../shared/types.ts";
4
4
  import type { AgentConfig } from "../../agents/agents.ts";
5
5
  import { validateAcceptanceInput } from "../shared/acceptance.ts";
6
6
  import { validateToolBudgetConfig } from "../shared/tool-budget.ts";
@@ -273,6 +273,23 @@ function normalizeRecoveryAcceptance(value: unknown, descriptorPath: string): Ac
273
273
  return value as AcceptanceInput;
274
274
  }
275
275
 
276
+ function normalizeRecoveryTurnBudget(value: unknown, descriptorPath: string): ResolvedTurnBudget {
277
+ if (value && typeof value === "object" && !Array.isArray(value)) {
278
+ const {
279
+ outcome: _outcome,
280
+ turnCount: _turnCount,
281
+ wrapUpRequestedAtTurn: _wrapUpRequestedAtTurn,
282
+ terminationDeferredAtTurn: _terminationDeferredAtTurn,
283
+ exceededAtTurn: _exceededAtTurn,
284
+ ...publicTurnBudget
285
+ } = value as Record<string, unknown>;
286
+ value = publicTurnBudget;
287
+ }
288
+ const result = resolveTurnBudgetConfig(value, "recoveryDescriptor.initialTurnBudget");
289
+ if (result.error || !result.turnBudget) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error ?? "recoveryDescriptor.initialTurnBudget is invalid."}`);
290
+ return result.turnBudget;
291
+ }
292
+
276
293
  export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): SteeringRecoveryDescriptor | undefined {
277
294
  if (!asyncDir) return undefined;
278
295
  const descriptorPath = path.join(asyncDir, "recovery-descriptor.json");
@@ -329,8 +346,7 @@ export function readAsyncRecoveryDescriptor(asyncDir: string | undefined): Steer
329
346
  }
330
347
  if (parsed.absoluteDeadlineAt !== undefined && (!Number.isFinite(parsed.absoluteDeadlineAt) || (parsed.absoluteDeadlineAt as number) <= 0)) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': absoluteDeadlineAt must be a positive timestamp.`);
331
348
  if (parsed.initialTurnBudget !== undefined) {
332
- const result = resolveTurnBudgetConfig(parsed.initialTurnBudget, "recoveryDescriptor.initialTurnBudget");
333
- if (result.error) throw new Error(`Invalid async recovery descriptor '${descriptorPath}': ${result.error}`);
349
+ parsed.initialTurnBudget = normalizeRecoveryTurnBudget(parsed.initialTurnBudget, descriptorPath);
334
350
  }
335
351
  if (parsed.initialToolBudget !== undefined) {
336
352
  const result = validateToolBudgetConfig(parsed.initialToolBudget, "recoveryDescriptor.initialToolBudget");
@@ -4,7 +4,7 @@ import { formatDuration, formatModelThinking, formatTokens, shortenPath } from "
4
4
  import { formatActivityLabel, formatParallelOutcome } from "../../shared/status-format.ts";
5
5
  import { type ActivityState, type AsyncJobStep, type AsyncParallelGroupStatus, type AsyncStatus, type CostSummary, type Details, type LaunchResolvedChildExtensionsV1, type RuntimeAcknowledgedChildExtensionsV1, type NestedRunSummary, type SteeringStatus, type SubagentRunMode, type TokenUsage, type TurnBudgetState, type UsageBudgetState, type ChainCheckpointState } from "../../shared/types.ts";
6
6
  import type { ResolvedSubagentCapabilityCeiling, SubagentCapabilityAudit } from "../shared/capability-ceiling.ts";
7
- import { readStatus } from "../../shared/utils.ts";
7
+ import { pruneStatusCacheForAsyncRoot, readStatus } from "../../shared/utils.ts";
8
8
  import { attachRootChildrenToSteps, buildNestedRouteIndex, type NestedRoute, projectNestedEvents } from "../shared/nested-events.ts";
9
9
  import { formatNestedRunStatusLines } from "../shared/nested-render.ts";
10
10
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
@@ -370,6 +370,7 @@ function sortRuns(runs: AsyncRunSummary[]): AsyncRunSummary[] {
370
370
 
371
371
  export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions = {}): AsyncRunSummary[] {
372
372
  let entries: string[];
373
+ let scannedCompleteRoot = false;
373
374
  try {
374
375
  if (options.runId !== undefined) {
375
376
  const resolution = resolveTargetedAsyncRun(asyncDirRoot, options.runId, options.sessionId);
@@ -383,6 +384,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
383
384
  : [];
384
385
  } else {
385
386
  entries = fs.readdirSync(asyncDirRoot).filter((entry) => isAsyncRunDir(asyncDirRoot, entry));
387
+ scannedCompleteRoot = true;
386
388
  }
387
389
  } catch (error) {
388
390
  if (isNotFoundError(error)) return [];
@@ -392,6 +394,7 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
392
394
  }
393
395
 
394
396
  if (options.entryLimit !== undefined) {
397
+ scannedCompleteRoot = false;
395
398
  const limit = Math.max(0, Math.floor(options.entryLimit));
396
399
  entries = entries
397
400
  .map((entry) => {
@@ -410,6 +413,8 @@ export function listAsyncRuns(asyncDirRoot: string, options: AsyncRunListOptions
410
413
  .map((candidate) => candidate.entry);
411
414
  }
412
415
 
416
+ if (scannedCompleteRoot) pruneStatusCacheForAsyncRoot(asyncDirRoot, entries);
417
+
413
418
  const allowedStates = options.states ? new Set(options.states) : undefined;
414
419
  const runs: AsyncRunSummary[] = [];
415
420
  // Route resolution for every run shares a single index built from the
@@ -21,6 +21,16 @@ import { POLL_INTERVAL_MS } from "../../shared/types.ts";
21
21
  import { resolveWatchPath } from "../../shared/utils.ts";
22
22
 
23
23
  export type ControlChannelFs = Pick<typeof fs, "mkdirSync" | "existsSync" | "rmSync" | "watch" | "readdirSync" | "readFileSync" | "realpathSync">;
24
+
25
+ function writeJsonToExistingDir(filePath: string, payload: object): void {
26
+ const tempPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`);
27
+ try {
28
+ fs.writeFileSync(tempPath, JSON.stringify(payload, null, 2), { encoding: "utf-8", flag: "wx" });
29
+ fs.renameSync(tempPath, filePath);
30
+ } finally {
31
+ fs.rmSync(tempPath, { force: true });
32
+ }
33
+ }
24
34
  export type ControlChannelTimers = { setInterval: typeof setInterval; clearInterval: typeof clearInterval };
25
35
  type KillFn = (pid: number, signal?: NodeJS.Signals | 0) => unknown;
26
36
 
@@ -205,6 +215,13 @@ export function writeSteerRequestToDir(dir: string, request: SteerRequest): stri
205
215
  return requestPath;
206
216
  }
207
217
 
218
+ export function writeSteerRequestToExistingDir(dir: string, request: SteerRequest): string {
219
+ if (!validSteerRequest(request)) throw new Error("steer request is malformed or exceeds transport limits.");
220
+ const requestPath = path.join(dir, steerRequestFileName(request));
221
+ writeJsonToExistingDir(requestPath, request);
222
+ return requestPath;
223
+ }
224
+
208
225
  export function writeSteerCapabilityAt(filePath: string, capability: Omit<SteerCapability, "type" | "protocolVersion">): string {
209
226
  assertChildIndex(capability.index);
210
227
  if (!Number.isInteger(capability.pid) || capability.pid <= 0) throw new Error("steer capability pid must be a positive integer.");
@@ -385,6 +402,25 @@ export function consumeSteerCapabilities(asyncDir: string, fsImpl: Pick<typeof f
385
402
  return capabilities;
386
403
  }
387
404
 
405
+ export function consumeSteerAckFromDir(
406
+ dir: string,
407
+ requestId: string,
408
+ fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs,
409
+ ): SteerAck | undefined {
410
+ if (!fsImpl.existsSync(dir)) return undefined;
411
+ let entries: string[];
412
+ try { entries = fsImpl.readdirSync(dir).filter((name) => name.endsWith(".json")).sort(); } catch { return undefined; }
413
+ for (const entry of entries) {
414
+ const target = path.join(dir, entry);
415
+ let ack: SteerAck | undefined;
416
+ try { ack = parseSteerAck(JSON.parse(fsImpl.readFileSync(target, "utf-8"))); } catch { ack = undefined; }
417
+ if (ack?.requestId !== requestId) continue;
418
+ try { fsImpl.rmSync(target, { force: true }); } catch { return undefined; }
419
+ return ack;
420
+ }
421
+ return undefined;
422
+ }
423
+
388
424
  export function consumeSteerAcks(asyncDir: string, fsImpl: Pick<typeof fs, "existsSync" | "readdirSync" | "readFileSync" | "rmSync"> = fs): SteerAck[] {
389
425
  const root = path.join(controlInboxDir(asyncDir), STEER_ACKS_DIR);
390
426
  if (!fsImpl.existsSync(root)) return [];
@@ -126,6 +126,7 @@ export function createResultWatcher(
126
126
  const processing = new Set<string>();
127
127
  let deliveryActive = true;
128
128
  let deliveryEpoch = 0;
129
+ let resultScanTimer: ReturnType<typeof setInterval> | null = null;
129
130
  // The sole in-memory ownership lease. It is acquired for one active session
130
131
  // and revoked before the watcher, queues, or callbacks are torn down.
131
132
  let activeSessionId: string | null = null;
@@ -343,9 +344,15 @@ export function createResultWatcher(
343
344
  }
344
345
  };
345
346
 
347
+ const clearResultScan = () => {
348
+ if (resultScanTimer) timers.clearInterval(resultScanTimer);
349
+ resultScanTimer = null;
350
+ };
351
+
346
352
  const startPolling = (reason: unknown) => {
347
353
  state.watcher?.close();
348
354
  state.watcher = null;
355
+ clearResultScan();
349
356
  if (state.watcherRestartTimer) return;
350
357
  console.error(`Subagent result watcher for '${resultsDir}' fell back to polling because native fs.watch is unavailable (${errorCode(reason) ?? "unknown error"}).`);
351
358
  primeExistingResults();
@@ -354,6 +361,7 @@ export function createResultWatcher(
354
361
  };
355
362
 
356
363
  const scheduleRestart = () => {
364
+ clearResultScan();
357
365
  if (state.watcherRestartTimer) return;
358
366
  state.watcherRestartTimer = timers.setTimeout(() => {
359
367
  state.watcherRestartTimer = null;
@@ -381,8 +389,11 @@ export function createResultWatcher(
381
389
  }
382
390
  try {
383
391
  const watchDir = resolveWatchPath(resultsDir, fsApi.realpathSync.native);
384
- state.watcher = fsApi.watch(watchDir, (event, file) => {
385
- if (event !== "rename" || !file) return;
392
+ state.watcher = fsApi.watch(watchDir, (_event, file) => {
393
+ if (!file) {
394
+ primeExistingResults();
395
+ return;
396
+ }
386
397
  const fileName = file.toString();
387
398
  if (fileName.endsWith(".json")) scheduleResult(fileName, true);
388
399
  });
@@ -394,6 +405,8 @@ export function createResultWatcher(
394
405
  scheduleRestart();
395
406
  });
396
407
  state.watcher.unref?.();
408
+ resultScanTimer = timers.setInterval(primeExistingResults, POLL_INTERVAL_MS);
409
+ resultScanTimer.unref?.();
397
410
  } catch (error) {
398
411
  if (shouldPoll(error)) return startPolling(error);
399
412
  console.error(`Failed to start subagent result watcher for '${resultsDir}':`, error);
@@ -413,6 +426,7 @@ export function createResultWatcher(
413
426
  timers.clearInterval(state.watcherRestartTimer);
414
427
  }
415
428
  state.watcherRestartTimer = null;
429
+ clearResultScan();
416
430
  state.resultFileCoalescer.clear();
417
431
  pendingTriggerTurn.clear();
418
432
  processing.clear();
@@ -3,6 +3,7 @@ import * as fs from "node:fs";
3
3
  import * as path from "node:path";
4
4
  import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
5
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { getProjectSubagentsDir } from "../../shared/artifacts.ts";
6
7
  import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
7
8
  import { shortenPath } from "../../shared/formatters.ts";
8
9
  import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
@@ -87,7 +88,7 @@ export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
87
88
  }
88
89
 
89
90
  export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
90
- if (!root) return path.join(path.resolve(cwd), ".pi-subagents", "schedules");
91
+ if (!root) return path.join(getProjectSubagentsDir(path.resolve(cwd)), "schedules");
91
92
  const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
92
93
  return path.join(root, projectKey);
93
94
  }
@@ -1,6 +1,7 @@
1
1
  import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { writeAtomicJson } from "../../shared/atomic-json.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
4
5
  import { DIRS, type AsyncParallelGroupStatus, type AsyncStatus, type NestedRunSummary, type SubagentRunMode } from "../../shared/types.ts";
5
6
  import { resolveEffectiveThinking } from "../../shared/model-info.ts";
6
7
  import { normalizeParallelGroups } from "./parallel-groups.ts";
@@ -84,26 +85,6 @@ function appendJsonlBestEffort(filePath: string, payload: object): void {
84
85
  }
85
86
  }
86
87
 
87
- function readStatusFile(asyncDir: string): AsyncStatus | null {
88
- const statusPath = path.join(asyncDir, "status.json");
89
- let content: string;
90
- try {
91
- content = fs.readFileSync(statusPath, "utf-8");
92
- } catch (error) {
93
- if (isNotFoundError(error)) return null;
94
- throw new Error(`Failed to read async status file '${statusPath}': ${getErrorMessage(error)}`, {
95
- cause: error instanceof Error ? error : undefined,
96
- });
97
- }
98
- try {
99
- return JSON.parse(content) as AsyncStatus;
100
- } catch (error) {
101
- throw new Error(`Failed to parse async status file '${statusPath}': ${getErrorMessage(error)}`, {
102
- cause: error instanceof Error ? error : undefined,
103
- });
104
- }
105
- }
106
-
107
88
  interface ResultChildOutcome {
108
89
  agent?: string;
109
90
  success?: boolean;
@@ -348,7 +329,7 @@ export function checkPidLiveness(pid: number, kill: KillFn = process.kill): PidL
348
329
 
349
330
  export function reconcileAsyncRun(asyncDir: string, options: ReconcileAsyncRunOptions = {}): ReconcileAsyncRunResult {
350
331
  const now = options.now?.() ?? Date.now();
351
- const status = readStatusFile(asyncDir);
332
+ const status = readStatus(asyncDir);
352
333
  const startedStatus = !status && options.startedRun ? buildStartedStatus(asyncDir, options.startedRun, now) : undefined;
353
334
  const effectiveStatus = status ?? startedStatus;
354
335
  if (!effectiveStatus) return { status: null, repaired: false };
@@ -1955,6 +1955,47 @@ function combinedAbortSignal(signals: Array<AbortSignal | undefined>): AbortSign
1955
1955
  return controller.signal;
1956
1956
  }
1957
1957
 
1958
+ async function runSingleStepWithTimeout(
1959
+ step: SubagentStep,
1960
+ ctx: SingleStepContext,
1961
+ parentDeadlineAt?: number,
1962
+ ): Promise<SingleStepResult> {
1963
+ if (step.timeoutMs === undefined) return runSingleStep(step, ctx);
1964
+
1965
+ const parentRemainingMs = parentDeadlineAt === undefined ? undefined : Math.max(0, parentDeadlineAt - Date.now());
1966
+ const timeoutMs = parentRemainingMs === undefined ? step.timeoutMs : Math.min(step.timeoutMs, parentRemainingMs);
1967
+ const timeoutMessage = parentRemainingMs !== undefined && parentRemainingMs <= step.timeoutMs
1968
+ ? ctx.timeoutMessage
1969
+ : `Subagent timed out after ${step.timeoutMs}ms.`;
1970
+ const timeoutController = new AbortController();
1971
+ let timeoutAction: (() => void) | undefined;
1972
+ let timeoutTriggered = false;
1973
+ const triggerTimeout = (): void => {
1974
+ if (timeoutTriggered) return;
1975
+ timeoutTriggered = true;
1976
+ timeoutController.abort();
1977
+ timeoutAction?.();
1978
+ };
1979
+ const registerTimeout = (action: (() => void) | undefined): void => {
1980
+ timeoutAction = action;
1981
+ ctx.registerTimeout?.(action ? triggerTimeout : undefined);
1982
+ if (action && timeoutTriggered) action();
1983
+ };
1984
+ const timer = setTimeout(triggerTimeout, timeoutMs);
1985
+ timer.unref?.();
1986
+ try {
1987
+ return await runSingleStep(step, {
1988
+ ...ctx,
1989
+ registerTimeout,
1990
+ timeoutSignal: combinedAbortSignal([ctx.timeoutSignal, timeoutController.signal]),
1991
+ timeoutMessage,
1992
+ });
1993
+ } finally {
1994
+ clearTimeout(timer);
1995
+ ctx.registerTimeout?.(undefined);
1996
+ }
1997
+ }
1998
+
1958
1999
  async function runSubagent(
1959
2000
  config: SubagentRunConfig,
1960
2001
  onWriterProcess?: (writer: { state: "none" | "spawning" } | { state: "running"; pid: number }) => void,
@@ -3449,7 +3490,7 @@ async function runSubagent(
3449
3490
  writeStatusPayload();
3450
3491
  appendJsonl(eventsPath, JSON.stringify({ type: "subagent.step.started", ts: taskStartTime, runId: id, stepIndex: fi, agent: task.agent }));
3451
3492
  flushPendingStepSteers(fi);
3452
- const singleResult = await runSingleStep(task, compactOptional<SingleStepContext>({
3493
+ const singleResult = await runSingleStepWithTimeout(task, compactOptional<SingleStepContext>({
3453
3494
  previousOutput, placeholder, cwd, sessionEnabled,
3454
3495
  outputs,
3455
3496
  sessionDir: config.sessionDir ? path.join(config.sessionDir, `dynamic-${stepIndex}-${taskIdx}`) : undefined,
@@ -3479,7 +3520,7 @@ async function runSubagent(
3479
3520
  onWriterProcess,
3480
3521
  onExternalProcess: (process) => updateExternalProcess(fi, process),
3481
3522
  skipAcceptance: () => timedOut || stopped,
3482
- }));
3523
+ }), config.deadlineAt);
3483
3524
  const taskEndTime = Date.now();
3484
3525
  const childInterrupted = singleResult.interrupted === true;
3485
3526
  const childStopped = singleResult.stopped === true;
@@ -3831,7 +3872,7 @@ async function runSubagent(
3831
3872
  const { taskForRun, taskCwd } = prepareParallelTaskRun(task, cwd, worktreeSetup, taskIdx);
3832
3873
  flushPendingStepSteers(fi);
3833
3874
 
3834
- const singleResult = await runSingleStep(taskForRun, compactOptional<SingleStepContext>({
3875
+ const singleResult = await runSingleStepWithTimeout(taskForRun, compactOptional<SingleStepContext>({
3835
3876
  previousOutput, placeholder, cwd: taskCwd, sessionEnabled,
3836
3877
  outputs,
3837
3878
  sessionDir: taskSessionDir,
@@ -3861,7 +3902,7 @@ async function runSubagent(
3861
3902
  onWriterProcess,
3862
3903
  onExternalProcess: (process) => updateExternalProcess(fi, process),
3863
3904
  skipAcceptance: () => timedOut || stopped,
3864
- }));
3905
+ }), config.deadlineAt);
3865
3906
  if (task.sessionFile) {
3866
3907
  latestSessionFile = task.sessionFile;
3867
3908
  }
@@ -4120,7 +4161,7 @@ async function runSubagent(
4120
4161
  }));
4121
4162
 
4122
4163
  flushPendingStepSteers(flatIndex);
4123
- const singleResult = await runSingleStep(seqStep, compactOptional<SingleStepContext>({
4164
+ const singleResult = await runSingleStepWithTimeout(seqStep, compactOptional<SingleStepContext>({
4124
4165
  previousOutput, placeholder, cwd, sessionEnabled,
4125
4166
  outputs: statusPayload.mode === "single" ? undefined : outputs,
4126
4167
  sessionDir: config.sessionDir,
@@ -4150,7 +4191,7 @@ async function runSubagent(
4150
4191
  onWriterProcess,
4151
4192
  onExternalProcess: (process) => updateExternalProcess(flatIndex, process),
4152
4193
  skipAcceptance: () => timedOut || stopped,
4153
- }));
4194
+ }), config.deadlineAt);
4154
4195
  if (seqStep.sessionFile) {
4155
4196
  latestSessionFile = seqStep.sessionFile;
4156
4197
  }
@@ -220,7 +220,7 @@ export async function steerAsyncRun(input: {
220
220
  const revived = await input.recover(limits);
221
221
  if (revived.isError || !revived.details.asyncId) throw new Error(revived.content[0]?.type === "text" ? revived.content[0].text : "Replacement launch failed; source run remains paused.");
222
222
  const sourceStatus = readStatus(asyncDir);
223
- const targetIndex = input.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
223
+ const targetIndex = input.index ?? sourceStatus?.steering?.recent.find((request) => request.id === requestId)?.targets[0]?.index ?? status.steps?.findIndex((step) => step.status === "running") ?? -1;
224
224
  if (sourceStatus?.state === "paused" && sourceStatus.steering && targetIndex >= 0) {
225
225
  updateSteeringTarget(sourceStatus.steering, requestId, targetIndex, "recovered", Date.now(), { replacementRunId: revived.details.asyncId });
226
226
  const stepSteering = sourceStatus.steps?.[targetIndex]?.steering;
@@ -34,6 +34,7 @@ import {
34
34
  import { discoverAvailableSkills, normalizeSkillInput } from "../../agents/skills.ts";
35
35
  import { INTERCOM_BRIDGE_MARKER } from "../../intercom/intercom-bridge.ts";
36
36
  import { runSync } from "./execution.ts";
37
+ import { workflowForegroundSteeringLaunchOptions } from "./workflow-foreground-steering.ts";
37
38
  import {
38
39
  beginForegroundChild,
39
40
  finishForegroundChild,
@@ -387,6 +388,7 @@ async function runParallelChainTasks(input: ParallelChainRunInput): Promise<Sing
387
388
  result = await runSync(input.ctx.cwd, input.agents, task.agent, taskStr, {
388
389
  permissions: input.permissions,
389
390
  parentSessionId: input.ctx.sessionManager.getSessionId() ?? undefined,
391
+ ...workflowForegroundSteeringLaunchOptions(input.foregroundControl, childIndex),
390
392
  capabilityCeiling: input.capabilityCeiling,
391
393
  context: input.contextForAgent?.(task.agent),
392
394
  cwd: taskCwd,
@@ -1367,6 +1369,7 @@ ${step.message}` : ""}` }],
1367
1369
  r = await runSync(ctx.cwd, agents, seqStep.agent, stepTask, {
1368
1370
  permissions: params.permissions,
1369
1371
  parentSessionId: ctx.sessionManager.getSessionId() ?? undefined,
1372
+ ...workflowForegroundSteeringLaunchOptions(params.foregroundControl, childIndex),
1370
1373
  capabilityCeiling: params.capabilityCeiling,
1371
1374
  context: params.contextForAgent?.(seqStep.agent),
1372
1375
  cwd: resolveChildCwd(cwd ?? ctx.cwd, seqStep.cwd),
@@ -338,6 +338,9 @@ async function runSingleAttempt(
338
338
  parentRootRunId: options.nestedRoute?.rootRunId,
339
339
  parentCapabilityToken: options.nestedRoute?.capabilityToken,
340
340
  parentSessionId: options.parentSessionId,
341
+ steerInboxDir: options.steerInboxDir,
342
+ steerCapabilityPath: options.steerCapabilityPath,
343
+ steerAckDir: options.steerAckDir,
341
344
  structuredOutput: options.structuredOutput,
342
345
  toolBudget: options.toolBudget,
343
346
  allowZeroToolBudget: options.allowZeroToolBudget,