pi-subagents 0.36.0 → 0.37.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 (41) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/README.md +93 -8
  3. package/agents/delegate.md +2 -0
  4. package/agents/worker.md +2 -0
  5. package/package.json +4 -2
  6. package/skills/pi-subagents/SKILL.md +20 -4
  7. package/src/agents/agent-management.ts +7 -1
  8. package/src/agents/agents.ts +99 -12
  9. package/src/api/capability-ceiling.ts +17 -0
  10. package/src/api/delegation.ts +3 -1
  11. package/src/api/preflight.ts +399 -0
  12. package/src/extension/index.ts +2 -0
  13. package/src/extension/rpc.ts +4 -0
  14. package/src/extension/schemas.ts +8 -2
  15. package/src/extension/tool-description.ts +2 -0
  16. package/src/runs/background/async-execution.ts +125 -14
  17. package/src/runs/background/async-resume.ts +24 -7
  18. package/src/runs/background/async-status.ts +18 -0
  19. package/src/runs/background/process-terminal.ts +280 -0
  20. package/src/runs/background/run-status.ts +7 -1
  21. package/src/runs/background/scheduled-runs.ts +6 -1
  22. package/src/runs/background/stale-run-reconciler.ts +6 -0
  23. package/src/runs/background/subagent-runner.ts +182 -12
  24. package/src/runs/foreground/chain-execution.ts +5 -0
  25. package/src/runs/foreground/execution.ts +59 -13
  26. package/src/runs/foreground/subagent-executor.ts +32 -2
  27. package/src/runs/shared/acceptance.ts +41 -30
  28. package/src/runs/shared/capability-ceiling.ts +177 -0
  29. package/src/runs/shared/dynamic-fanout.ts +1 -1
  30. package/src/runs/shared/mcp-direct-tool-allowlist.ts +12 -6
  31. package/src/runs/shared/nested-events.ts +8 -1
  32. package/src/runs/shared/parallel-utils.ts +5 -0
  33. package/src/runs/shared/pi-args.ts +141 -58
  34. package/src/runs/shared/session-lease.ts +25 -5
  35. package/src/runs/shared/subagent-prompt-runtime.ts +14 -1
  36. package/src/runs/shared/tool-availability.ts +18 -2
  37. package/src/shared/launch-contract.ts +123 -0
  38. package/src/shared/types.ts +104 -7
  39. package/src/shared/utils.ts +17 -42
  40. package/src/slash/delegation-adapters.ts +1 -1
  41. package/src/slash/slash-commands.ts +1 -1
