pi-subagents 0.32.0 → 0.33.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 (52) hide show
  1. package/CHANGELOG.md +30 -3
  2. package/README.md +147 -58
  3. package/install.mjs +2 -1
  4. package/package.json +3 -2
  5. package/skills/pi-subagents/SKILL.md +70 -14
  6. package/src/agents/agent-management.ts +177 -5
  7. package/src/agents/agent-memory.ts +254 -0
  8. package/src/agents/agent-serializer.ts +11 -0
  9. package/src/agents/agents.ts +142 -12
  10. package/src/agents/chain-serializer.ts +27 -2
  11. package/src/extension/doctor.ts +1 -9
  12. package/src/extension/fanout-child.ts +2 -2
  13. package/src/extension/index.ts +65 -90
  14. package/src/extension/rpc.ts +369 -0
  15. package/src/extension/schemas.ts +52 -8
  16. package/src/extension/tool-description.ts +200 -0
  17. package/src/intercom/intercom-bridge.ts +21 -253
  18. package/src/intercom/native-supervisor-channel.ts +510 -0
  19. package/src/runs/background/async-execution.ts +51 -7
  20. package/src/runs/background/async-job-tracker.ts +12 -2
  21. package/src/runs/background/async-status.ts +27 -2
  22. package/src/runs/background/completion-batcher.ts +166 -0
  23. package/src/runs/background/control-channel.ts +106 -1
  24. package/src/runs/background/fleet-view.ts +515 -0
  25. package/src/runs/background/notify.ts +161 -44
  26. package/src/runs/background/result-watcher.ts +1 -2
  27. package/src/runs/background/run-id-resolver.ts +3 -2
  28. package/src/runs/background/run-status.ts +166 -6
  29. package/src/runs/background/scheduled-runs.ts +514 -0
  30. package/src/runs/background/subagent-runner.ts +409 -35
  31. package/src/runs/background/wait.ts +353 -0
  32. package/src/runs/foreground/chain-execution.ts +95 -21
  33. package/src/runs/foreground/execution.ts +150 -21
  34. package/src/runs/foreground/subagent-executor.ts +378 -64
  35. package/src/runs/shared/dynamic-fanout.ts +1 -1
  36. package/src/runs/shared/model-fallback.ts +167 -20
  37. package/src/runs/shared/model-scope.ts +128 -0
  38. package/src/runs/shared/nested-events.ts +31 -0
  39. package/src/runs/shared/parallel-utils.ts +1 -0
  40. package/src/runs/shared/pi-args.ts +30 -1
  41. package/src/runs/shared/subagent-prompt-runtime.ts +108 -2
  42. package/src/runs/shared/tool-budget.ts +74 -0
  43. package/src/runs/shared/turn-budget.ts +52 -0
  44. package/src/shared/artifacts.ts +1 -0
  45. package/src/shared/atomic-json.ts +15 -2
  46. package/src/shared/child-transcript.ts +212 -0
  47. package/src/shared/settings.ts +3 -1
  48. package/src/shared/types.ts +134 -19
  49. package/src/slash/prompt-workflows.ts +330 -0
  50. package/src/slash/slash-commands.ts +16 -2
  51. package/src/tui/render.ts +16 -8
  52. package/src/extension/companion-suggestions.ts +0 -359
