pi-subagents 0.50.0 → 0.52.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 (109) hide show
  1. package/CHANGELOG.md +103 -0
  2. package/agents/oracle.md +3 -1
  3. package/agents/reviewer.md +1 -0
  4. package/agents/scout.md +2 -2
  5. package/agents/worker.md +2 -1
  6. package/async-retention-discovery-worker.mjs +180 -0
  7. package/docs/agents.md +40 -2
  8. package/docs/configuration.md +30 -12
  9. package/docs/extension-api.md +42 -1
  10. package/docs/models.md +2 -0
  11. package/docs/observability.md +41 -5
  12. package/docs/tool-reference.md +38 -39
  13. package/docs/workflows.md +171 -3
  14. package/package.json +4 -2
  15. package/skills/pi-subagents/SKILL.md +6 -4
  16. package/skills/pi-subagents/references/constraints-and-recipes.md +12 -6
  17. package/skills/pi-subagents/references/execution-controls.md +29 -15
  18. package/skills/pi-subagents/references/management-authoring-rpc.md +3 -3
  19. package/skills/pi-subagents/references/prompting-and-roles.md +4 -2
  20. package/src/agents/agent-management.ts +124 -351
  21. package/src/agents/agents.ts +157 -31
  22. package/src/agents/skills.ts +1 -1
  23. package/src/api/external-job-provider.ts +185 -0
  24. package/src/api/preflight.ts +36 -8
  25. package/src/api/shared-types.ts +2 -0
  26. package/src/extension/config.ts +3 -3
  27. package/src/extension/doctor.ts +3 -6
  28. package/src/extension/fanout-child.ts +2 -2
  29. package/src/extension/index.ts +166 -88
  30. package/src/extension/public-execution.ts +31 -2
  31. package/src/extension/schemas.ts +12 -35
  32. package/src/extension/tool-description.ts +35 -24
  33. package/src/inspectors/herdr/actions.ts +2 -2
  34. package/src/inspectors/herdr/inspector-runner.ts +2 -1
  35. package/src/inspectors/herdr/project-panes.ts +2 -2
  36. package/src/intercom/native-supervisor-channel.ts +32 -10
  37. package/src/missions/lifecycle.ts +6 -1
  38. package/src/missions/store.ts +4 -9
  39. package/src/missions/workflow-state.ts +2 -2
  40. package/src/profiles/profiles.ts +3 -1
  41. package/src/runs/background/active-run-index.ts +31 -8
  42. package/src/runs/background/async-execution.ts +68 -40
  43. package/src/runs/background/async-job-tracker.ts +20 -4
  44. package/src/runs/background/async-resume.ts +47 -15
  45. package/src/runs/background/async-retention.ts +886 -0
  46. package/src/runs/background/async-status.ts +39 -53
  47. package/src/runs/background/chain-append.ts +3 -33
  48. package/src/runs/background/completion-dedupe.ts +5 -1
  49. package/src/runs/background/completion-replay.ts +22 -12
  50. package/src/runs/background/control-channel.ts +14 -68
  51. package/src/runs/background/fleet-view.ts +68 -19
  52. package/src/runs/background/index-segment.ts +59 -0
  53. package/src/runs/background/inspect-rpc.ts +443 -0
  54. package/src/runs/background/notify.ts +31 -5
  55. package/src/runs/background/result-files.ts +158 -90
  56. package/src/runs/background/result-watcher.ts +116 -20
  57. package/src/runs/background/resume-guidance.ts +8 -5
  58. package/src/runs/background/retained-children.ts +13 -3
  59. package/src/runs/background/run-id-query.ts +7 -0
  60. package/src/runs/background/run-id-resolver.ts +11 -9
  61. package/src/runs/background/run-status.ts +27 -18
  62. package/src/runs/background/scheduled-runs.ts +24 -4
  63. package/src/runs/background/stale-run-reconciler.ts +6 -3
  64. package/src/runs/background/steering.ts +11 -1
  65. package/src/runs/background/subagent-runner.ts +253 -140
  66. package/src/runs/background/subagent-wait.ts +8 -8
  67. package/src/runs/background/terminal-run-index.ts +129 -0
  68. package/src/runs/background/wait-completions.ts +21 -4
  69. package/src/runs/background/wait-subscriptions.ts +81 -2
  70. package/src/runs/foreground/async-steering-action.ts +21 -14
  71. package/src/runs/foreground/execution.ts +3 -1
  72. package/src/runs/foreground/subagent-executor.ts +666 -1579
  73. package/src/runs/foreground/workflow-detach-reconcile.ts +194 -0
  74. package/src/runs/foreground/workflow-foreground-steering.ts +6 -5
  75. package/src/runs/shared/acceptance.ts +4 -4
  76. package/src/runs/shared/chain-outputs.ts +1 -3
  77. package/src/runs/shared/external-job-bridge.ts +444 -0
  78. package/src/runs/shared/external-job-runner.ts +286 -0
  79. package/src/runs/shared/mcp-direct-tool-allowlist.ts +14 -0
  80. package/src/runs/shared/model-fallback.ts +98 -38
  81. package/src/runs/shared/orca-progress-tabs.ts +84 -22
  82. package/src/runs/shared/parallel-handoff.ts +46 -4
  83. package/src/runs/shared/parallel-utils.ts +4 -15
  84. package/src/runs/shared/permissions.ts +5 -1
  85. package/src/runs/shared/pi-args.ts +8 -1
  86. package/src/runs/shared/session-lease.ts +0 -6
  87. package/src/runs/shared/subagent-control.ts +26 -4
  88. package/src/runs/shared/subagent-prompt-runtime.ts +12 -6
  89. package/src/runs/shared/workflow-graph.ts +1 -23
  90. package/src/runs/shared/worktree.ts +14 -2
  91. package/src/shared/atomic-json.ts +22 -2
  92. package/src/shared/capacity-resilient-json.ts +102 -0
  93. package/src/shared/completion-owner.ts +14 -0
  94. package/src/shared/file-system-retry.ts +49 -1
  95. package/src/shared/fork-context.ts +42 -0
  96. package/src/shared/prompt-resources.ts +0 -40
  97. package/src/shared/settings.ts +3 -27
  98. package/src/shared/types.ts +71 -26
  99. package/src/shared/utils.ts +8 -0
  100. package/src/shared/watch-strategy.ts +10 -0
  101. package/src/slash/slash-bridge.ts +2 -1
  102. package/src/slash/slash-commands.ts +29 -3
  103. package/src/tui/fleet.ts +63 -15
  104. package/src/watchdog/change-signature.ts +1 -1
  105. package/src/watchdog/lsp-diagnostics.ts +1 -0
  106. package/src/workflows/chat-progress.ts +2 -2
  107. package/src/workflows/scripted-workflow.ts +220 -67
  108. package/src/runs/foreground/chain-clarify.ts +0 -1354
  109. package/src/runs/foreground/chain-execution.ts +0 -1581