@@ -0,0 +1,280 @@
1
+ import * as fs from "node:fs";
2
+ import * as path from "node:path";
3
+ import { writeAtomicJson, writePrivateAtomicJson } from "../../shared/atomic-json.ts";
4
+ import {
5
+ SUBAGENT_LIFECYCLE_ARTIFACT_VERSION,
6
+ type AsyncStatus,
7
+ type CanonicalSessionTerminalV1,
8
+ type ProcessInstanceExitV1,
9
+ type ProcessTerminalReason,
10
+ type ProcessTerminalV1,
11
+ } from "../../shared/types.ts";
12
+ import { canonicalSessionId, inspectSessionLease } from "../shared/session-lease.ts";
13
+
14
+ export interface ProcessTerminalCandidate {
15
+ version: 1;
16
+ runId: string;
17
+ runnerProcessInstanceId: string;
18
+ writers: Record<string, ProcessInstanceExitV1[]>;
19
+ expectedWriters?: Record<string, number>;
20
+ sessionFile?: string;
21
+ revivalLeaseToken?: string;
22
+ revivalLeaseReleaseAcknowledged?: boolean;
23
+ }
24
+
25
+ export interface RunnerCloseObservation {
26
+ processInstanceId: string;
27
+ closeObservedAt: number;
28
+ exitCode: number | null;
29
+ signal: string | null;
30
+ }
31
+
32
+ function isRecord(value: unknown): value is Record<string, unknown> {
33
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ function validProcessInstance(value: unknown, kind?: "runner" | "pi-writer"): value is ProcessInstanceExitV1 {
37
+ if (!isRecord(value)) return false;
38
+ if (typeof value.processInstanceId !== "string" || value.processInstanceId.length === 0) return false;
39
+ if (kind ? value.kind !== kind : (value.kind !== "runner" && value.kind !== "pi-writer")) return false;
40
+ if (typeof value.closeObservedAt !== "number" || !Number.isFinite(value.closeObservedAt)) return false;
41
+ if (typeof value.exitCode !== "number" && value.exitCode !== null) return false;
42
+ if (typeof value.signal !== "string" && value.signal !== null) return false;
43
+ return value.kind === "runner"
44
+ ? value.attempt === undefined
45
+ : typeof value.attempt === "number" && Number.isInteger(value.attempt) && value.attempt >= 0;
46
+ }
47
+
48
+ function validInstance(value: unknown): value is ProcessInstanceExitV1 {
49
+ return validProcessInstance(value, "pi-writer");
50
+ }
51
+
52
+ export function processTerminalCandidatePath(asyncDir: string): string {
53
+ return path.join(asyncDir, "process-terminal-candidate.json");
54
+ }
55
+
56
+ export function processTerminalPath(asyncDir: string): string {
57
+ return path.join(asyncDir, "process-terminal.json");
58
+ }
59
+
60
+ function errorMessage(error: unknown): string {
61
+ return error instanceof Error ? error.message : String(error);
62
+ }
63
+
64
+ export function readProcessTerminalCandidate(asyncDir: string): ProcessTerminalCandidate | undefined {
65
+ try {
66
+ const raw = JSON.parse(fs.readFileSync(processTerminalCandidatePath(asyncDir), "utf-8")) as unknown;
67
+ if (!isRecord(raw) || raw.version !== 1 || typeof raw.runId !== "string" || typeof raw.runnerProcessInstanceId !== "string" || !isRecord(raw.writers)) {
68
+ throw new Error(`Invalid process-terminal candidate in '${asyncDir}'.`);
69
+ }
70
+ const writers: Record<string, ProcessInstanceExitV1[]> = {};
71
+ for (const [index, entries] of Object.entries(raw.writers)) {
72
+ if (!Array.isArray(entries) || !entries.every(validInstance)) throw new Error(`Invalid writer process records for child '${index}'.`);
73
+ writers[index] = entries;
74
+ }
75
+ let expectedWriters: Record<string, number> | undefined;
76
+ if (raw.expectedWriters !== undefined) {
77
+ if (!isRecord(raw.expectedWriters)) throw new Error("Invalid expected writer process records.");
78
+ expectedWriters = {};
79
+ for (const [index, count] of Object.entries(raw.expectedWriters)) {
80
+ if (typeof count !== "number" || !Number.isInteger(count) || count < 0) throw new Error(`Invalid expected writer count for child '${index}'.`);
81
+ expectedWriters[index] = count;
82
+ }
83
+ }
84
+ if (raw.sessionFile !== undefined && typeof raw.sessionFile !== "string") throw new Error("Invalid process-terminal candidate sessionFile.");
85
+ if (raw.revivalLeaseToken !== undefined && typeof raw.revivalLeaseToken !== "string") throw new Error("Invalid process-terminal candidate lease token.");
86
+ if (raw.revivalLeaseReleaseAcknowledged !== undefined && typeof raw.revivalLeaseReleaseAcknowledged !== "boolean") throw new Error("Invalid process-terminal lease release acknowledgement.");
87
+ return {
88
+ version: 1,
89
+ runId: raw.runId,
90
+ runnerProcessInstanceId: raw.runnerProcessInstanceId,
91
+ writers,
92
+ ...(expectedWriters ? { expectedWriters } : {}),
93
+ ...(raw.sessionFile ? { sessionFile: raw.sessionFile } : {}),
94
+ ...(raw.revivalLeaseToken ? { revivalLeaseToken: raw.revivalLeaseToken } : {}),
95
+ ...(raw.revivalLeaseReleaseAcknowledged !== undefined ? { revivalLeaseReleaseAcknowledged: raw.revivalLeaseReleaseAcknowledged } : {}),
96
+ };
97
+ } catch (error) {
98
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
99
+ throw error;
100
+ }
101
+ }
102
+
103
+ export function writeProcessTerminalCandidate(asyncDir: string, candidate: ProcessTerminalCandidate): void {
104
+ writePrivateAtomicJson(processTerminalCandidatePath(asyncDir), candidate);
105
+ }
106
+
107
+ export function markProcessTerminalCandidateLeaseRelease(asyncDir: string, token: string, acknowledged: boolean): void {
108
+ const candidate = readProcessTerminalCandidate(asyncDir);
109
+ if (!candidate || candidate.revivalLeaseToken !== token) return;
110
+ writeProcessTerminalCandidate(asyncDir, { ...candidate, revivalLeaseReleaseAcknowledged: acknowledged });
111
+ }
112
+
113
+ function unknownProof(runId: string, runnerProcessInstanceId: string, reason: ProcessTerminalReason, diagnostic?: string): ProcessTerminalV1 {
114
+ return { version: 1, state: "unknown", runId, runnerProcessInstanceId, reason, ...(diagnostic ? { diagnostic } : {}) };
115
+ }
116
+
117
+ function resumeDisposition(state: string | undefined, sessionFile: string | undefined): "resumable" | "non-resumable" | "unavailable" {
118
+ if (state === "stopped") return "non-resumable";
119
+ if (state !== "complete" && state !== "completed" && state !== "failed" && state !== "paused") return "unavailable";
120
+ return sessionFile && fs.existsSync(sessionFile) ? "resumable" : "unavailable";
121
+ }
122
+
123
+ function sessionProjection(candidate: ProcessTerminalCandidate, lease: ReturnType<typeof inspectSessionLease>): CanonicalSessionTerminalV1 | undefined {
124
+ if (!candidate.sessionFile || lease.state !== "free") return undefined;
125
+ if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) return undefined;
126
+ return {
127
+ canonicalSessionId: canonicalSessionId(candidate.sessionFile),
128
+ leaseDisposition: candidate.revivalLeaseToken ? "released" : "not-held",
129
+ freeAtObservation: true,
130
+ ...(candidate.revivalLeaseToken ? { canonicalSessionLeaseReleased: true } : {}),
131
+ };
132
+ }
133
+
134
+ function validateProof(raw: unknown, asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): raw is ProcessTerminalV1 {
135
+ if (!isRecord(raw) || raw.version !== 1 || !["pending", "observed", "unknown", "not-started"].includes(String(raw.state)) || typeof raw.runId !== "string" || !raw.runId || typeof raw.runnerProcessInstanceId !== "string" || !raw.runnerProcessInstanceId) {
136
+ throw new Error(`Invalid process-terminal proof in '${asyncDir}'.`);
137
+ }
138
+ if (fallback?.runId && raw.runId !== fallback.runId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to run '${raw.runId}', expected '${fallback.runId}'.`);
139
+ if (fallback?.runnerProcessInstanceId && raw.runnerProcessInstanceId !== fallback.runnerProcessInstanceId) throw new Error(`Process-terminal proof in '${asyncDir}' belongs to runner '${raw.runnerProcessInstanceId}', expected '${fallback.runnerProcessInstanceId}'.`);
140
+ if (raw.instances !== undefined && (!Array.isArray(raw.instances) || !raw.instances.every((entry) => validProcessInstance(entry)))) {
141
+ throw new Error(`Invalid process-terminal instances in '${asyncDir}'.`);
142
+ }
143
+ if (raw.state === "observed") {
144
+ if (typeof raw.observedAt !== "number" || !Number.isFinite(raw.observedAt)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing observedAt.`);
145
+ if (!Array.isArray(raw.instances)) throw new Error(`Observed process-terminal proof in '${asyncDir}' is missing instances.`);
146
+ const runner = raw.instances.find((entry) => isRecord(entry) && entry.kind === "runner");
147
+ if (!validProcessInstance(runner, "runner") || runner.processInstanceId !== raw.runnerProcessInstanceId) throw new Error(`Observed process-terminal proof in '${asyncDir}' has no matching runner instance.`);
148
+ }
149
+ if (raw.resumeDisposition !== undefined && !["resumable", "non-resumable", "unavailable"].includes(String(raw.resumeDisposition))) throw new Error(`Invalid process-terminal resume disposition in '${asyncDir}'.`);
150
+ return true;
151
+ }
152
+
153
+ export function sanitizeProcessTerminal(value: unknown, fallback: { runId?: string; runnerProcessInstanceId?: string }, label = "status"): ProcessTerminalV1 | undefined {
154
+ if (value === undefined) return undefined;
155
+ try {
156
+ validateProof(value, label, fallback);
157
+ return value;
158
+ } catch (error) {
159
+ return unknownProof(fallback.runId ?? label, fallback.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
160
+ }
161
+ }
162
+
163
+ export function readProcessTerminal(asyncDir: string, fallback?: { runId?: string; runnerProcessInstanceId?: string }): ProcessTerminalV1 | undefined {
164
+ try {
165
+ const raw = JSON.parse(fs.readFileSync(processTerminalPath(asyncDir), "utf-8")) as unknown;
166
+ validateProof(raw, asyncDir, fallback);
167
+ return raw;
168
+ } catch (error) {
169
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
170
+ return unknownProof(fallback?.runId ?? path.basename(asyncDir), fallback?.runnerProcessInstanceId ?? "unknown", "proof-write-failed", errorMessage(error));
171
+ }
172
+ }
173
+
174
+ function stepProcessTerminalProof(
175
+ proof: ProcessTerminalV1,
176
+ childIndex: number,
177
+ state: ProcessTerminalV1["state"],
178
+ records: ProcessInstanceExitV1[],
179
+ resumeDispositionValue: ProcessTerminalV1["resumeDisposition"],
180
+ ): ProcessTerminalV1 {
181
+ const base = {
182
+ version: 1 as const,
183
+ runId: proof.runId,
184
+ childIndex,
185
+ runnerProcessInstanceId: proof.runnerProcessInstanceId,
186
+ ...(resumeDispositionValue ? { resumeDisposition: resumeDispositionValue } : {}),
187
+ };
188
+ if (state === "observed") {
189
+ return { ...base, state, observedAt: proof.state === "observed" ? proof.observedAt : Date.now(), instances: records };
190
+ }
191
+ if (state === "unknown") {
192
+ return { ...base, state, reason: proof.state === "unknown" ? proof.reason : "writer-close-unverified" };
193
+ }
194
+ return { ...base, state };
195
+ }
196
+
197
+ function overlayStatus(asyncDir: string, proof: ProcessTerminalV1, candidate?: ProcessTerminalCandidate): void {
198
+ const statusPath = path.join(asyncDir, "status.json");
199
+ try {
200
+ const status = JSON.parse(fs.readFileSync(statusPath, "utf-8")) as AsyncStatus;
201
+ status.processTerminal = proof;
202
+ if (status.steps) {
203
+ for (const [index, step] of status.steps.entries()) {
204
+ const records = candidate?.writers[String(index)] ?? [];
205
+ const expected = candidate?.expectedWriters?.[String(index)] ?? (records.length > 0 ? records.length : 0);
206
+ const stepState = expected === 0 ? "not-started" : proof.state === "observed" && records.length === expected ? "observed" : proof.state === "pending" ? "pending" : "unknown";
207
+ step.processTerminal = stepProcessTerminalProof(proof, index, stepState, records, resumeDisposition(step.status, step.sessionFile ?? candidate?.sessionFile));
208
+ }
209
+ }
210
+ writeAtomicJson(statusPath, status);
211
+ } catch {
212
+ // The proof sidecar remains authoritative when terminal status is unavailable.
213
+ }
214
+ }
215
+
216
+ export function finalizeProcessTerminal(
217
+ asyncDir: string,
218
+ runId: string,
219
+ runnerClose: RunnerCloseObservation,
220
+ ): ProcessTerminalV1 {
221
+ const existing = readProcessTerminal(asyncDir, { runId, runnerProcessInstanceId: runnerClose.processInstanceId });
222
+ if (existing && fs.existsSync(processTerminalPath(asyncDir))) {
223
+ if (existing.state === "observed" && existing.runId === runId && existing.runnerProcessInstanceId === runnerClose.processInstanceId) return existing;
224
+ if (existing.state === "unknown") return existing;
225
+ }
226
+ let proof: ProcessTerminalV1;
227
+ let candidateForOverlay: ProcessTerminalCandidate | undefined;
228
+ try {
229
+ const candidate = readProcessTerminalCandidate(asyncDir);
230
+ candidateForOverlay = candidate;
231
+ if (!candidate) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-candidate-missing");
232
+ else if (candidate.runId !== runId || candidate.runnerProcessInstanceId !== runnerClose.processInstanceId) proof = unknownProof(runId, runnerClose.processInstanceId, "runner-instance-mismatch");
233
+ else {
234
+ const allWriters = Object.values(candidate.writers).flat();
235
+ const status = (() => {
236
+ try { return JSON.parse(fs.readFileSync(path.join(asyncDir, "status.json"), "utf-8")) as AsyncStatus; } catch { return undefined; }
237
+ })();
238
+ const session = candidate.sessionFile ? inspectSessionLease(candidate.sessionFile) : undefined;
239
+ const writerEntries = Object.entries(candidate.writers);
240
+ const expectedWriters = candidate.expectedWriters ?? Object.fromEntries(writerEntries.map(([index, records]) => [index, records.length]));
241
+ const expectedEntries = Object.entries(expectedWriters);
242
+ const expectedIndexes = new Set(expectedEntries.map(([index]) => index));
243
+ const writerIndexes = new Set(writerEntries.map(([index]) => index));
244
+ const inconsistentWriters = writerEntries.some(([index, records]) => !expectedIndexes.has(index) || records.length !== expectedWriters[index])
245
+ || expectedEntries.some(([index, expected]) => !writerIndexes.has(index) && expected !== 0);
246
+ if (session && session.state !== "free") {
247
+ proof = unknownProof(runId, runnerClose.processInstanceId, session.state === "owned" ? "canonical-session-lease-active" : "canonical-session-unavailable");
248
+ } else if (candidate.revivalLeaseToken && candidate.revivalLeaseReleaseAcknowledged !== true) {
249
+ proof = unknownProof(runId, runnerClose.processInstanceId, "canonical-session-release-unverified");
250
+ } else if (inconsistentWriters || (allWriters.length === 0 && expectedEntries.length === 0)) {
251
+ proof = unknownProof(runId, runnerClose.processInstanceId, "writer-close-unverified");
252
+ } else {
253
+ const runner: ProcessInstanceExitV1 = { kind: "runner", ...runnerClose };
254
+ const canonicalSession = session && sessionProjection(candidate, session);
255
+ proof = {
256
+ version: 1,
257
+ state: "observed",
258
+ runId,
259
+ runnerProcessInstanceId: runnerClose.processInstanceId,
260
+ observedAt: runnerClose.closeObservedAt,
261
+ instances: [runner, ...allWriters],
262
+ resumeDisposition: resumeDisposition(status?.state, candidate.sessionFile ?? status?.sessionFile),
263
+ ...(canonicalSession ? { canonicalSession } : {}),
264
+ };
265
+ }
266
+ }
267
+ } catch (error) {
268
+ proof = unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", errorMessage(error));
269
+ }
270
+ let durable = false;
271
+ try {
272
+ writeAtomicJson(processTerminalPath(asyncDir), proof);
273
+ durable = true;
274
+ overlayStatus(asyncDir, proof, candidateForOverlay);
275
+ fs.appendFileSync(path.join(asyncDir, "events.jsonl"), `${JSON.stringify({ type: "subagent.run.process_terminal", lifecycleArtifactVersion: SUBAGENT_LIFECYCLE_ARTIFACT_VERSION, ts: Date.now(), runId, processTerminal: proof })}\n`, "utf-8");
276
+ } catch {
277
+ // Do not emit a process-terminal event when the proof sidecar was not durable.
278
+ }
279
+ return durable ? proof : unknownProof(runId, runnerClose.processInstanceId, "proof-write-failed", "Failed to persist process-terminal proof.");
280
+ }
@@ -8,6 +8,7 @@ import { formatModelThinking } from "../../shared/formatters.ts";
8
8
  import { formatActivityLabel } from "../../shared/status-format.ts";
9
9
  import { ASYNC_DIR, RESULTS_DIR, type AsyncStatus, type Details, type ForegroundResumeRun, type NestedRunSummary, type SteeringStatus, type SubagentState } from "../../shared/types.ts";
10
10
  import { resolveSubagentIntercomTarget } from "../../intercom/intercom-bridge.ts";
11
+ import { readProcessTerminal, sanitizeProcessTerminal } from "./process-terminal.ts";
11
12
  import { resolveAsyncRunLocation } from "./async-resume.ts";
12
13
  import { resolveSubagentRunId } from "./run-id-resolver.ts";
13
14
  import { flatToLogicalStepIndex, normalizeParallelGroups } from "./parallel-groups.ts";
@@ -351,10 +352,15 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
351
352
  const updated = status.lastUpdate ? new Date(status.lastUpdate).toISOString() : "n/a";
352
353
  const statusActivityText = status.state === "running" ? formatActivityLabel(status.lastActivityAt, status.activityState) : undefined;
353
354
  const steeringText = formatSteeringSummary(status);
355
+ const processTerminal = readProcessTerminal(asyncDir, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId })
356
+ ?? sanitizeProcessTerminal(status.processTerminal, { runId: status.runId, runnerProcessInstanceId: status.processTerminal?.runnerProcessInstanceId }, path.join(asyncDir, "status.json"));
354
357
 