@@ -0,0 +1,514 @@
1
+ import { createHash, randomUUID } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import type { AgentToolResult } from "@earendil-works/pi-agent-core";
5
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
6
+ import { writeAtomicJson } from "../../shared/atomic-json.ts";
7
+ import { formatDuration, shortenPath } from "../../shared/formatters.ts";
8
+ import { resolveCurrentSessionId } from "../../shared/session-identity.ts";
9
+ import {
10
+ TEMP_ROOT_DIR,
11
+ type Details,
12
+ type ExtensionConfig,
13
+ } from "../../shared/types.ts";
14
+ import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
15
+
16
+ export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
17
+ export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
18
+
19
+ const MAX_TIMER_DELAY_MS = 2_147_483_647;
20
+ const DEFAULT_MAX_LATENESS_MS = 5 * 60 * 1000;
21
+ const DEFAULT_MAX_PENDING = 20;
22
+
23
+ export type ScheduledRunAction = typeof SCHEDULED_RUN_ACTIONS[number];
24
+ export type ScheduledRunState = "scheduled" | "running" | "fired" | "canceled" | "missed" | "failed";
25
+
26
+ type ScheduledRunJob = {
27
+ id: string;
28
+ name: string;
29
+ schedule: string;
30
+ runAt: number;
31
+ state: ScheduledRunState;
32
+ createdAt: number;
33
+ updatedAt: number;
34
+ cwd: string;
35
+ sessionId: string;
36
+ params: SubagentParamsLike;
37
+ lastRunId?: string;
38
+ lastAsyncDir?: string;
39
+ lastError?: string;
40
+ firedAt?: number;
41
+ canceledAt?: number;
42
+ };
43
+
44
+ type ScheduledRunStoreData = {
45
+ version: 1;
46
+ cwd: string;
47
+ sessionId: string;
48
+ jobs: ScheduledRunJob[];
49
+ };
50
+
51
+ type ScheduledRunTimers = Pick<typeof globalThis, "setTimeout" | "clearTimeout">;
52
+
53
+ type ScheduledRunManagerDeps = {
54
+ config: ExtensionConfig;
55
+ launch(params: SubagentParamsLike, ctx: ExtensionContext, signal: AbortSignal): Promise<AgentToolResult<Details>>;
56
+ storeRoot?: string;
57
+ now?: () => number;
58
+ randomId?: () => string;
59
+ timers?: ScheduledRunTimers;
60
+ };
61
+
62
+ export function isScheduledRunAction(action: unknown): action is ScheduledRunAction {
63
+ return typeof action === "string" && (SCHEDULED_RUN_ACTIONS as readonly string[]).includes(action);
64
+ }
65
+
66
+ export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
67
+ return config.scheduledRuns?.enabled === true;
68
+ }
69
+
70
+ export function scheduledRunStorePath(cwd: string, sessionId: string, root = SCHEDULED_RUNS_DIR): string {
71
+ const digest = createHash("sha256").update(`${path.resolve(cwd)}\0${sessionId}`).digest("hex").slice(0, 20);
72
+ return path.join(root, `${digest}.json`);
73
+ }
74
+
75
+ export function parseScheduledRunTime(schedule: string, now = Date.now()): number {
76
+ const trimmed = schedule.trim();
77
+ const relative = trimmed.match(/^\+(\d+)(s|m|h|d)$/);
78
+ if (relative) {
79
+ const amount = Number(relative[1]);
80
+ if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid schedule "${schedule}". Relative schedules must be positive, such as "+10m".`);
81
+ const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2] as "s" | "m" | "h" | "d"];
82
+ const runAt = now + amount * unitMs;
83
+ if (!Number.isSafeInteger(runAt) || Number.isNaN(new Date(runAt).getTime())) throw new Error(`Invalid schedule "${schedule}". Relative delay is too large.`);
84
+ return runAt;
85
+ }
86
+ if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
87
+ const iso = trimmed.match(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2})(?:\.\d{1,3})?)?(Z|[+-]\d{2}:\d{2})$/);
88
+ if (!iso) throw new Error(`Invalid schedule "${schedule}". Absolute ISO timestamps must include a timezone, such as "2030-01-01T09:00:00Z".`);
89
+ const year = Number(iso[1]);
90
+ const month = Number(iso[2]);
91
+ const day = Number(iso[3]);
92
+ const hour = Number(iso[4]);
93
+ const minute = Number(iso[5]);
94
+ const second = iso[6] === undefined ? 0 : Number(iso[6]);
95
+ const offset = iso[7]!;
96
+ const offsetHour = offset === "Z" ? 0 : Number(offset.slice(1, 3));
97
+ const offsetMinute = offset === "Z" ? 0 : Number(offset.slice(4, 6));
98
+ const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
99
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
100
+ throw new Error(`Invalid schedule "${schedule}". Use a valid future ISO timestamp.`);
101
+ }
102
+ const parsed = new Date(trimmed).getTime();
103
+ if (!Number.isNaN(parsed)) {
104
+ if (parsed <= now) throw new Error(`Scheduled time ${new Date(parsed).toISOString()} is in the past.`);
105
+ return parsed;
106
+ }
107
+ }
108
+ throw new Error(`Invalid schedule "${schedule}". Use a one-shot relative delay like "+10m" or a future ISO timestamp with timezone.`);
109
+ }
110
+
111
+ function readStoreData(filePath: string, cwd: string, sessionId: string): ScheduledRunStoreData {
112
+ if (!fs.existsSync(filePath)) return { version: 1, cwd, sessionId, jobs: [] };
113
+ let parsed: unknown;
114
+ try {
115
+ parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
116
+ } catch (error) {
117
+ const message = error instanceof Error ? error.message : String(error);
118
+ throw new Error(`Failed to parse scheduled subagent store '${filePath}': ${message}`, { cause: error instanceof Error ? error : undefined });
119
+ }
120
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
121
+ throw new Error(`Scheduled subagent store '${filePath}' must be a JSON object.`);
122
+ }
123
+ const data = parsed as Partial<ScheduledRunStoreData>;
124
+ if (data.version !== 1) throw new Error(`Unsupported scheduled subagent store version in '${filePath}'.`);
125
+ if (!Array.isArray(data.jobs)) throw new Error(`Scheduled subagent store '${filePath}' must contain a jobs array.`);
126
+ const jobs: ScheduledRunJob[] = [];
127
+ const validStates = new Set<ScheduledRunState>(["scheduled", "running", "fired", "canceled", "missed", "failed"]);
128
+ for (const [index, job] of data.jobs.entries()) {
129
+ if (!job || typeof job !== "object" || Array.isArray(job)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} must be an object.`);
130
+ const candidate = job as Partial<ScheduledRunJob>;
131
+ if (typeof candidate.id !== "string" || typeof candidate.name !== "string" || typeof candidate.schedule !== "string" || typeof candidate.cwd !== "string" || typeof candidate.sessionId !== "string") {
132
+ throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid string fields.`);
133
+ }
134
+ const timestamps = [candidate.runAt, candidate.createdAt, candidate.updatedAt];
135
+ if (timestamps.some((value) => typeof value !== "number" || !Number.isFinite(value) || Number.isNaN(new Date(value).getTime()))) {
136
+ throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid timestamps.`);
137
+ }
138
+ if (!candidate.state || !validStates.has(candidate.state)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid state.`);
139
+ if (!candidate.params || typeof candidate.params !== "object" || Array.isArray(candidate.params)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid params.`);
140
+ jobs.push(candidate as ScheduledRunJob);
141
+ }
142
+ return {
143
+ version: 1,
144
+ cwd: typeof data.cwd === "string" ? data.cwd : cwd,
145
+ sessionId: typeof data.sessionId === "string" ? data.sessionId : sessionId,
146
+ jobs,
147
+ };
148
+ }
149
+
150
+ class ScheduledRunStore {
151
+ private readonly filePath: string;
152
+ private readonly cwd: string;
153
+ private readonly sessionId: string;
154
+
155
+ constructor(filePath: string, cwd: string, sessionId: string) {
156
+ this.filePath = filePath;
157
+ this.cwd = cwd;
158
+ this.sessionId = sessionId;
159
+ }
160
+
161
+ list(): ScheduledRunJob[] {
162
+ return readStoreData(this.filePath, this.cwd, this.sessionId).jobs;
163
+ }
164
+
165
+ get(id: string): ScheduledRunJob | undefined {
166
+ return this.list().find((job) => job.id === id);
167
+ }
168
+
169
+ mutate<T>(fn: (data: ScheduledRunStoreData) => T): T {
170
+ const data = readStoreData(this.filePath, this.cwd, this.sessionId);
171
+ const result = fn(data);
172
+ writeAtomicJson(this.filePath, data);
173
+ return result;
174
+ }
175
+ }
176
+
177
+ function resolveMaxLatenessMs(config: ExtensionConfig): number {
178
+ const value = config.scheduledRuns?.maxLatenessMs;
179
+ return Number.isInteger(value) && value >= 0 ? value : DEFAULT_MAX_LATENESS_MS;
180
+ }
181
+
182
+ function resolveMaxPending(config: ExtensionConfig): number {
183
+ const value = config.scheduledRuns?.maxPending;
184
+ return Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
185
+ }
186
+
187
+ function terminalState(state: ScheduledRunState): boolean {
188
+ return state === "fired" || state === "canceled" || state === "missed" || state === "failed";
189
+ }
190
+
191
+ function jobMode(params: SubagentParamsLike): Details["mode"] {
192
+ if ((params.chain?.length ?? 0) > 0) return "chain";
193
+ if ((params.tasks?.length ?? 0) > 0) return "parallel";
194
+ return "single";
195
+ }
196
+
197
+ function describeScheduledTarget(params: SubagentParamsLike): string {
198
+ if ((params.chain?.length ?? 0) > 0) return `chain (${params.chain!.length})`;
199
+ if ((params.tasks?.length ?? 0) > 0) return `parallel (${params.tasks!.length})`;
200
+ return params.agent ? `agent ${params.agent}` : "subagent run";
201
+ }
202
+
203
+ function textResult(text: string, isError = false): AgentToolResult<Details> {
204
+ return {
205
+ content: [{ type: "text", text }],
206
+ ...(isError ? { isError: true } : {}),
207
+ details: { mode: "management", results: [] },
208
+ };
209
+ }
210
+
211
+ function resolveJobById(jobs: ScheduledRunJob[], requestedId: string): ScheduledRunJob {
212
+ const exact = jobs.find((job) => job.id === requestedId);
213
+ if (exact) return exact;
214
+ const matches = jobs.filter((job) => job.id.startsWith(requestedId));
215
+ if (matches.length === 1) return matches[0]!;
216
+ if (matches.length > 1) throw new Error(`Ambiguous scheduled run id prefix '${requestedId}' matched: ${matches.map((job) => job.id).join(", ")}. Provide a longer id.`);
217
+ throw new Error(`Scheduled run '${requestedId}' not found.`);
218
+ }
219
+
220
+ function sanitizeScheduledParams(params: SubagentParamsLike): { params?: SubagentParamsLike; error?: string } {
221
+ const hasChain = (params.chain?.length ?? 0) > 0;
222
+ const hasTasks = (params.tasks?.length ?? 0) > 0;
223
+ const hasSingle = !hasChain && !hasTasks && Boolean(params.agent);
224
+ if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
225
+ return { error: "action='schedule' requires exactly one execution mode: agent, tasks, or chain." };
226
+ }
227
+ if (!params.schedule?.trim()) return { error: "action='schedule' requires schedule, such as '+10m' or a future ISO timestamp." };
228
+ if (params.context === "fork") return { error: "Scheduled subagent runs require fresh context. Forked parent-session context is not safe at fire time." };
229
+ if (params.async === false) return { error: "Scheduled subagent runs are always async; omit async or set async: true." };
230
+ if (params.clarify === true) return { error: "Scheduled subagent runs cannot open clarify UI; omit clarify or set clarify: false." };
231
+
232
+ const {
233
+ action: _action,
234
+ id: _id,
235
+ runId: _runId,
236
+ dir: _dir,
237
+ index: _index,
238
+ message: _message,
239
+ chainName: _chainName,
240
+ config: _config,
241
+ schedule: _schedule,
242
+ scheduleName: _scheduleName,
243
+ ...executionParams
244
+ } = params;
245
+ return { params: { ...executionParams, async: true, clarify: false, context: "fresh" } };
246
+ }
247
+
248
+ export class ScheduledRunManager {
249
+ private store: ScheduledRunStore | undefined;
250
+ private ctx: ExtensionContext | undefined;
251
+ private timers = new Map<string, ReturnType<typeof setTimeout>>();
252
+ private readonly storeRoot: string;
253
+ private readonly now: () => number;
254
+ private readonly randomId: () => string;
255
+ private readonly timersApi: ScheduledRunTimers;
256
+ private readonly deps: ScheduledRunManagerDeps;
257
+
258
+ constructor(deps: ScheduledRunManagerDeps) {
259
+ this.deps = deps;
260
+ this.storeRoot = deps.storeRoot ?? SCHEDULED_RUNS_DIR;
261
+ this.now = deps.now ?? Date.now;
262
+ this.randomId = deps.randomId ?? (() => randomUUID().slice(0, 8));
263
+ this.timersApi = deps.timers ?? globalThis;
264
+ }
265
+
266
+ bindSession(ctx: ExtensionContext): void {
267
+ this.stopTimers();
268
+ this.ctx = ctx;
269
+ if (!scheduledRunsEnabled(this.deps.config)) {
270
+ this.store = undefined;
271
+ return;
272
+ }
273
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
274
+ this.store = new ScheduledRunStore(scheduledRunStorePath(ctx.cwd, sessionId, this.storeRoot), ctx.cwd, sessionId);
275
+ this.rearmScheduledJobs();
276
+ }
277
+
278
+ stop(): void {
279
+ this.stopTimers();
280
+ this.store = undefined;
281
+ this.ctx = undefined;
282
+ }
283
+
284
+ async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
285
+ this.ctx = ctx;
286
+ try {
287
+ if (!scheduledRunsEnabled(this.deps.config)) {
288
+ return textResult("Scheduled subagent runs are disabled. Set { \"scheduledRuns\": { \"enabled\": true } } in ~/.pi/agent/extensions/subagent/config.json, then reload Pi. Schedule only explicit delayed runs the user asked for.", true);
289
+ }
290
+ if (!this.store) this.bindSession(ctx);
291
+ if (!this.store) return textResult("Scheduled subagent store is unavailable for this session.", true);
292
+ switch (params.action) {
293
+ case "schedule": return this.createJob(params, ctx);
294
+ case "schedule-list": return this.listJobs();
295
+ case "schedule-status": return this.statusJob(params);
296
+ case "schedule-cancel": return this.cancelJob(params);
297
+ default: return textResult(`Unknown scheduled-run action: ${params.action}`, true);
298
+ }
299
+ } catch (error) {
300
+ const message = error instanceof Error ? error.message : String(error);
301
+ return textResult(message, true);
302
+ }
303
+ }
304
+
305
+ private createJob(params: SubagentParamsLike, ctx: ExtensionContext): AgentToolResult<Details> {
306
+ const store = this.requireStore();
307
+ const sanitized = sanitizeScheduledParams(params);
308
+ if (sanitized.error) return textResult(sanitized.error, true);
309
+ const scheduleInput = params.schedule!.trim();
310
+ const runAt = parseScheduledRunTime(scheduleInput, this.now());
311
+ const pendingCount = store.list().filter((job) => job.state === "scheduled" || job.state === "running").length;
312
+ const maxPending = resolveMaxPending(this.deps.config);
313
+ if (pendingCount >= maxPending) return textResult(`Scheduled subagent limit reached (${pendingCount}/${maxPending} pending or running). Cancel an existing scheduled run before adding another.`, true);
314
+ const id = this.randomId();
315
+ const sessionId = resolveCurrentSessionId(ctx.sessionManager);
316
+ const scheduleName = params.scheduleName?.trim();
317
+ const executionParams = sanitized.params!;
318
+ const now = this.now();
319
+ const job: ScheduledRunJob = {
320
+ id,
321
+ name: scheduleName || describeScheduledTarget(executionParams),
322
+ schedule: scheduleInput,
323
+ runAt,
324
+ state: "scheduled",
325
+ createdAt: now,
326
+ updatedAt: now,
327
+ cwd: ctx.cwd,
328
+ sessionId,
329
+ params: executionParams,
330
+ };
331
+ store.mutate((data) => {
332
+ data.jobs.push(job);
333
+ });
334
+ this.arm(job);
335
+ return textResult([
336
+ `Scheduled subagent run ${job.id}.`,
337
+ `Name: ${job.name}`,
338
+ `When: ${new Date(job.runAt).toISOString()}`,
339
+ `Mode: ${jobMode(executionParams)}`,
340
+ `Context: fresh (scheduled runs never fork parent-session context)`,
341
+ `Status: subagent({ action: "schedule-status", id: "${job.id}" })`,
342
+ `Cancel before it fires: subagent({ action: "schedule-cancel", id: "${job.id}" })`,
343
+ ].join("\n"));
344
+ }
345
+
346
+ private listJobs(): AgentToolResult<Details> {
347
+ const jobs = this.requireStore().list().sort((left, right) => left.runAt - right.runAt);
348
+ if (jobs.length === 0) return textResult("No scheduled subagent runs for this session.");
349
+ const lines = [`Scheduled subagent runs: ${jobs.length}`, ""];
350
+ for (const job of jobs) {
351
+ const parts = [job.id, job.state, new Date(job.runAt).toISOString(), job.name];
352
+ if (job.lastRunId) parts.push(`run ${job.lastRunId}`);
353
+ if (job.lastError) parts.push(`error: ${job.lastError}`);
354
+ lines.push(`- ${parts.join(" | ")}`);
355
+ }
356
+ return textResult(lines.join("\n"));
357
+ }
358
+
359
+ private statusJob(params: SubagentParamsLike): AgentToolResult<Details> {
360
+ const requestedId = params.id ?? params.runId;
361
+ if (!requestedId) return textResult("action='schedule-status' requires id.", true);
362
+ const job = resolveJobById(this.requireStore().list(), requestedId);
363
+ const lines = [
364
+ `Scheduled run: ${job.id}`,
365
+ `Name: ${job.name}`,
366
+ `State: ${job.state}`,
367
+ `Schedule: ${job.schedule}`,
368
+ `Run at: ${new Date(job.runAt).toISOString()}`,
369
+ `Mode: ${jobMode(job.params)}`,
370
+ `CWD: ${shortenPath(job.cwd)}`,
371
+ `Created: ${new Date(job.createdAt).toISOString()}`,
372
+ `Updated: ${new Date(job.updatedAt).toISOString()}`,
373
+ job.lastRunId ? `Launched async run: ${job.lastRunId}` : undefined,
374
+ job.lastAsyncDir ? `Async dir: ${job.lastAsyncDir}` : undefined,
375
+ job.lastError ? `Error: ${job.lastError}` : undefined,
376
+ job.state === "scheduled" ? `Cancel: subagent({ action: "schedule-cancel", id: "${job.id}" })` : undefined,
377
+ job.lastRunId ? `Async status: subagent({ action: "status", id: "${job.lastRunId}" })` : undefined,
378
+ ].filter((line): line is string => Boolean(line));
379
+ return textResult(lines.join("\n"));
380
+ }
381
+
382
+ private cancelJob(params: SubagentParamsLike): AgentToolResult<Details> {
383
+ const requestedId = params.id ?? params.runId;
384
+ if (!requestedId) return textResult("action='schedule-cancel' requires id.", true);
385
+ const store = this.requireStore();
386
+ const job = resolveJobById(store.list(), requestedId);
387
+ if (job.state === "running") return textResult(`Scheduled run ${job.id} already launched async run ${job.lastRunId ?? "unknown"}; interrupt that async run instead.`, true);
388
+ if (terminalState(job.state)) return textResult(`Scheduled run ${job.id} is already ${job.state}.`, true);
389
+ const now = this.now();
390
+ this.clearTimer(job.id);
391
+ store.mutate((data) => {
392
+ const stored = data.jobs.find((candidate) => candidate.id === job.id);
393
+ if (!stored) return;
394
+ stored.state = "canceled";
395
+ stored.canceledAt = now;
396
+ stored.updatedAt = now;
397
+ });
398
+ return textResult(`Canceled scheduled subagent run ${job.id}.`);
399
+ }
400
+
401
+ private rearmScheduledJobs(): void {
402
+ const store = this.requireStore();
403
+ const now = this.now();
404
+ const maxLatenessMs = resolveMaxLatenessMs(this.deps.config);
405
+ const dueToMiss = store.list().filter((job) => job.state === "scheduled" && job.runAt + maxLatenessMs < now);
406
+ if (dueToMiss.length > 0) {
407
+ store.mutate((data) => {
408
+ for (const missed of dueToMiss) {
409
+ const job = data.jobs.find((candidate) => candidate.id === missed.id);
410
+ if (!job || job.state !== "scheduled") continue;
411
+ job.state = "missed";
412
+ job.updatedAt = now;
413
+ job.lastError = `Missed scheduled time by more than ${formatDuration(maxLatenessMs)} while Pi was not available.`;
414
+ }
415
+ });
416
+ }
417
+ for (const job of store.list()) {
418
+ if (job.state === "scheduled") this.arm(job);
419
+ }
420
+ }
421
+
422
+ private arm(job: ScheduledRunJob): void {
423
+ this.clearTimer(job.id);
424
+ const delayMs = Math.max(0, job.runAt - this.now());
425
+ const timer = this.timersApi.setTimeout(() => {
426
+ void this.fire(job.id);
427
+ }, Math.min(delayMs, MAX_TIMER_DELAY_MS));
428
+ timer.unref?.();
429
+ this.timers.set(job.id, timer);
430
+ }
431
+
432
+ private async fire(jobId: string): Promise<void> {
433
+ this.clearTimer(jobId);
434
+ const store = this.store;
435
+ const ctx = this.ctx;
436
+ if (!store || !ctx) return;
437
+ let job = store.get(jobId);
438
+ if (!job || job.state !== "scheduled") return;
439
+ const now = this.now();
440
+ // A timer capped at MAX_TIMER_DELAY_MS may fire before runAt for far-future schedules; re-arm and wait.
441
+ if (now < job.runAt) {
442
+ this.arm(job);
443
+ return;
444
+ }
445
+ const maxLatenessMs = resolveMaxLatenessMs(this.deps.config);
446
+ if (job.runAt + maxLatenessMs < now) {
447
+ store.mutate((data) => {
448
+ const stored = data.jobs.find((candidate) => candidate.id === jobId);
449
+ if (!stored || stored.state !== "scheduled") return;
450
+ stored.state = "missed";
451
+ stored.updatedAt = now;
452
+ stored.lastError = `Missed scheduled time by more than ${formatDuration(maxLatenessMs)}.`;
453
+ });
454
+ return;
455
+ }
456
+ store.mutate((data) => {
457
+ const stored = data.jobs.find((candidate) => candidate.id === jobId);
458
+ if (!stored || stored.state !== "scheduled") return;
459
+ stored.state = "running";
460
+ stored.firedAt = now;
461
+ stored.updatedAt = now;
462
+ });
463
+ job = store.get(jobId);
464
+ if (!job || job.state !== "running") return;
465
+ const controller = new AbortController();
466
+ try {
467
+ const result = await this.deps.launch(job.params, ctx, controller.signal);
468
+ const launchRunId = result.details?.asyncId ?? result.details?.runId;
469
+ store.mutate((data) => {
470
+ const stored = data.jobs.find((candidate) => candidate.id === jobId);
471
+ if (!stored) return;
472
+ stored.updatedAt = this.now();
473
+ if (result.isError || !launchRunId) {
474
+ stored.state = "failed";
475
+ stored.lastError = result.content.find((item) => item.type === "text")?.text ?? "Scheduled subagent launch failed.";
476
+ return;
477
+ }
478
+ stored.state = "fired";
479
+ stored.lastRunId = launchRunId;
480
+ stored.lastAsyncDir = result.details?.asyncDir;
481
+ });
482
+ } catch (error) {
483
+ const message = error instanceof Error ? error.message : String(error);
484
+ store.mutate((data) => {
485
+ const stored = data.jobs.find((candidate) => candidate.id === jobId);
486
+ if (!stored) return;
487
+ stored.state = "failed";
488
+ stored.lastError = message;
489
+ stored.updatedAt = this.now();
490
+ });
491
+ }
492
+ }
493
+
494
+ private requireStore(): ScheduledRunStore {
495
+ if (!this.store) throw new Error("Scheduled subagent store is not bound to a session.");
496
+ return this.store;
497
+ }
498
+
499
+ private clearTimer(jobId: string): void {
500
+ const timer = this.timers.get(jobId);
501
+ if (!timer) return;
502
+ this.timersApi.clearTimeout(timer);
503
+ this.timers.delete(jobId);
504
+ }
505
+
506
+ private stopTimers(): void {
507
+ for (const timer of this.timers.values()) this.timersApi.clearTimeout(timer);
508
+ this.timers.clear();
509
+ }
510
+ }
511
+
512
+ export function createScheduledRunManager(deps: ScheduledRunManagerDeps): ScheduledRunManager {
513
+ return new ScheduledRunManager(deps);
514
+ }