@@ -226,7 +226,7 @@ function summarizeForegroundChildren(run: ForegroundResumeRun, indices: Set<numb
226
226
  }
227
227
 
228
228
  function foregroundChildrenNeedingAttention(run: ForegroundResumeRun, indices: Set<number>) {
229
- return run.children.filter((child) => indices.has(child.index) && child.status === "detached" && child.activityState === "needs_attention");
229
+ return run.children.filter((child) => indices.has(child.index) && child.status === "detached" && child.activityState === "needs_attention" && child.currentTool === "contact_supervisor");
230
230
  }
231
231
 
232
232
  function formatForegroundAttention(run: ForegroundResumeRun, children: ReturnType<typeof foregroundChildrenNeedingAttention>, elapsedMs: number): AgentToolResult<Details> {
@@ -278,19 +278,19 @@ function attentionRunsForSession(params: SubagentWaitParams, deps: SubagentWaitD
278
278
  return activeRunsForSession(params, deps).filter((run) => needsAttention(run) && initialIds.has(run.id));
279
279
  }
280
280
 
281
- /** All runs (any state) for this session, for the final summary. */
282
- function allRunsForSession(params: SubagentWaitParams, deps: SubagentWaitDeps): AsyncRunSummary[] {
281
+ /** Exact initial runs in any state, for the final summary. */
282
+ function runsForIds(runIds: Iterable<string>, deps: SubagentWaitDeps): AsyncRunSummary[] {
283
283
  const asyncDirRoot = deps.asyncDirRoot ?? DIRS.async;
284
284
  const resultsDir = deps.resultsDir ?? DIRS.results;
285
- const runs = listAsyncRuns(asyncDirRoot, {
285
+ return [...runIds].flatMap((runId) => listAsyncRuns(asyncDirRoot, {
286
286
  sessionId: deps.state.currentSessionId ?? undefined,
287
287
  resultsDir,
288
288
  kill: deps.kill,
289
289
  now: deps.now,
290
290
  includeNested: false,
291
- ...(params.id ? { runId: params.id } : {}),
292
- });
293
- return params.id ? runs.filter((run) => matchesId(run, params.id!)) : runs;
291
+ runId,
292
+ exactRunId: true,
293
+ }));
294
294
  }
295
295
 
296
296
  function summarizeTerminalRuns(runs: AsyncRunSummary[], providerFinishedCount = 0): string {
@@ -611,7 +611,7 @@ export async function waitForSubagents(
611
611
  const activeProviderIds = new Set(providerActive.map(backgroundWorkIdentity));
612
612
  const providerFinishedCount = [...initialProviderIds].filter((id) => !activeProviderIds.has(id)).length;
613
613
  try {
614
- const allNow = allRunsForSession(waitParams, deps);
614
+ const allNow = runsForIds(initialAsyncIds, deps);
615
615
  const terminal = allNow.filter((run) => !ACTIVE_STATES.includes(run.state) && initialAsyncIds.has(run.id));
616
616
  finishedAsyncCount = terminal.length;
617
617
  failedAsyncCount = terminal.filter((run) => run.state === "failed").length;
@@ -0,0 +1,129 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import type { AsyncStatus } from "../../shared/types.ts";
4
+ import { readStatus } from "../../shared/utils.ts";
5
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
6
+ import { encodeIndexSegment } from "./index-segment.ts";
7
+
8
+ export const TERMINAL_RUN_INDEX_DIR = ".terminal-runs";
9
+
10
+ const TERMINAL_RUN_INDEX_VERSION = 1;
11
+ const TIMESTAMP_WIDTH = 16;
12
+
13
+ interface TerminalRunIndexEntry {
14
+ version: 1;
15
+ runId: string;
16
+ sessionId: string;
17
+ endedAt: number;
18
+ }
19
+
20
+ function isTerminalState(state: AsyncStatus["state"]): boolean {
21
+ return state === "complete" || state === "failed" || state === "paused" || state === "stopped" || state === "rejected";
22
+ }
23
+
24
+ function indexRoot(asyncDirRoot: string): string {
25
+ return path.join(asyncDirRoot, TERMINAL_RUN_INDEX_DIR);
26
+ }
27
+
28
+ function sessionIndexDir(asyncDirRoot: string, sessionId: string): string {
29
+ return path.join(indexRoot(asyncDirRoot), encodeIndexSegment(sessionId));
30
+ }
31
+
32
+ function markerName(endedAt: number, runId: string): string {
33
+ return `${Math.max(0, Math.floor(endedAt)).toString().padStart(TIMESTAMP_WIDTH, "0")}-${encodeIndexSegment(runId)}.json`;
34
+ }
35
+
36
+ function markerPath(asyncDir: string, sessionId: string, endedAt: number): string {
37
+ return path.join(sessionIndexDir(path.dirname(asyncDir), sessionId), markerName(endedAt, path.basename(asyncDir)));
38
+ }
39
+
40
+ function parseEntry(value: unknown): TerminalRunIndexEntry | undefined {
41
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
42
+ const entry = value as Partial<TerminalRunIndexEntry>;
43
+ if (entry.version !== TERMINAL_RUN_INDEX_VERSION
44
+ || typeof entry.runId !== "string" || !entry.runId
45
+ || typeof entry.sessionId !== "string" || !entry.sessionId
46
+ || typeof entry.endedAt !== "number" || !Number.isFinite(entry.endedAt)) return undefined;
47
+ return { version: TERMINAL_RUN_INDEX_VERSION, runId: entry.runId, sessionId: entry.sessionId, endedAt: entry.endedAt };
48
+ }
49
+
50
+ export function updateTerminalRunIndex(asyncDir: string, status: AsyncStatus): void {
51
+ if (!isTerminalState(status.state) || typeof status.sessionId !== "string" || !status.sessionId) return;
52
+ const endedAt = status.endedAt ?? status.lastUpdate ?? status.startedAt;
53
+ const marker = markerPath(asyncDir, status.sessionId, endedAt);
54
+ const entry: TerminalRunIndexEntry = {
55
+ version: TERMINAL_RUN_INDEX_VERSION,
56
+ runId: status.runId || path.basename(asyncDir),
57
+ sessionId: status.sessionId,
58
+ endedAt,
59
+ };
60
+ writeAtomicJson(marker, entry);
61
+ }
62
+
63
+ function removeInvalidMarker(marker: string): void {
64
+ try {
65
+ fs.rmSync(marker, { force: true });
66
+ } catch {
67
+ // An advisory marker can remain for a later read when cleanup is contended.
68
+ }
69
+ }
70
+
71
+ function markerFiles(dir: string): string[] {
72
+ try {
73
+ return fs.readdirSync(dir, { withFileTypes: true })
74
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
75
+ .map((entry) => path.join(dir, entry.name));
76
+ } catch (error) {
77
+ const code = (error as NodeJS.ErrnoException).code;
78
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
79
+ throw error;
80
+ }
81
+ }
82
+
83
+ function sessionDirs(asyncDirRoot: string, sessionId: string | undefined): string[] {
84
+ if (sessionId) return [sessionIndexDir(asyncDirRoot, sessionId)];
85
+ try {
86
+ return fs.readdirSync(indexRoot(asyncDirRoot), { withFileTypes: true })
87
+ .filter((entry) => entry.isDirectory())
88
+ .map((entry) => path.join(indexRoot(asyncDirRoot), entry.name));
89
+ } catch (error) {
90
+ const code = (error as NodeJS.ErrnoException).code;
91
+ if (code === "ENOENT" || code === "ENOTDIR") return [];
92
+ throw error;
93
+ }
94
+ }
95
+
96
+ export function readRecentTerminalRunIndex(asyncDirRoot: string, options: { sessionId?: string; limit?: number } = {}): string[] {
97
+ const limit = options.limit === undefined ? Number.POSITIVE_INFINITY : Math.max(0, Math.floor(options.limit));
98
+ const candidates = sessionDirs(asyncDirRoot, options.sessionId)
99
+ .flatMap(markerFiles)
100
+ .sort((left, right) => path.basename(right).localeCompare(path.basename(left)))
101
+ .slice(0, limit);
102
+ const runIds: string[] = [];
103
+ const seen = new Set<string>();
104
+ for (const marker of candidates) {
105
+ let entry: TerminalRunIndexEntry | undefined;
106
+ try {
107
+ entry = parseEntry(JSON.parse(fs.readFileSync(marker, "utf-8")));
108
+ } catch (error) {
109
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") continue;
110
+ }
111
+ if (!entry || (options.sessionId !== undefined && entry.sessionId !== options.sessionId)) {
112
+ removeInvalidMarker(marker);
113
+ continue;
114
+ }
115
+ const asyncDir = path.join(asyncDirRoot, entry.runId);
116
+ const status = readStatus(asyncDir);
117
+ if (!status || !isTerminalState(status.state) || status.sessionId !== entry.sessionId || (status.runId && status.runId !== entry.runId)) {
118
+ removeInvalidMarker(marker);
119
+ continue;
120
+ }
121
+ if (seen.has(entry.runId)) {
122
+ removeInvalidMarker(marker);
123
+ continue;
124
+ }
125
+ seen.add(entry.runId);
126
+ runIds.push(entry.runId);
127
+ }
128
+ return runIds;
129
+ }
@@ -2,7 +2,7 @@ import * as fs from "node:fs";
2
2
  import type { ArtifactPaths, SubagentState, WaitCompletion, WaitCompletionChild } from "../../shared/types.ts";
3
3
  import type { AsyncRunSummary } from "./async-status.ts";
4
4
  import { readCompletionReplay, writeCompletionReplay } from "./completion-replay.ts";
5
- import { resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
5
+ import { fallbackResultPayloadPathForSessionRun, resultFilePath, resultPayloadPathForSessionRun } from "./result-files.ts";
6
6
 
7
7
  function asNonEmptyString(value: unknown): string | undefined {
8
8
  return typeof value === "string" && value ? value : undefined;
@@ -14,6 +14,11 @@ function errorCode(error: unknown): string | undefined {
14
14
  : undefined;
15
15
  }
16
16
 
17
+ function isAccessDenied(error: unknown): boolean {
18
+ const code = errorCode(error);
19
+ return code === "EPERM" || code === "EACCES";
20
+ }
21
+
17
22
  function errorMessage(error: unknown): string {
18
23
  return error instanceof Error ? error.message : String(error);
19
24
  }
@@ -115,9 +120,21 @@ export function collectWaitCompletions(terminal: AsyncRunSummary[], state: Subag
115
120
  continue;
116
121
  }
117
122
  const publicResultPath = resultFilePath(resultsDir, run.id);
118
- const resultPath = run.sessionId
119
- ? resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath
120
- : publicResultPath;
123
+ let resultPath = publicResultPath;
124
+ try {
125
+ resultPath = run.sessionId
126
+ ? resultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath
127
+ : publicResultPath;
128
+ } catch (error) {
129
+ if (!isAccessDenied(error) || !run.sessionId) throw error;
130
+ try {
131
+ resultPath = fallbackResultPayloadPathForSessionRun(resultsDir, run.sessionId, run.id) ?? publicResultPath;
132
+ } catch (fallbackError) {
133
+ throw new Error(`Failed to read subagent result '${publicResultPath}': ${errorMessage(fallbackError)}`, {
134
+ cause: fallbackError instanceof Error ? fallbackError : undefined,
135
+ });
136
+ }
137
+ }
121
138
  try {
122
139
  const raw = JSON.parse(fs.readFileSync(resultPath, "utf-8")) as Record<string, unknown>;
123
140
  completions.push(toWaitCompletion(raw, run.id));
@@ -21,6 +21,20 @@ import {
21
21
 
22
22
  const SUBSCRIPTION_VERSION = 1;
23
23
  const RECONCILE_INTERVAL_MS = 1_000;
24
+ /**
25
+ * How often the subscriptions directory is re-scanned for expired records left
26
+ * by other sessions. Rare compared to RECONCILE_INTERVAL_MS because it costs a
27
+ * directory read, and nothing depends on the sweep being prompt.
28
+ */
29
+ const FOREIGN_SWEEP_INTERVAL_MS = 60_000;
30
+ /**
31
+ * How far past expiry a record armed by another session is kept before it is
32
+ * swept. The owning session settles its own expired records with a "timed out"
33
+ * notice on the next restore(), so removing one the moment it expires would take
34
+ * that notice away from a session that simply had not resumed yet. A day is long
35
+ * enough for an ordinary return and short enough to bound the directory.
36
+ */
37
+ const FOREIGN_SWEEP_GRACE_MS = 24 * 60 * 60 * 1000;
24
38
 
25
39
  export interface ArmWaitSubscriptionInput {
26
40
  targetKind: "async" | "foreground";
@@ -95,6 +109,63 @@ export function createWaitSubscriptionManager(
95
109
  state.waitSubscriptions = subscriptions;
96
110
  const unresolvedRestoredForegroundTokens = new Set<string>();
97
111
  let disposed = false;
112
+ let lastForeignSweepAt = 0;
113
+
114
+ /**
115
+ * Remove expired records armed by another session.
116
+ *
117
+ * Only the owning session can be woken, so restore() never loads a foreign
118
+ * record into `subscriptions` and reconcileRecord() returns before its
119
+ * timeout branch. Nothing reconciles such a record and nothing expires it, so
120
+ * a session that arms a wait and never comes back (forked, renamed, deleted)
121
+ * leaves one file here per wait, forever.
122
+ *
123
+ * Kept for FOREIGN_SWEEP_GRACE_MS past expiry so an owner that resumes late
124
+ * still finds its record and gets the timeout notice.
125
+ *
126
+ * Runs on the reconcile timer as well as at restore(), because a record that
127
+ * is still live when another session starts would otherwise never be looked
128
+ * at again: restore() only fires on session_start, and reconcile() walks the
129
+ * in-memory map, which holds current-session records only. A host that keeps
130
+ * one session open for hours is exactly where that gap bites.
131
+ *
132
+ * Never throws: it runs from the reconcile timer.
133
+ */
134
+ const sweepExpiredForeignSubscriptions = (force = false) => {
135
+ const sweptAt = now();
136
+ if (!force && sweptAt - lastForeignSweepAt < FOREIGN_SWEEP_INTERVAL_MS) return;
137
+ lastForeignSweepAt = sweptAt;
138
+ let files: string[];
139
+ try {
140
+ files = fs.readdirSync(subscriptionsDir).filter((file) => file.endsWith(".json"));
141
+ } catch (error) {
142
+ if (!isNotFound(error)) console.error(`Failed to scan wait subscriptions in '${subscriptionsDir}':`, error);
143
+ return;
144
+ }
145
+ for (const file of files) {
146
+ const filePath = path.join(subscriptionsDir, file);
147
+ let record: WaitSubscriptionRecord | undefined;
148
+ try {
149
+ record = parseRecord(JSON.parse(fs.readFileSync(filePath, "utf-8")));
150
+ } catch (error) {
151
+ console.error(`Ignoring invalid wait subscription '${filePath}':`, error);
152
+ continue;
153
+ }
154
+ if (!record || file !== `${record.token}.json`) continue;
155
+ // The owning session keeps its own expired records so reconcileRecord()
156
+ // can still settle them with the "timed out" notice callers expect.
157
+ if (record.sessionId === state.currentSessionId) continue;
158
+ if (sweptAt < record.expiresAt + FOREIGN_SWEEP_GRACE_MS) continue;
159
+ try {
160
+ fs.unlinkSync(filePath);
161
+ } catch (error) {
162
+ // Losing this race is harmless: another session may have swept the
163
+ // same expired record. Anything else is reported and retried on the
164
+ // next sweep rather than interrupting the caller.
165
+ if (!isNotFound(error)) console.error(`Failed to remove expired wait subscription '${filePath}':`, error);
166
+ }
167
+ }
168
+ };
98
169
 
99
170
  const remove = (record: WaitSubscriptionRecord) => {
100
171
  try {
@@ -146,7 +217,7 @@ export function createWaitSubscriptionManager(
146
217
  return;
147
218
  }
148
219
  const detached = run.children.filter((child) => child.status === "detached");
149
- if (detached.some((child) => child.activityState === "needs_attention")) {
220
+ if (detached.some((child) => child.activityState === "needs_attention" && child.currentTool === "contact_supervisor")) {
150
221
  settle(record, "needs attention", "Reply to the pending supervisor request or inspect the run status.");
151
222
  return;
152
223
  }
@@ -160,6 +231,7 @@ export function createWaitSubscriptionManager(
160
231
  const runs = listAsyncRuns(asyncDirRoot, {
161
232
  sessionId: record.sessionId,
162
233
  runId: record.runId,
234
+ exactRunId: true,
163
235
  resultsDir,
164
236
  kill: options.kill,
165
237
  now,
@@ -187,7 +259,11 @@ export function createWaitSubscriptionManager(
187
259
  };
188
260
 
189
261
  const reconcile = () => {
190
- if (disposed || !state.currentSessionId) return;
262
+ if (disposed) return;
263
+ // Before the session check: with no current session every record is
264
+ // foreign, and expired ones should still be cleaned up.
265
+ sweepExpiredForeignSubscriptions();
266
+ if (!state.currentSessionId) return;
191
267
  for (const record of [...subscriptions.values()]) {
192
268
  try {
193
269
  reconcileRecord(record);
@@ -233,6 +309,9 @@ export function createWaitSubscriptionManager(
233
309
  restore() {
234
310
  subscriptions.clear();
235
311
  unresolvedRestoredForegroundTokens.clear();
312
+ // Unconditional: a process that starts without a session identity should
313
+ // still clear records nobody can act on.
314
+ sweepExpiredForeignSubscriptions(true);
236
315
  if (!state.currentSessionId) return;
237
316
  let files: string[];
238
317
  try {
@@ -8,7 +8,17 @@ import { readStatus } from "../../shared/utils.ts";
8
8
  import { consumeSteerAcks, deliverInterruptRequest, queueRevivalBrief, requestAsyncSteer, type SteerDeliveryMode, type SteerRequest } from "../background/control-channel.ts";
9
9
  import { resolveAsyncResumeTarget } from "../background/async-resume.ts";
10
10
  import { reconcileAsyncRun } from "../background/stale-run-reconciler.ts";
11
- import { actionResultFromSteeringStatus, claimSteeringRecovery, createSteeringStatus, recordSteeringRequest, remainingSteeringRecoveryLimits, updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
11
+ import { actionResultFromSteeringStatus, claimSteeringRecovery, createSteeringStatus, recordSteeringRequest, remainingSteeringRecoveryLimits, steeringReceipt, updateSteeringTarget, waitForSteeringAction } from "../background/steering.ts";
12
+
13
+ export function canQueueRetainedAsyncFollowUp(status: AsyncStatus, index?: number): boolean {
14
+ const steps = status.steps ?? [];
15
+ return status.state === "complete"
16
+ && Boolean(status.parentWorkflowRunId)
17
+ && steps.length === 1
18
+ && (steps[0]?.status === "complete" || steps[0]?.status === "completed")
19
+ && Boolean(steps[0]?.sessionFile ?? status.sessionFile)
20
+ && (index === undefined || index === 0);
21
+ }
12
22
 
13
23
  export async function steerAsyncRun(input: {
14
24
  state: SubagentState;
@@ -51,7 +61,7 @@ export async function steerAsyncRun(input: {
51
61
  }
52
62
  const steps = status.steps ?? [];
53
63
  if (status.state !== "running" && status.state !== "queued") {
54
- const retained = input.mode === "follow_up" && status.state === "complete" && Boolean(status.parentWorkflowRunId) && steps.length === 1 && (steps[0]?.status === "complete" || steps[0]?.status === "completed") && Boolean(steps[0]?.sessionFile ?? status.sessionFile);
64
+ const retained = input.mode === "follow_up" && canQueueRetainedAsyncFollowUp(status, input.index);
55
65
  if (!retained) {
56
66
  return {
57
67
  content: [{ type: "text", text: `Async run '${input.runId}' is not running or queued and cannot be steered.` }],
@@ -59,9 +69,6 @@ export async function steerAsyncRun(input: {
59
69
  details: { mode: "management", results: [] },
60
70
  };
61
71
  }
62
- if (input.index !== undefined && input.index !== 0) {
63
- return { content: [{ type: "text", text: `Retained async run '${status.runId}' has one child. Index ${input.index} is out of range.` }], isError: true, details: { mode: "management", results: [] } };
64
- }
65
72
  const request: SteerRequest = { type: "steer", id: randomUUID(), ts: Date.now(), message: input.message.trim(), mode: "follow_up", targetIndex: 0, source: "steer-action" };
66
73
  try {
67
74
  queueRevivalBrief(asyncDir, request);
@@ -72,7 +79,7 @@ export async function steerAsyncRun(input: {
72
79
  recordSteeringRequest(status.steering, { id: request.id, requestedAt: request.ts, source: request.source, message: request.message, targets: [{ index: 0, state: "scheduled" }] });
73
80
  writeAtomicJson(path.join(asyncDir, "status.json"), status);
74
81
  const queued = { requestId: request.id, state: "scheduled" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets: [{ index: 0, state: "scheduled" as const }] };
75
- return { content: [{ type: "text", text: `Follow-up queued for the next resume of retained async run ${status.runId} (request ${request.id}).` }], details: { mode: "management", results: [], steering: queued } };
82
+ return { content: [{ type: "text", text: steeringReceipt(request.message, `Follow-up queued for the next resume of retained async run ${status.runId} (request ${request.id}).`) }], details: { mode: "management", results: [], steering: queued } };
76
83
  }
77
84
  if (input.index !== undefined) {
78
85
  if (input.index < 0 || input.index >= steps.length) {
@@ -135,17 +142,17 @@ export async function steerAsyncRun(input: {
135
142
  const targets = targetIndexes.map((index) => ({ index, state: status.steps?.[index]?.status === "pending" ? "scheduled" as const : "pending" as const }));
136
143
  if (targets.every((target) => target.state === "scheduled")) {
137
144
  const scheduled = { requestId, state: "scheduled" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets };
138
- return { content: [{ type: "text", text: `Steering scheduled for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: scheduled } };
145
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering scheduled for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: scheduled } };
139
146
  }
140
147
  const waited = await waitForSteeringAction({ asyncDir, sourceRunId: status.runId, requestId, timeoutMs: input.ackTimeoutMs ?? 3_000, signal: input.signal });
141
148
  const result = waited ?? { requestId, state: "pending" as const, deliveryStatus: "queued" as const, sourceRunId: status.runId, targets };
142
149
  if (input.signal?.aborted) {
143
- return { content: [{ type: "text", text: `Steering pending for async run ${status.runId} (request ${requestId}); caller aborted before recovery.` }], details: { mode: "management", results: [], steering: result } };
150
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering pending for async run ${status.runId} (request ${requestId}); caller aborted before recovery.`) }], details: { mode: "management", results: [], steering: result } };
144
151
  }
145
152
  const finalStatus = readStatus(asyncDir);
146
153
  const finalResult = finalStatus?.steering ? actionResultFromSteeringStatus(finalStatus.steering, status.runId, requestId) : undefined;
147
154
  if (finalResult?.state === "delivered") {
148
- return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: finalResult } };
155
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: finalResult } };
149
156
  }
150
157
  const running = (finalStatus?.steps ?? status.steps ?? []).filter((step) => step.status === "running");
151
158
  const recoveryAllowed = (input.mode ?? "steer") === "steer" && status.mode === "single" && status.isNested !== true && running.length === 1 && Boolean(finalStatus?.steering) && (input.index === undefined || input.index === 0);
@@ -160,7 +167,7 @@ export async function steerAsyncRun(input: {
160
167
  try {
161
168
  const latest = readStatus(asyncDir);
162
169
  const latestResult = latest?.steering ? actionResultFromSteeringStatus(latest.steering, status.runId, requestId) : undefined;
163
- if (latestResult?.state === "delivered") return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: latestResult } };
170
+ if (latestResult?.state === "delivered") return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: latestResult } };
164
171
  const committedAt = Date.now();
165
172
  input.onBeforeRecoveryClaim?.(requestId, committedAt);
166
173
  const { claimPath, markerPath } = claimSteeringRecovery(asyncDir, { requestId, sourceRunId: status.runId, committedAt });
@@ -170,7 +177,7 @@ export async function steerAsyncRun(input: {
170
177
  if (preCommitResult?.state === "delivered" && preCommitResult.targets.every((target) => target.deliveredAt !== undefined && target.deliveredAt <= committedAt)) {
171
178
  fs.rmSync(markerPath, { force: true });
172
179
  fs.rmSync(claimPath, { force: true });
173
- return { content: [{ type: "text", text: `Steering delivered for async run ${status.runId} (request ${requestId}).` }], details: { mode: "management", results: [], steering: preCommitResult } };
180
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering delivered for async run ${status.runId} (request ${requestId}).`) }], details: { mode: "management", results: [], steering: preCommitResult } };
174
181
  }
175
182
  try {
176
183
  deliverInterruptRequest({ asyncDir, source: "steering-recovery" });
@@ -228,7 +235,7 @@ export async function steerAsyncRun(input: {
228
235
  }
229
236
  const recovered = paused.steering ? actionResultFromSteeringStatus(paused.steering, status.runId, requestId, revived.details.asyncId) : undefined;
230
237
  appendSteeringNotice("recovered", `Steering recovered for run ${status.runId}; replacement ${revived.details.asyncId} launched.`);
231
- return { content: [{ type: "text", text: `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.` }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
238
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering recovered for async run ${status.runId}; replacement ${revived.details.asyncId} launched after the source paused.`) }], details: { mode: "management", results: [], steering: recovered ?? { requestId, state: "recovered", sourceRunId: status.runId, replacementRunId: revived.details.asyncId, deliveryStatus: "delivered", targets: [{ index: input.index ?? 0, state: "recovered" }] } } };
232
239
  } catch (error) {
233
240
  const reason = error instanceof Error ? error.message : String(error);
234
241
  const failedStatus = readStatus(asyncDir);
@@ -242,13 +249,13 @@ export async function steerAsyncRun(input: {
242
249
  }
243
250
  const failed = failedStatus?.steering ? actionResultFromSteeringStatus(failedStatus.steering, status.runId, requestId) : undefined;
244
251
  appendSteeringNotice("failed", `Steering failed for run ${status.runId}: ${reason}`);
245
- return { content: [{ type: "text", text: `Steering failed for async run ${status.runId} (request ${requestId}): ${reason}` }], isError: true, details: { mode: "management", results: [], steering: failed ?? { requestId, state: "failed", sourceRunId: status.runId, deliveryStatus: "queued", targets: [{ index: input.index ?? 0, state: "failed", reason }] } } };
252
+ return { content: [{ type: "text", text: steeringReceipt(input.message, `Steering failed for async run ${status.runId} (request ${requestId}): ${reason}`) }], isError: true, details: { mode: "management", results: [], steering: failed ?? { requestId, state: "failed", sourceRunId: status.runId, deliveryStatus: "queued", targets: [{ index: input.index ?? 0, state: "failed", reason }] } } };
246
253
  }
247
254
  }
248
255
  const stateText = result.state === "failed" ? "failed" : result.state === "partial" ? "partial" : result.deliveryStatus === "queued" ? "queued" : result.state === "delivered" ? "delivered" : result.state === "scheduled" ? "scheduled" : result.state === "recovered" ? "recovered" : "pending";
249
256
  const isError = result.state === "failed" || result.state === "partial";
250
257
  return {
251
- content: [{ type: "text", text: `Steering ${stateText} for async run ${status.runId} (request ${requestId}).` }],
258
+ content: [{ type: "text", text: steeringReceipt(input.message, `Steering ${stateText} for async run ${status.runId} (request ${requestId}).`) }],
252
259
  ...(isError ? { isError: true } : {}),
253
260
  details: { mode: "management", results: [], steering: result },
254
261
  };
@@ -783,6 +783,7 @@ async function runSingleAttempt(
783
783
  currentToolDurationMs: input.currentToolDurationMs ?? currentToolDurationMs(now),
784
784
  currentPath: input.currentPath ?? progress.currentPath,
785
785
  recentFailureSummary: input.recentFailureSummary,
786
+ taskPreview: task,
786
787
  });
787
788
  emitControlEvent(event);
788
789
  return previous !== "needs_attention";
@@ -809,6 +810,7 @@ async function runSingleAttempt(
809
810
  currentToolDurationMs: currentToolDurationMs(now),
810
811
  currentPath: progress.currentPath,
811
812
  elapsedMs: now - startTime,
813
+ taskPreview: task,
812
814
  }));
813
815
  return true;
814
816
  };
@@ -1671,7 +1673,7 @@ async function runSyncCompletionInner(
1671
1673
  agent.fallbackModels,
1672
1674
  options.availableModels,
1673
1675
  options.preferredModelProvider,
1674
- { scope: options.modelScope },
1676
+ { scope: options.modelScope, primaryModelFromParent: options.modelOverrideFromParent },
1675
1677
  );
1676
1678
  const attemptedModels: string[] = [];
1677
1679
  const modelAttempts: ModelAttempt[] = [];