355
358
  const lines = [
356
359
  `Run: ${status.runId}`,
357
360
  `State: ${status.state}`,
361
+ processTerminal ? `Process terminal: ${processTerminal.state}${processTerminal.reason ? ` (${processTerminal.reason})` : ""}` : undefined,
362
+ status.capabilityCeiling ? `Capability ceiling: ${status.capabilityCeiling.allowedTools === undefined ? "names unrestricted" : status.capabilityCeiling.allowedTools.length === 0 ? "none" : status.capabilityCeiling.allowedTools.join(", ")}\nExtensions denied: ${status.capabilityCeiling.denyExtensions ? "yes" : "no"} (sources: ${status.capabilityCeiling.sources.join(", ")})` : undefined,
363
+ status.capabilityAudit ? `Capability audit: ${status.capabilityAudit.removedTools.length} tools removed, ${status.capabilityAudit.removedExtensionCount} extension entries removed` : undefined,
358
364
  status.error ? `Error: ${status.error}` : undefined,
359
365
  statusActivityText ? `Activity: ${statusActivityText}` : undefined,
360
366
  steeringText ? `Steering: ${steeringText}` : undefined,
@@ -402,7 +408,7 @@ export function inspectSubagentStatus(params: RunStatusParams, deps: RunStatusDe
402
408
  if (fs.existsSync(logPath)) lines.push(`Log: ${logPath}`);
403
409
  if (fs.existsSync(eventsPath)) lines.push(`Events: ${eventsPath}`);
404
410
 
405
- return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [] } };
411
+ return { content: [{ type: "text", text: lines.join("\n") }], details: { mode: "single", results: [], ...(processTerminal ? { lifecycleStatus: { processTerminal } } : {}) } };
406
412
  }
407
413
  }
408
414
 
@@ -13,6 +13,7 @@ import {
13
13
  } from "../../shared/types.ts";
14
14
  import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
15
15
  import { validateExecutionAcceptance } from "../shared/acceptance.ts";
16
+ import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
16
17
 
17
18
  export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
18
19
  export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
@@ -57,6 +58,7 @@ type ScheduledRunManagerDeps = {
57
58
  storeRoot?: string;
58
59
  now?: () => number;
59
60
  randomId?: () => string;
61
+ resolveCapabilityCeiling?: (sessionId: string) => ResolvedSubagentCapabilityCeiling | undefined;
60
62
  timers?: ScheduledRunTimers;
61
63
  };
62
64
 
@@ -310,12 +312,15 @@ export class ScheduledRunManager {
310
312
  const sanitized = sanitizeScheduledParams(params);
311
313
  if (sanitized.error) return textResult(sanitized.error, true);
312
314
  const scheduleInput = params.schedule!.trim();
315
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
316
+ if (this.deps.resolveCapabilityCeiling?.(sessionId)) {
317
+ return textResult("Cannot schedule a capability-ceiling-restricted run because this store does not yet persist ceilings. Remove the active parent restriction or run it immediately.", true);
318
+ }
313
319
  const runAt = parseScheduledRunTime(scheduleInput, this.now());
314
320
  const pendingCount = store.list().filter((job) => job.state === "scheduled" || job.state === "running").length;
315
321
  const maxPending = resolveMaxPending(this.deps.config);
316
322
  if (pendingCount >= maxPending) return textResult(`Scheduled subagent limit reached (${pendingCount}/${maxPending} pending or running). Cancel an existing scheduled run before adding another.`, true);
317
323
  const id = this.randomId();
318
- const sessionId = resolveCurrentSessionId(ctx.sessionManager);
319
324
  const scheduleName = params.scheduleName?.trim();
320
325
  const executionParams = sanitized.params!;
321
326
  const now = this.now();
@@ -175,6 +175,9 @@ function terminalStatusFromResult(status: AsyncStatus, resultPath: string, now:
175
175
  return {
176
176
  ...status,
177
177
  state: repair.state,
178
+ ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
179
+ processTerminal: { version: 1 as const, state: "unknown" as const, runId: status.runId, runnerProcessInstanceId: "observer-unavailable", reason: "observer-unavailable" as const },
180
+ } : {}),
178
181
  ...(repair.state === "stopped" ? { stopped: true } : {}),
179
182
  activityState: undefined,
180
183
  lastUpdate: now,
@@ -231,6 +234,9 @@ function buildFailedRepair(status: AsyncStatus, asyncDir: string, now: number, r
231
234
  const repairedStatus: AsyncStatus = {
232
235
  ...status,
233
236
  state: "failed",
237
+ ...(status.lifecycleArtifactVersion === 3 && (!status.processTerminal || status.processTerminal.state === "pending") ? {
238
+ processTerminal: { version: 1 as const, state: "unknown" as const, runId, runnerProcessInstanceId: "observer-unavailable", reason: "stale-repair" as const },
239
+ } : {}),
234
240
  activityState: undefined,
235
241
  lastUpdate: now,
236
242
  endedAt: now,