pi-subagents 0.40.0 → 0.41.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 (119) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +246 -525
  3. package/agents/oracle.md +1 -0
  4. package/package.json +12 -4
  5. package/prompts/parallel-context-build.md +1 -1
  6. package/prompts/parallel-handoff-plan.md +1 -1
  7. package/prompts/review-loop.md +1 -1
  8. package/skills/pi-subagents/SKILL.md +6 -6
  9. package/skills/pi-subagents/references/constraints-and-recipes.md +19 -26
  10. package/skills/pi-subagents/references/execution-controls.md +98 -97
  11. package/skills/pi-subagents/references/management-authoring-rpc.md +2 -2
  12. package/skills/pi-subagents/references/prompting-and-roles.md +18 -27
  13. package/src/agents/agent-management.ts +155 -65
  14. package/src/agents/agent-serializer.ts +19 -0
  15. package/src/agents/agents.ts +154 -71
  16. package/src/agents/chain-serializer.ts +10 -7
  17. package/src/agents/frontmatter.ts +5 -3
  18. package/src/agents/identity.ts +1 -1
  19. package/src/agents/proactive-skills.ts +13 -10
  20. package/src/agents/skills.ts +23 -6
  21. package/src/api/control-channel.ts +4 -0
  22. package/src/api/delegation.ts +26 -194
  23. package/src/api/external-runs.ts +129 -0
  24. package/src/api/intercom-bridge.ts +3 -0
  25. package/src/api/pi-args.ts +5 -0
  26. package/src/api/preflight.ts +3 -3
  27. package/src/api/shared-types.ts +19 -0
  28. package/src/extension/config.ts +10 -0
  29. package/src/extension/control-notices.ts +5 -39
  30. package/src/extension/doctor.ts +10 -9
  31. package/src/extension/fanout-child.ts +7 -4
  32. package/src/extension/index.ts +232 -68
  33. package/src/extension/rpc.ts +18 -12
  34. package/src/extension/schemas.ts +48 -37
  35. package/src/extension/tool-description.ts +36 -86
  36. package/src/inspectors/herdr/actions.ts +229 -0
  37. package/src/inspectors/herdr/client.ts +130 -0
  38. package/src/inspectors/herdr/inspector-runner.ts +141 -0
  39. package/src/inspectors/herdr/project-panes.ts +154 -0
  40. package/src/integrations/herdr-status.ts +330 -0
  41. package/src/intercom/intercom-bridge.ts +3 -2
  42. package/src/intercom/result-intercom.ts +5 -1
  43. package/src/missions/actions.ts +372 -0
  44. package/src/missions/lifecycle.ts +314 -0
  45. package/src/missions/store.ts +442 -0
  46. package/src/missions/types.ts +135 -0
  47. package/src/policy/authority.ts +46 -0
  48. package/src/profiles/profiles.ts +29 -3
  49. package/src/runs/background/async-execution.ts +98 -49
  50. package/src/runs/background/async-job-tracker.ts +10 -2
  51. package/src/runs/background/async-resume.ts +6 -6
  52. package/src/runs/background/async-status.ts +29 -1
  53. package/src/runs/background/auto-drain.ts +3 -3
  54. package/src/runs/background/chain-append.ts +3 -2
  55. package/src/runs/background/control-channel.ts +9 -7
  56. package/src/runs/background/fleet-view.ts +3 -4
  57. package/src/runs/background/notify.ts +2 -1
  58. package/src/runs/background/process-terminal.ts +5 -5
  59. package/src/runs/background/result-watcher.ts +13 -5
  60. package/src/runs/background/run-id-resolver.ts +3 -3
  61. package/src/runs/background/run-status.ts +35 -8
  62. package/src/runs/background/scheduled-runs.ts +602 -375
  63. package/src/runs/background/stale-run-reconciler.ts +3 -3
  64. package/src/runs/background/subagent-runner.ts +608 -445
  65. package/src/runs/background/subagent-wait.ts +50 -9
  66. package/src/runs/background/wait-subscriptions.ts +253 -0
  67. package/src/runs/background/wait-tool.ts +12 -4
  68. package/src/runs/foreground/async-steering-action.ts +3 -3
  69. package/src/runs/foreground/chain-clarify.ts +8 -4
  70. package/src/runs/foreground/chain-execution.ts +56 -30
  71. package/src/runs/foreground/execution.ts +15 -2
  72. package/src/runs/foreground/subagent-executor.ts +1023 -273
  73. package/src/runs/shared/acceptance.ts +28 -6
  74. package/src/runs/shared/child-protocol.ts +302 -22
  75. package/src/runs/shared/dynamic-fanout.ts +1 -1
  76. package/src/runs/shared/external-cli-runner.ts +130 -0
  77. package/src/runs/shared/long-running-guard.ts +42 -1
  78. package/src/runs/shared/nested-events.ts +59 -5
  79. package/src/runs/shared/nested-render.ts +9 -4
  80. package/src/runs/shared/parallel-handoff.ts +86 -2
  81. package/src/runs/shared/parallel-utils.ts +11 -2
  82. package/src/runs/shared/permissions.ts +95 -0
  83. package/src/runs/shared/pi-args.ts +11 -1
  84. package/src/runs/shared/pi-spawn.ts +11 -1
  85. package/src/runs/shared/run-history.ts +1 -1
  86. package/src/runs/shared/subagent-prompt-runtime.ts +36 -5
  87. package/src/runs/shared/subagent-startup-retry.ts +5 -2
  88. package/src/runs/shared/turn-budget.ts +6 -6
  89. package/src/runs/shared/worktree.ts +122 -12
  90. package/src/shared/accessible-dir.ts +29 -7
  91. package/src/shared/artifacts.ts +18 -1
  92. package/src/shared/fork-context.ts +3 -2
  93. package/src/shared/launch-contract.ts +1 -0
  94. package/src/shared/settings.ts +10 -0
  95. package/src/shared/types.ts +156 -14
  96. package/src/shared/utils.ts +8 -6
  97. package/src/slash/delegation-adapters.ts +32 -194
  98. package/src/slash/delegation-request.ts +43 -126
  99. package/src/slash/prompt-template-bridge.ts +158 -205
  100. package/src/slash/prompt-workflows.ts +21 -57
  101. package/src/slash/slash-bridge.ts +14 -0
  102. package/src/slash/slash-commands.ts +31 -632
  103. package/src/slash/subagents-admin.ts +18 -14
  104. package/src/tui/fleet-status.ts +156 -21
  105. package/src/tui/fleet-transcript.ts +110 -5
  106. package/src/tui/fleet.ts +56 -24
  107. package/src/tui/render.ts +291 -109
  108. package/src/types/pi-runtime-compat.d.ts +14 -0
  109. package/src/watchdog/lsp-diagnostics.ts +12 -7
  110. package/src/watchdog/model-selection.ts +2 -2
  111. package/src/watchdog/permission-arbiter.ts +145 -0
  112. package/src/watchdog/register-child.ts +1 -1
  113. package/src/watchdog/register-main.ts +1 -1
  114. package/src/watchdog/review.ts +4 -1
  115. package/src/watchdog/runtime.ts +3 -2
  116. package/src/workflows/chat-progress.ts +140 -0
  117. package/src/workflows/scripted-workflow.ts +415 -0
  118. package/agents/advisor.md +0 -73
  119. package/src/extension/chain-validation.ts +0 -181
@@ -3,54 +3,69 @@ 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 { 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";
6
+ import { writePrivateAtomicJson } from "../../shared/atomic-json.ts";
7
+ import { shortenPath } from "../../shared/formatters.ts";
8
+ import type { AsyncStatus, Details, ExtensionConfig } from "../../shared/types.ts";
14
9
  import type { SubagentParamsLike } from "../foreground/subagent-executor.ts";
15
10
  import { validateExecutionAcceptance } from "../shared/acceptance.ts";
16
11
  import type { ResolvedSubagentCapabilityCeiling } from "../shared/capability-ceiling.ts";
17
12
 
18
- export const SCHEDULED_RUNS_DIR = path.join(TEMP_ROOT_DIR, "scheduled-subagent-runs");
19
- export const SCHEDULED_RUN_ACTIONS = ["schedule", "schedule-list", "schedule-status", "schedule-cancel"] as const;
13
+ export const SCHEDULED_RUN_ACTIONS = [
14
+ "schedule.create",
15
+ "schedule.list",
16
+ "schedule.show",
17
+ "schedule.history",
18
+ "schedule.pause",
19
+ "schedule.resume",
20
+ "schedule.run",
21
+ "schedule.run-due",
22
+ "schedule.delete",
23
+ ] as const;
20
24
 
21
25
  const MAX_TIMER_DELAY_MS = 2_147_483_647;
22
- const DEFAULT_MAX_LATENESS_MS = 5 * 60 * 1000;
23
26
  const DEFAULT_MAX_PENDING = 20;
27
+ const MAX_HISTORY = 100;
28
+ const STALE_LAUNCH_CLAIM_MS = 5 * 60_000;
29
+ const SCHEDULE_ID = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
24
30
 
31
+ type ScheduledRunTimers = Pick<typeof globalThis, "setTimeout" | "clearTimeout">;
25
32
  export type ScheduledRunAction = typeof SCHEDULED_RUN_ACTIONS[number];
26
- export type ScheduledRunState = "scheduled" | "running" | "fired" | "canceled" | "missed" | "failed";
27
-
28
- type ScheduledRunJob = {
33
+ export type ScheduleRunState = "running" | "skipped" | "missed" | "completed" | "failed_launch" | "failed_run";
34
+ export type ScheduleTrigger =
35
+ | { kind: "once"; at: string; nextRunAt?: string }
36
+ | { kind: "interval"; every: string; everyMs: number; anchorAt: string; nextRunAt: string };
37
+ export type ScheduleTarget = { workflowScript: string } | { agent: string; task?: string };
38
+
39
+ export interface ScheduleRecord {
40
+ schemaVersion: 1;
29
41
  id: string;
30
42
  name: string;
31
- schedule: string;
32
- runAt: number;
33
- state: ScheduledRunState;
34
- createdAt: number;
35
- updatedAt: number;
36
43
  cwd: string;
37
- sessionId: string;
38
- params: SubagentParamsLike;
44
+ trigger: ScheduleTrigger;
45
+ target: ScheduleTarget;
46
+ overlap: "skip";
47
+ catchUp: "none" | "latest";
48
+ timeoutMs?: number;
49
+ paused: boolean;
50
+ createdAt: string;
51
+ updatedAt: string;
52
+ activeRunId?: string;
39
53
  lastRunId?: string;
40
- lastAsyncDir?: string;
41
- lastError?: string;
42
- firedAt?: number;
43
- canceledAt?: number;
44
- };
45
-
46
- type ScheduledRunStoreData = {
47
- version: 1;
48
- cwd: string;
49
- sessionId: string;
50
- jobs: ScheduledRunJob[];
51
- };
54
+ }
52
55
 
53
- type ScheduledRunTimers = Pick<typeof globalThis, "setTimeout" | "clearTimeout">;
56
+ export interface ScheduleRunRecord {
57
+ schemaVersion: 1;
58
+ id: string;
59
+ scheduleId: string;
60
+ plannedAt: string;
61
+ dueReason: "timer" | "run-due" | "manual";
62
+ state: ScheduleRunState;
63
+ startedAt?: string;
64
+ completedAt?: string;
65
+ asyncId?: string;
66
+ asyncDir?: string;
67
+ error?: string;
68
+ }
54
69
 
55
70
  type ScheduledRunManagerDeps = {
56
71
  config: ExtensionConfig;
@@ -67,194 +82,280 @@ export function isScheduledRunAction(action: unknown): action is ScheduledRunAct
67
82
  }
68
83
 
69
84
  export function scheduledRunsEnabled(config: ExtensionConfig): boolean {
70
- return config.scheduledRuns?.enabled === true;
85
+ return config.scheduledRuns?.enabled !== false;
71
86
  }
72
87
 
73
- export function scheduledRunStorePath(cwd: string, sessionId: string, root = SCHEDULED_RUNS_DIR): string {
74
- const digest = createHash("sha256").update(`${path.resolve(cwd)}\0${sessionId}`).digest("hex").slice(0, 20);
75
- return path.join(root, `${digest}.json`);
88
+ export function scheduledRunStorePath(cwd: string, _sessionId?: string, root?: string): string {
89
+ if (!root) return path.join(path.resolve(cwd), ".pi-subagents", "schedules");
90
+ const projectKey = createHash("sha256").update(path.resolve(cwd)).digest("hex").slice(0, 20);
91
+ return path.join(root, projectKey);
76
92
  }
77
93
 
78
- export function parseScheduledRunTime(schedule: string, now = Date.now()): number {
79
- const trimmed = schedule.trim();
94
+ export function parseScheduledRunTime(at: string, now = Date.now()): number {
95
+ const trimmed = at.trim();
80
96
  const relative = trimmed.match(/^\+(\d+)(s|m|h|d)$/);
81
97
  if (relative) {
82
98
  const amount = Number(relative[1]);
83
- if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid schedule "${schedule}". Relative schedules must be positive, such as "+10m".`);
84
- const unitMs = { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2] as "s" | "m" | "h" | "d"];
85
- const runAt = now + amount * unitMs;
86
- if (!Number.isSafeInteger(runAt) || Number.isNaN(new Date(runAt).getTime())) throw new Error(`Invalid schedule "${schedule}". Relative delay is too large.`);
87
- return runAt;
88
- }
89
- if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
90
- 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})$/);
91
- if (!iso) throw new Error(`Invalid schedule "${schedule}". Absolute ISO timestamps must include a timezone, such as "2030-01-01T09:00:00Z".`);
92
- const year = Number(iso[1]);
93
- const month = Number(iso[2]);
94
- const day = Number(iso[3]);
95
- const hour = Number(iso[4]);
96
- const minute = Number(iso[5]);
97
- const second = iso[6] === undefined ? 0 : Number(iso[6]);
98
- const offset = iso[7]!;
99
- const offsetHour = offset === "Z" ? 0 : Number(offset.slice(1, 3));
100
- const offsetMinute = offset === "Z" ? 0 : Number(offset.slice(4, 6));
101
- const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
102
- if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59) {
103
- throw new Error(`Invalid schedule "${schedule}". Use a valid future ISO timestamp.`);
104
- }
105
- const parsed = new Date(trimmed).getTime();
106
- if (!Number.isNaN(parsed)) {
107
- if (parsed <= now) throw new Error(`Scheduled time ${new Date(parsed).toISOString()} is in the past.`);
108
- return parsed;
109
- }
99
+ if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid at value "${at}". Relative delays must be positive, such as "+10m".`);
100
+ const unitMs = { s: 1_000, m: 60_000, h: 3_600_000, d: 86_400_000 }[relative[2] as "s" | "m" | "h" | "d"];
101
+ const result = now + amount * unitMs;
102
+ if (!Number.isSafeInteger(result)) throw new Error(`Invalid at value "${at}". Relative delay is too large.`);
103
+ return result;
110
104
  }
111
- throw new Error(`Invalid schedule "${schedule}". Use a one-shot relative delay like "+10m" or a future ISO timestamp with timezone.`);
105
+ 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})$/);
106
+ if (!iso) throw new Error(`Invalid at value "${at}". Use a one-shot delay such as "+10m" or an ISO timestamp with timezone.`);
107
+ const year = Number(iso[1]);
108
+ const month = Number(iso[2]);
109
+ const day = Number(iso[3]);
110
+ const hour = Number(iso[4]);
111
+ const minute = Number(iso[5]);
112
+ const second = iso[6] === undefined ? 0 : Number(iso[6]);
113
+ const zone = iso[7]!;
114
+ const offsetHour = zone === "Z" ? 0 : Number(zone.slice(1, 3));
115
+ const offsetMinute = zone === "Z" ? 0 : Number(zone.slice(4, 6));
116
+ const daysInMonth = month >= 1 && month <= 12 ? new Date(Date.UTC(year, month, 0)).getUTCDate() : 0;
117
+ const parsed = Date.parse(trimmed);
118
+ if (month < 1 || month > 12 || day < 1 || day > daysInMonth || hour > 23 || minute > 59 || second > 59 || offsetHour > 23 || offsetMinute > 59 || !Number.isFinite(parsed)) throw new Error(`Invalid at value "${at}". Use a valid ISO timestamp.`);
119
+ if (parsed <= now) throw new Error(`Scheduled time ${new Date(parsed).toISOString()} is in the past.`);
120
+ return parsed;
121
+ }
122
+
123
+ export function parseScheduleInterval(every: string): number {
124
+ const match = every.trim().match(/^(\d+)(m|h|d|w)$/);
125
+ if (!match) throw new Error(`Invalid every value "${every}". This first recurring slice supports fixed intervals such as "30m", "6h", "2d", or "2w".`);
126
+ const amount = Number(match[1]);
127
+ if (!Number.isSafeInteger(amount) || amount < 1) throw new Error(`Invalid every value "${every}". Interval must be positive.`);
128
+ const unitMs = { m: 60_000, h: 3_600_000, d: 86_400_000, w: 604_800_000 }[match[2] as "m" | "h" | "d" | "w"];
129
+ const result = amount * unitMs;
130
+ if (!Number.isSafeInteger(result)) throw new Error(`Invalid every value "${every}". Interval is too large.`);
131
+ return result;
112
132
  }
113
133
 
114
- function readStoreData(filePath: string, cwd: string, sessionId: string): ScheduledRunStoreData {
115
- if (!fs.existsSync(filePath)) return { version: 1, cwd, sessionId, jobs: [] };
116
- let parsed: unknown;
134
+ function timestamp(value: number): string {
135
+ return new Date(value).toISOString();
136
+ }
137
+
138
+ function validateScheduleId(id: string): string {
139
+ if (!SCHEDULE_ID.test(id)) throw new Error("Schedule id must be 1-64 characters and contain only letters, numbers, '.', '_', or '-'.");
140
+ return id;
141
+ }
142
+
143
+ function pathWithin(root: string, candidate: string): boolean {
144
+ const relative = path.relative(root, candidate);
145
+ return relative === "" || (relative !== ".." && !relative.startsWith(`..${path.sep}`) && !path.isAbsolute(relative));
146
+ }
147
+
148
+ function assertScheduleRoot(root: string, projectCwd: string | undefined, create: boolean): void {
149
+ if (!projectCwd) {
150
+ if (create) fs.mkdirSync(root, { recursive: true, mode: 0o700 });
151
+ return;
152
+ }
153
+ const projectPath = fs.realpathSync(projectCwd);
154
+ let existing = root;
155
+ while (!fs.existsSync(existing)) {
156
+ const parent = path.dirname(existing);
157
+ if (parent === existing) break;
158
+ existing = parent;
159
+ }
160
+ if (!pathWithin(projectPath, fs.realpathSync(existing))) throw new Error(`Project schedule root '${root}' resolves outside the real project.`);
161
+ if (!create) return;
162
+ fs.mkdirSync(root, { recursive: true, mode: 0o700 });
163
+ if (!pathWithin(projectPath, fs.realpathSync(root))) throw new Error(`Project schedule root '${root}' resolves outside the real project.`);
164
+ }
165
+
166
+ function scheduleDir(root: string, id: string, create = false, projectCwd?: string): string {
167
+ assertScheduleRoot(root, projectCwd, create);
168
+ const dir = path.join(root, validateScheduleId(id));
117
169
  try {
118
- parsed = JSON.parse(fs.readFileSync(filePath, "utf-8"));
170
+ const stat = fs.lstatSync(dir);
171
+ if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`Schedule path '${dir}' must be a real directory.`);
119
172
  } catch (error) {
120
- const message = error instanceof Error ? error.message : String(error);
121
- throw new Error(`Failed to parse scheduled subagent store '${filePath}': ${message}`, { cause: error instanceof Error ? error : undefined });
122
- }
123
- if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
124
- throw new Error(`Scheduled subagent store '${filePath}' must be a JSON object.`);
125
- }
126
- const data = parsed as Partial<ScheduledRunStoreData>;
127
- if (data.version !== 1) throw new Error(`Unsupported scheduled subagent store version in '${filePath}'.`);
128
- if (!Array.isArray(data.jobs)) throw new Error(`Scheduled subagent store '${filePath}' must contain a jobs array.`);
129
- const jobs: ScheduledRunJob[] = [];
130
- const validStates = new Set<ScheduledRunState>(["scheduled", "running", "fired", "canceled", "missed", "failed"]);
131
- for (const [index, job] of data.jobs.entries()) {
132
- if (!job || typeof job !== "object" || Array.isArray(job)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} must be an object.`);
133
- const candidate = job as Partial<ScheduledRunJob>;
134
- if (typeof candidate.id !== "string" || typeof candidate.name !== "string" || typeof candidate.schedule !== "string" || typeof candidate.cwd !== "string" || typeof candidate.sessionId !== "string") {
135
- throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid string fields.`);
136
- }
137
- const timestamps = [candidate.runAt, candidate.createdAt, candidate.updatedAt];
138
- if (timestamps.some((value) => typeof value !== "number" || !Number.isFinite(value) || Number.isNaN(new Date(value).getTime()))) {
139
- throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid timestamps.`);
140
- }
141
- if (!candidate.state || !validStates.has(candidate.state)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid state.`);
142
- if (!candidate.params || typeof candidate.params !== "object" || Array.isArray(candidate.params)) throw new Error(`Scheduled subagent store '${filePath}' job ${index} has invalid params.`);
143
- jobs.push(candidate as ScheduledRunJob);
173
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
174
+ if (!create) return dir;
175
+ fs.mkdirSync(dir, { mode: 0o700 });
144
176
  }
145
- return {
146
- version: 1,
147
- cwd: typeof data.cwd === "string" ? data.cwd : cwd,
148
- sessionId: typeof data.sessionId === "string" ? data.sessionId : sessionId,
149
- jobs,
150
- };
177
+ const rootPath = fs.realpathSync(root);
178
+ const dirPath = fs.realpathSync(dir);
179
+ if (dirPath !== path.join(rootPath, id)) throw new Error(`Schedule path '${dir}' escapes the project schedule root.`);
180
+ return dir;
151
181
  }
152
182
 
153
- class ScheduledRunStore {
154
- private readonly filePath: string;
155
- private readonly cwd: string;
156
- private readonly sessionId: string;
183
+ function readJson(file: string, label: string): unknown {
184
+ try {
185
+ return JSON.parse(fs.readFileSync(file, "utf-8"));
186
+ } catch (error) {
187
+ throw new Error(`Failed to read ${label} '${file}': ${error instanceof Error ? error.message : String(error)}`, { cause: error instanceof Error ? error : undefined });
188
+ }
189
+ }
190
+
191
+ function parseSchedule(value: unknown, file: string): ScheduleRecord {
192
+ if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error(`Schedule record '${file}' must be a JSON object.`);
193
+ const record = value as Partial<ScheduleRecord>;
194
+ if (record.schemaVersion !== 1 || typeof record.id !== "string" || typeof record.name !== "string" || typeof record.cwd !== "string" || typeof record.createdAt !== "string" || typeof record.updatedAt !== "string" || typeof record.paused !== "boolean") throw new Error(`Schedule record '${file}' has invalid required fields.`);
195
+ validateScheduleId(record.id);
196
+ if (!record.trigger || typeof record.trigger !== "object" || !record.target || typeof record.target !== "object") throw new Error(`Schedule record '${file}' has invalid trigger or target.`);
197
+ if (record.overlap !== "skip" || (record.catchUp !== "none" && record.catchUp !== "latest")) throw new Error(`Schedule record '${file}' has unsupported policy fields.`);
198
+ if (record.trigger.kind === "once") {
199
+ if (typeof record.trigger.at !== "string" || (record.trigger.nextRunAt !== undefined && typeof record.trigger.nextRunAt !== "string")) throw new Error(`Schedule record '${file}' has an invalid one-shot trigger.`);
200
+ } else if (record.trigger.kind === "interval") {
201
+ if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string") throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
202
+ } else throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
203
+ const workflow = "workflowScript" in record.target && typeof record.target.workflowScript === "string";
204
+ const agent = "agent" in record.target && typeof record.target.agent === "string";
205
+ if (workflow === agent) throw new Error(`Schedule record '${file}' has an invalid target.`);
206
+ return record as ScheduleRecord;
207
+ }
157
208
 
158
- constructor(filePath: string, cwd: string, sessionId: string) {
159
- this.filePath = filePath;
160
- this.cwd = cwd;
161
- this.sessionId = sessionId;
209
+ class ScheduleStore {
210
+ readonly root: string;
211
+ private readonly projectCwd?: string;
212
+
213
+ constructor(root: string, projectCwd?: string) {
214
+ this.root = root;
215
+ this.projectCwd = projectCwd;
162
216
  }
163
217
 
164
- list(): ScheduledRunJob[] {
165
- return readStoreData(this.filePath, this.cwd, this.sessionId).jobs;
218
+ directory(id: string, create = false): string {
219
+ return scheduleDir(this.root, id, create, this.projectCwd);
166
220
  }
167
221
 
168
- get(id: string): ScheduledRunJob | undefined {
169
- return this.list().find((job) => job.id === id);
222
+ ids(): string[] {
223
+ assertScheduleRoot(this.root, this.projectCwd, false);
224
+ if (!fs.existsSync(this.root)) return [];
225
+ return fs.readdirSync(this.root, { withFileTypes: true })
226
+ .filter((entry) => entry.isDirectory() && SCHEDULE_ID.test(entry.name))
227
+ .map((entry) => entry.name);
170
228
  }
171
229
 
172
- mutate<T>(fn: (data: ScheduledRunStoreData) => T): T {
173
- const data = readStoreData(this.filePath, this.cwd, this.sessionId);
174
- const result = fn(data);
175
- writeAtomicJson(this.filePath, data);
176
- return result;
230
+ list(): ScheduleRecord[] {
231
+ return this.ids().map((id) => this.get(id));
232
+ }
233
+
234
+ get(id: string): ScheduleRecord {
235
+ const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "schedule.json");
236
+ if (!fs.existsSync(file)) throw new Error(`Schedule '${id}' not found.`);
237
+ return parseSchedule(readJson(file, "schedule record"), file);
238
+ }
239
+
240
+ write(record: ScheduleRecord): void {
241
+ writePrivateAtomicJson(path.join(scheduleDir(this.root, record.id, true, this.projectCwd), "schedule.json"), record);
242
+ }
243
+
244
+ delete(id: string): void {
245
+ fs.rmSync(scheduleDir(this.root, id, false, this.projectCwd), { recursive: true, force: true });
246
+ }
247
+
248
+ history(id: string): ScheduleRunRecord[] {
249
+ const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "history.json");
250
+ if (!fs.existsSync(file)) return [];
251
+ const value = readJson(file, "schedule history") as { schemaVersion?: unknown; runs?: unknown };
252
+ if (value?.schemaVersion !== 1 || !Array.isArray(value.runs)) throw new Error(`Schedule history '${file}' has invalid fields.`);
253
+ return value.runs as ScheduleRunRecord[];
177
254
  }
178
- }
179
255
 
180
- function resolveMaxLatenessMs(config: ExtensionConfig): number {
181
- const value = config.scheduledRuns?.maxLatenessMs;
182
- return Number.isInteger(value) && value >= 0 ? value : DEFAULT_MAX_LATENESS_MS;
256
+ writeRun(schedule: ScheduleRecord, run: ScheduleRunRecord, event: string): void {
257
+ const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
258
+ writePrivateAtomicJson(path.join(dir, "runs", `${run.id}.json`), run);
259
+ const runs = [run, ...this.history(schedule.id).filter((item) => item.id !== run.id)].slice(0, MAX_HISTORY);
260
+ writePrivateAtomicJson(path.join(dir, "history.json"), { schemaVersion: 1, runs });
261
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
262
+ fs.appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify({ schemaVersion: 1, timestamp: new Date().toISOString(), event, scheduleId: schedule.id, runId: run.id, state: run.state })}\n`, { encoding: "utf-8", mode: 0o600 });
263
+ }
264
+
265
+ appendEvent(schedule: ScheduleRecord, event: string): void {
266
+ const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
267
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
268
+ fs.appendFileSync(path.join(dir, "events.jsonl"), `${JSON.stringify({ schemaVersion: 1, timestamp: new Date().toISOString(), event, scheduleId: schedule.id })}\n`, { encoding: "utf-8", mode: 0o600 });
269
+ }
183
270
  }
184
271
 
185
272
  function resolveMaxPending(config: ExtensionConfig): number {
186
273
  const value = config.scheduledRuns?.maxPending;
187
- return Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
274
+ return typeof value === "number" && Number.isInteger(value) && value >= 1 ? value : DEFAULT_MAX_PENDING;
188
275
  }
189
276
 
190
- function terminalState(state: ScheduledRunState): boolean {
191
- return state === "fired" || state === "canceled" || state === "missed" || state === "failed";
277
+ function nextAfter(trigger: ScheduleTrigger, plannedAt: number, now: number): string | undefined {
278
+ if (trigger.kind === "once") return undefined;
279
+ let next = plannedAt + trigger.everyMs;
280
+ while (next <= now) next += trigger.everyMs;
281
+ return timestamp(next);
192
282
  }
193
283
 
194
- function jobMode(params: SubagentParamsLike): Details["mode"] {
195
- if ((params.chain?.length ?? 0) > 0) return "chain";
196
- if ((params.tasks?.length ?? 0) > 0) return "parallel";
197
- return "single";
284
+ function nextRunAt(schedule: ScheduleRecord): number | undefined {
285
+ const value = schedule.trigger.nextRunAt;
286
+ if (!value) return undefined;
287
+ const parsed = Date.parse(value);
288
+ if (!Number.isFinite(parsed)) throw new Error(`Schedule '${schedule.id}' has invalid nextRunAt.`);
289
+ return parsed;
198
290
  }
199
291
 
200
- function describeScheduledTarget(params: SubagentParamsLike): string {
201
- if ((params.chain?.length ?? 0) > 0) return `chain (${params.chain!.length})`;
202
- if ((params.tasks?.length ?? 0) > 0) return `parallel (${params.tasks!.length})`;
203
- return params.agent ? `agent ${params.agent}` : "subagent run";
292
+ function duePlannedAt(schedule: ScheduleRecord, now: number): number | undefined {
293
+ const next = nextRunAt(schedule);
294
+ if (next === undefined || next > now || schedule.catchUp !== "latest" || schedule.trigger.kind !== "interval") return next;
295
+ return next + Math.floor((now - next) / schedule.trigger.everyMs) * schedule.trigger.everyMs;
204
296
  }
205
297
 
206
- function textResult(text: string, isError = false): AgentToolResult<Details> {
298
+ function textResult(text: string, schedules?: ScheduleRecord[], runs?: ScheduleRunRecord[], isError = false): AgentToolResult<Details> {
207
299
  return {
208
300
  content: [{ type: "text", text }],
209
301
  ...(isError ? { isError: true } : {}),
210
- details: { mode: "management", results: [] },
302
+ details: { mode: "management", results: [], schedules: { ...(schedules ? { records: schedules } : {}), ...(runs ? { runs } : {}) } },
211
303
  };
212
304
  }
213
305
 
214
- function resolveJobById(jobs: ScheduledRunJob[], requestedId: string): ScheduledRunJob {
215
- const exact = jobs.find((job) => job.id === requestedId);
216
- if (exact) return exact;
217
- const matches = jobs.filter((job) => job.id.startsWith(requestedId));
218
- if (matches.length === 1) return matches[0]!;
219
- if (matches.length > 1) throw new Error(`Ambiguous scheduled run id prefix '${requestedId}' matched: ${matches.map((job) => job.id).join(", ")}. Provide a longer id.`);
220
- throw new Error(`Scheduled run '${requestedId}' not found.`);
306
+ function targetLabel(target: ScheduleTarget): string {
307
+ return "workflowScript" in target ? "workflowScript" : `agent ${target.agent}`;
308
+ }
309
+
310
+ function sanitizeTarget(params: SubagentParamsLike): { target?: ScheduleTarget; error?: string } {
311
+ if (params.tasks || params.chain) return { error: "Recurring schedules support workflowScript or one agent/task target, not legacy tasks or chain inputs." };
312
+ const hasWorkflow = typeof params.workflowScript === "string" && params.workflowScript.trim().length > 0;
313
+ const hasAgent = typeof params.agent === "string" && params.agent.trim().length > 0;
314
+ if (hasWorkflow === hasAgent) return { error: "schedule.create requires exactly one target: workflowScript or agent with optional task." };
315
+ if (params.context === "fork") return { error: "Scheduled runs require fresh context." };
316
+ if (params.async === false) return { error: "Scheduled runs are always async." };
317
+ if (params.clarify === true) return { error: "Scheduled runs cannot open clarify UI." };
318
+ const acceptanceErrors = validateExecutionAcceptance(params as Parameters<typeof validateExecutionAcceptance>[0]);
319
+ if (acceptanceErrors.length) return { error: acceptanceErrors.join(" ") };
320
+ return hasWorkflow ? { target: { workflowScript: params.workflowScript!.trim() } } : { target: { agent: params.agent!.trim(), ...(params.task === undefined ? {} : { task: params.task }) } };
221
321
  }
222
322
 
223
- function sanitizeScheduledParams(params: SubagentParamsLike): { params?: SubagentParamsLike; error?: string } {
224
- const hasChain = (params.chain?.length ?? 0) > 0;
225
- const hasTasks = (params.tasks?.length ?? 0) > 0;
226
- const hasSingle = !hasChain && !hasTasks && Boolean(params.agent);
227
- if (Number(hasChain) + Number(hasTasks) + Number(hasSingle) !== 1) {
228
- return { error: "action='schedule' requires exactly one execution mode: agent, tasks, or chain." };
229
- }
230
- if (!params.schedule?.trim()) return { error: "action='schedule' requires schedule, such as '+10m' or a future ISO timestamp." };
231
- if (params.context === "fork") return { error: "Scheduled subagent runs require fresh context. Forked parent-session context is not safe at fire time." };
232
- if (params.async === false) return { error: "Scheduled subagent runs are always async; omit async or set async: true." };
233
- if (params.clarify === true) return { error: "Scheduled subagent runs cannot open clarify UI; omit clarify or set clarify: false." };
234
- const acceptanceErrors = validateExecutionAcceptance(params);
235
- if (acceptanceErrors.length > 0) return { error: acceptanceErrors.join(" ") };
236
-
237
- const {
238
- action: _action,
239
- id: _id,
240
- runId: _runId,
241
- dir: _dir,
242
- index: _index,
243
- message: _message,
244
- chainName: _chainName,
245
- config: _config,
246
- schedule: _schedule,
247
- scheduleName: _scheduleName,
248
- ...executionParams
249
- } = params;
250
- return { params: { ...executionParams, async: true, clarify: false, context: "fresh" } };
323
+ function executionParams(schedule: ScheduleRecord): SubagentParamsLike {
324
+ return {
325
+ ...schedule.target,
326
+ async: true,
327
+ clarify: false,
328
+ context: "fresh",
329
+ cwd: schedule.cwd,
330
+ mission: false,
331
+ ...(schedule.timeoutMs === undefined ? {} : { timeoutMs: schedule.timeoutMs }),
332
+ };
333
+ }
334
+
335
+ function snapshotContext(ctx: ExtensionContext, cwd: string): ExtensionContext {
336
+ const source = ctx.sessionManager;
337
+ const sessionId = source.getSessionId();
338
+ const sessionFile = source.getSessionFile();
339
+ const sessionManager = new Proxy(source, {
340
+ get(target, property) {
341
+ if (property === "getSessionId") return () => sessionId;
342
+ if (property === "getSessionFile") return () => sessionFile;
343
+ const value = Reflect.get(target, property, target) as unknown;
344
+ return typeof value === "function" ? value.bind(target) : value;
345
+ },
346
+ });
347
+ return { ...ctx, cwd, sessionManager };
348
+ }
349
+
350
+ export function listScheduledRunSummaries(cwd: string, root?: string): ScheduleRecord[] {
351
+ return new ScheduleStore(scheduledRunStorePath(cwd, undefined, root), root === undefined ? path.resolve(cwd) : undefined).list();
251
352
  }
252
353
 
253
354
  export class ScheduledRunManager {
254
- private store: ScheduledRunStore | undefined;
255
- private ctx: ExtensionContext | undefined;
256
- private timers = new Map<string, ReturnType<typeof setTimeout>>();
257
- private readonly storeRoot: string;
355
+ private store?: ScheduleStore;
356
+ private readonly stores = new Map<string, ScheduleStore>();
357
+ private readonly contexts = new Map<string, ExtensionContext>();
358
+ private readonly timers = new Map<string, ReturnType<typeof setTimeout>>();
258
359
  private readonly now: () => number;
259
360
  private readonly randomId: () => string;
260
361
  private readonly timersApi: ScheduledRunTimers;
@@ -262,253 +363,379 @@ export class ScheduledRunManager {
262
363
 
263
364
  constructor(deps: ScheduledRunManagerDeps) {
264
365
  this.deps = deps;
265
- this.storeRoot = deps.storeRoot ?? SCHEDULED_RUNS_DIR;
266
366
  this.now = deps.now ?? Date.now;
267
367
  this.randomId = deps.randomId ?? (() => randomUUID().slice(0, 8));
268
368
  this.timersApi = deps.timers ?? globalThis;
269
369
  }
270
370
 
271
371
  bindSession(ctx: ExtensionContext): void {
272
- this.stopTimers();
273
- this.ctx = ctx;
274
- if (!scheduledRunsEnabled(this.deps.config)) {
275
- this.store = undefined;
276
- return;
277
- }
278
- const sessionId = resolveCurrentSessionId(ctx.sessionManager);
279
- this.store = new ScheduledRunStore(scheduledRunStorePath(ctx.cwd, sessionId, this.storeRoot), ctx.cwd, sessionId);
280
- this.rearmScheduledJobs();
372
+ if (!scheduledRunsEnabled(this.deps.config)) return;
373
+ this.selectProject(ctx.cwd, ctx);
281
374
  }
282
375
 
283
376
  stop(): void {
284
377
  this.stopTimers();
285
378
  this.store = undefined;
286
- this.ctx = undefined;
379
+ this.stores.clear();
380
+ this.contexts.clear();
287
381
  }
288
382
 
289
383
  async handleToolCall(params: SubagentParamsLike, ctx: ExtensionContext): Promise<AgentToolResult<Details>> {
290
- this.ctx = ctx;
291
384
  try {
292
- if (!scheduledRunsEnabled(this.deps.config)) {
293
- 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);
294
- }
295
- if (!this.store) this.bindSession(ctx);
296
- if (!this.store) return textResult("Scheduled subagent store is unavailable for this session.", true);
385
+ if (!scheduledRunsEnabled(this.deps.config)) return textResult("Scheduled runs are disabled by scheduledRuns.enabled=false.", undefined, undefined, true);
386
+ this.selectProject(params.cwd ?? ctx.cwd, ctx);
297
387
  switch (params.action) {
298
- case "schedule": return this.createJob(params, ctx);
299
- case "schedule-list": return this.listJobs();
300
- case "schedule-status": return this.statusJob(params);
301
- case "schedule-cancel": return this.cancelJob(params);
302
- default: return textResult(`Unknown scheduled-run action: ${params.action}`, true);
388
+ case "schedule.create": return this.create(params, ctx);
389
+ case "schedule.list": return this.list();
390
+ case "schedule.show": return this.show(params);
391
+ case "schedule.history": return this.history(params);
392
+ case "schedule.pause": return this.pause(params, true);
393
+ case "schedule.resume": return this.pause(params, false);
394
+ case "schedule.run": return await this.runManual(params);
395
+ case "schedule.run-due": return await this.runDue();
396
+ case "schedule.delete": return this.remove(params);
397
+ default: return textResult(`Unknown schedule action: ${params.action}`, undefined, undefined, true);
303
398
  }
304
399
  } catch (error) {
305
- const message = error instanceof Error ? error.message : String(error);
306
- return textResult(message, true);
400
+ return textResult(error instanceof Error ? error.message : String(error), undefined, undefined, true);
307
401
  }
308
402
  }
309
403
 
310
- private createJob(params: SubagentParamsLike, ctx: ExtensionContext): AgentToolResult<Details> {
311
- const store = this.requireStore();
312
- const sanitized = sanitizeScheduledParams(params);
313
- if (sanitized.error) return textResult(sanitized.error, true);
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);
404
+ handleAsyncCompletion(payload: unknown): void {
405
+ if (!payload || typeof payload !== "object") return;
406
+ const data = payload as { id?: unknown; runId?: unknown; success?: unknown; state?: unknown; summary?: unknown };
407
+ const asyncId = typeof data.runId === "string" ? data.runId : typeof data.id === "string" ? data.id : undefined;
408
+ if (!asyncId) return;
409
+ for (const store of this.stores.values()) {
410
+ let ids: string[];
411
+ try {
412
+ ids = store.ids();
413
+ } catch (error) {
414
+ console.error(`Failed to inspect schedule store '${store.root}' during async completion:`, error);
415
+ continue;
416
+ }
417
+ for (const id of ids) {
418
+ try {
419
+ const schedule = store.get(id);
420
+ const run = store.history(id).find((item) => item.asyncId === asyncId && item.state === "running");
421
+ if (!run) continue;
422
+ this.finishRun(store, schedule, run, data.success === true, typeof data.summary === "string" ? data.summary : undefined);
423
+ return;
424
+ } catch (error) {
425
+ console.error(`Failed to inspect schedule '${id}' in '${store.root}' during async completion:`, error);
426
+ }
427
+ }
318
428
  }
319
- const runAt = parseScheduledRunTime(scheduleInput, this.now());
320
- const pendingCount = store.list().filter((job) => job.state === "scheduled" || job.state === "running").length;
321
- const maxPending = resolveMaxPending(this.deps.config);
322
- if (pendingCount >= maxPending) return textResult(`Scheduled subagent limit reached (${pendingCount}/${maxPending} pending or running). Cancel an existing scheduled run before adding another.`, true);
323
- const id = this.randomId();
324
- const scheduleName = params.scheduleName?.trim();
325
- const executionParams = sanitized.params!;
429
+ }
430
+
431
+ private create(params: SubagentParamsLike, ctx: ExtensionContext): AgentToolResult<Details> {
432
+ const store = this.requireStore();
433
+ const target = sanitizeTarget(params);
434
+ if (target.error) return textResult(target.error, undefined, undefined, true);
435
+ const at = params.at?.trim();
436
+ const every = params.every?.trim();
437
+ if (Boolean(at) === Boolean(every)) return textResult("schedule.create requires exactly one trigger: at or every.", undefined, undefined, true);
438
+ if (params.overlap !== undefined && params.overlap !== "skip") return textResult("This first recurring slice supports overlap='skip' only.", undefined, undefined, true);
439
+ if (params.catchUp !== undefined && params.catchUp !== "none" && params.catchUp !== "latest") return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
440
+ if (params.missionId !== undefined || params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined) return textResult("Mission attachment is deferred from this first schedule slice.", undefined, undefined, true);
441
+ if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year") return textResult("Calendar schedules are deferred from this first safe slice. Use a fixed interval such as every:'24h' or every:'7d'.", undefined, undefined, true);
442
+ const sessionId = ctx.sessionManager.getSessionId() ?? "unknown";
443
+ if (this.deps.resolveCapabilityCeiling?.(sessionId)) return textResult("Cannot persist a schedule while a capability ceiling is active.", undefined, undefined, true);
444
+ if (store.list().length >= resolveMaxPending(this.deps.config)) return textResult(`Schedule limit reached (${resolveMaxPending(this.deps.config)}).`, undefined, undefined, true);
445
+ const id = validateScheduleId((params.id?.trim() || this.randomId()));
446
+ if (store.list().some((item) => item.id === id)) return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
326
447
  const now = this.now();
327
- const job: ScheduledRunJob = {
448
+ let trigger: ScheduleTrigger;
449
+ if (at) {
450
+ const planned = parseScheduledRunTime(at, now);
451
+ trigger = { kind: "once", at, nextRunAt: timestamp(planned) };
452
+ } else {
453
+ const everyMs = parseScheduleInterval(every!);
454
+ trigger = { kind: "interval", every: every!, everyMs, anchorAt: timestamp(now), nextRunAt: timestamp(now + everyMs) };
455
+ }
456
+ const schedule: ScheduleRecord = {
457
+ schemaVersion: 1,
328
458
  id,
329
- name: scheduleName || describeScheduledTarget(executionParams),
330
- schedule: scheduleInput,
331
- runAt,
332
- state: "scheduled",
333
- createdAt: now,
334
- updatedAt: now,
335
- cwd: ctx.cwd,
336
- sessionId,
337
- params: executionParams,
459
+ name: params.name?.trim() || params.scheduleName?.trim() || targetLabel(target.target!),
460
+ cwd: path.resolve(params.cwd ?? ctx.cwd),
461
+ trigger,
462
+ target: target.target!,
463
+ overlap: "skip",
464
+ catchUp: params.catchUp ?? "latest",
465
+ ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
466
+ paused: false,
467
+ createdAt: timestamp(now),
468
+ updatedAt: timestamp(now),
338
469
  };
339
- store.mutate((data) => {
340
- data.jobs.push(job);
341
- });
342
- this.arm(job);
343
- return textResult([
344
- `Scheduled subagent run ${job.id}.`,
345
- `Name: ${job.name}`,
346
- `When: ${new Date(job.runAt).toISOString()}`,
347
- `Mode: ${jobMode(executionParams)}`,
348
- `Context: fresh (scheduled runs never fork parent-session context)`,
349
- `Status: subagent({ action: "schedule-status", id: "${job.id}" })`,
350
- `Cancel before it fires: subagent({ action: "schedule-cancel", id: "${job.id}" })`,
351
- ].join("\n"));
352
- }
353
-
354
- private listJobs(): AgentToolResult<Details> {
355
- const jobs = this.requireStore().list().sort((left, right) => left.runAt - right.runAt);
356
- if (jobs.length === 0) return textResult("No scheduled subagent runs for this session.");
357
- const lines = [`Scheduled subagent runs: ${jobs.length}`, ""];
358
- for (const job of jobs) {
359
- const parts = [job.id, job.state, new Date(job.runAt).toISOString(), job.name];
360
- if (job.lastRunId) parts.push(`run ${job.lastRunId}`);
361
- if (job.lastError) parts.push(`error: ${job.lastError}`);
362
- lines.push(`- ${parts.join(" | ")}`);
363
- }
364
- return textResult(lines.join("\n"));
365
- }
366
-
367
- private statusJob(params: SubagentParamsLike): AgentToolResult<Details> {
368
- const requestedId = params.id ?? params.runId;
369
- if (!requestedId) return textResult("action='schedule-status' requires id.", true);
370
- const job = resolveJobById(this.requireStore().list(), requestedId);
371
- const lines = [
372
- `Scheduled run: ${job.id}`,
373
- `Name: ${job.name}`,
374
- `State: ${job.state}`,
375
- `Schedule: ${job.schedule}`,
376
- `Run at: ${new Date(job.runAt).toISOString()}`,
377
- `Mode: ${jobMode(job.params)}`,
378
- `CWD: ${shortenPath(job.cwd)}`,
379
- `Created: ${new Date(job.createdAt).toISOString()}`,
380
- `Updated: ${new Date(job.updatedAt).toISOString()}`,
381
- job.lastRunId ? `Launched async run: ${job.lastRunId}` : undefined,
382
- job.lastAsyncDir ? `Async dir: ${job.lastAsyncDir}` : undefined,
383
- job.lastError ? `Error: ${job.lastError}` : undefined,
384
- job.state === "scheduled" ? `Cancel: subagent({ action: "schedule-cancel", id: "${job.id}" })` : undefined,
385
- job.lastRunId ? `Async status: subagent({ action: "status", id: "${job.lastRunId}" })` : undefined,
386
- ].filter((line): line is string => Boolean(line));
387
- return textResult(lines.join("\n"));
388
- }
389
-
390
- private cancelJob(params: SubagentParamsLike): AgentToolResult<Details> {
391
- const requestedId = params.id ?? params.runId;
392
- if (!requestedId) return textResult("action='schedule-cancel' requires id.", true);
470
+ store.write(schedule);
471
+ store.appendEvent(schedule, "schedule.created");
472
+ this.arm(schedule, store);
473
+ return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}`, [schedule]);
474
+ }
475
+
476
+ private list(): AgentToolResult<Details> {
477
+ const schedules = this.requireStore().list().sort((a, b) => (a.trigger.nextRunAt ?? "").localeCompare(b.trigger.nextRunAt ?? ""));
478
+ if (!schedules.length) return textResult("No project schedules.", []);
479
+ return textResult([`Project schedules: ${schedules.length}`, ...schedules.map((item) => `- ${item.id} | ${item.paused ? "paused" : item.activeRunId ? "running" : "scheduled"} | ${item.trigger.nextRunAt ?? "no next run"} | ${item.name}`)].join("\n"), schedules);
480
+ }
481
+
482
+ private show(params: SubagentParamsLike): AgentToolResult<Details> {
483
+ const schedule = this.resolve(params);
484
+ return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Target: ${targetLabel(schedule.target)}`, `CWD: ${shortenPath(schedule.cwd)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
485
+ }
486
+
487
+ private history(params: SubagentParamsLike): AgentToolResult<Details> {
488
+ const schedule = this.resolve(params);
489
+ const runs = this.requireStore().history(schedule.id);
490
+ return textResult(runs.length ? [`Schedule history: ${schedule.id}`, ...runs.map((run) => `- ${run.id} | ${run.state} | ${run.plannedAt}${run.asyncId ? ` | async ${run.asyncId}` : ""}`)].join("\n") : `No runs recorded for schedule ${schedule.id}.`, [schedule], runs);
491
+ }
492
+
493
+ private pause(params: SubagentParamsLike, paused: boolean): AgentToolResult<Details> {
494
+ const schedule = this.resolve(params);
495
+ if (schedule.paused === paused) return textResult(`Schedule ${schedule.id} is already ${paused ? "paused" : "active"}.`, [schedule]);
496
+ schedule.paused = paused;
497
+ schedule.updatedAt = timestamp(this.now());
498
+ this.requireStore().write(schedule);
499
+ this.requireStore().appendEvent(schedule, paused ? "schedule.paused" : "schedule.resumed");
393
500
  const store = this.requireStore();
394
- const job = resolveJobById(store.list(), requestedId);
395
- if (job.state === "running") return textResult(`Scheduled run ${job.id} already launched async run ${job.lastRunId ?? "unknown"}; interrupt that async run instead.`, true);
396
- if (terminalState(job.state)) return textResult(`Scheduled run ${job.id} is already ${job.state}.`, true);
397
- const now = this.now();
398
- this.clearTimer(job.id);
399
- store.mutate((data) => {
400
- const stored = data.jobs.find((candidate) => candidate.id === job.id);
401
- if (!stored) return;
402
- stored.state = "canceled";
403
- stored.canceledAt = now;
404
- stored.updatedAt = now;
405
- });
406
- return textResult(`Canceled scheduled subagent run ${job.id}.`);
407
- }
408
-
409
- private rearmScheduledJobs(): void {
501
+ if (paused) this.clearTimer(store, schedule.id); else this.restoreOne(store, schedule);
502
+ return textResult(`${paused ? "Paused" : "Resumed"} schedule ${schedule.id}.`, [schedule]);
503
+ }
504
+
505
+ private async runManual(params: SubagentParamsLike): Promise<AgentToolResult<Details>> {
410
506
  const store = this.requireStore();
411
- const now = this.now();
412
- const maxLatenessMs = resolveMaxLatenessMs(this.deps.config);
413
- const dueToMiss = store.list().filter((job) => job.state === "scheduled" && job.runAt + maxLatenessMs < now);
414
- if (dueToMiss.length > 0) {
415
- store.mutate((data) => {
416
- for (const missed of dueToMiss) {
417
- const job = data.jobs.find((candidate) => candidate.id === missed.id);
418
- if (!job || job.state !== "scheduled") continue;
419
- job.state = "missed";
420
- job.updatedAt = now;
421
- job.lastError = `Missed scheduled time by more than ${formatDuration(maxLatenessMs)} while Pi was not available.`;
422
- }
423
- });
507
+ const schedule = this.resolve(params);
508
+ const run = await this.launch(store, schedule, this.now(), "manual", false);
509
+ return textResult(`Manual schedule run ${run.id}: ${run.state}${run.asyncId ? ` (async ${run.asyncId})` : ""}.`, [store.get(schedule.id)], [run], run.state === "failed_launch");
510
+ }
511
+
512
+ private async runDue(): Promise<AgentToolResult<Details>> {
513
+ const store = this.requireStore();
514
+ const due = store.list().filter((schedule) => !schedule.paused && nextRunAt(schedule) !== undefined && nextRunAt(schedule)! <= this.now());
515
+ const runs: ScheduleRunRecord[] = [];
516
+ for (const schedule of due) {
517
+ const planned = duePlannedAt(schedule, this.now())!;
518
+ if (!schedule.activeRunId && schedule.catchUp === "none" && planned < this.now()) runs.push(this.recordMissed(store, schedule, planned, "run-due"));
519
+ else runs.push(await this.launch(store, schedule, planned, "run-due", true));
424
520
  }
425
- for (const job of store.list()) {
426
- if (job.state === "scheduled") this.arm(job);
521
+ return textResult(runs.length ? `Processed ${runs.length} due schedule(s).` : "No schedules are due.", store.list(), runs);
522
+ }
523
+
524
+ private remove(params: SubagentParamsLike): AgentToolResult<Details> {
525
+ const schedule = this.resolve(params);
526
+ if (schedule.activeRunId) return textResult(`Schedule ${schedule.id} has active run ${schedule.activeRunId}; stop that run before deleting the schedule.`, [schedule], undefined, true);
527
+ const store = this.requireStore();
528
+ this.clearTimer(store, schedule.id);
529
+ store.appendEvent(schedule, "schedule.deleted");
530
+ store.delete(schedule.id);
531
+ return textResult(`Deleted schedule ${schedule.id}.`);
532
+ }
533
+
534
+ private restore(store: ScheduleStore): void {
535
+ for (const schedule of store.list()) this.restoreOne(store, schedule);
536
+ }
537
+
538
+ private restoreOne(store: ScheduleStore, schedule: ScheduleRecord): void {
539
+ if (schedule.activeRunId) {
540
+ const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
541
+ const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
542
+ if (run?.state === "running" && run.asyncDir) {
543
+ try {
544
+ const status = readJson(path.join(run.asyncDir, "status.json"), "async status") as Partial<AsyncStatus>;
545
+ if (["complete", "failed", "stopped", "rejected"].includes(String(status.state))) this.finishRun(store, schedule, run, status.state === "complete", typeof status.error === "string" ? status.error : undefined);
546
+ } catch (error) {
547
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof Error && /ENOENT/.test(error.message))) throw error;
548
+ }
549
+ }
550
+ if (schedule.activeRunId && (!run || run.state !== "running" || (!run.asyncId && Number.isFinite(startedAt) && startedAt + STALE_LAUNCH_CLAIM_MS <= this.now()))) {
551
+ if (run?.state === "running") {
552
+ run.state = "failed_launch";
553
+ run.completedAt = timestamp(this.now());
554
+ run.error = "Recovered a stale launch claim before an async run was attached.";
555
+ store.writeRun(schedule, run, "schedule.run.failed");
556
+ }
557
+ schedule.activeRunId = undefined;
558
+ schedule.updatedAt = timestamp(this.now());
559
+ store.write(schedule);
560
+ fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
561
+ }
427
562
  }
563
+ if (schedule.paused) return;
564
+ const next = nextRunAt(schedule);
565
+ if (next === undefined) return;
566
+ if (!schedule.activeRunId && next < this.now() && schedule.catchUp === "none") this.recordMissed(store, schedule, next, "timer");
567
+ this.arm(schedule, store);
428
568
  }
429
569
 
430
- private arm(job: ScheduledRunJob): void {
431
- this.clearTimer(job.id);
432
- const delayMs = Math.max(0, job.runAt - this.now());
433
- const timer = this.timersApi.setTimeout(() => {
434
- void this.fire(job.id);
435
- }, Math.min(delayMs, MAX_TIMER_DELAY_MS));
570
+ private arm(schedule: ScheduleRecord, store: ScheduleStore): void {
571
+ this.clearTimer(store, schedule.id);
572
+ if (schedule.paused) return;
573
+ const next = nextRunAt(schedule);
574
+ if (next === undefined) return;
575
+ const timer = this.timersApi.setTimeout(() => void this.fire(store, schedule.id), Math.min(Math.max(0, next - this.now()), MAX_TIMER_DELAY_MS));
436
576
  timer.unref?.();
437
- this.timers.set(job.id, timer);
577
+ this.timers.set(this.timerKey(store, schedule.id), timer);
578
+ }
579
+
580
+ private async fire(store: ScheduleStore, id: string): Promise<void> {
581
+ this.clearTimer(store, id);
582
+ const schedule = store.get(id);
583
+ const planned = duePlannedAt(schedule, this.now());
584
+ if (planned === undefined || schedule.paused) return;
585
+ if (planned > this.now()) return this.arm(schedule, store);
586
+ await this.launch(store, schedule, planned, "timer", true);
438
587
  }
439
588
 
440
- private async fire(jobId: string): Promise<void> {
441
- this.clearTimer(jobId);
442
- const store = this.store;
443
- const ctx = this.ctx;
444
- if (!store || !ctx) return;
445
- let job = store.get(jobId);
446
- if (!job || job.state !== "scheduled") return;
589
+ private async launch(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"], advance: boolean): Promise<ScheduleRunRecord> {
447
590
  const now = this.now();
448
- // A timer capped at MAX_TIMER_DELAY_MS may fire before runAt for far-future schedules; re-arm and wait.
449
- if (now < job.runAt) {
450
- this.arm(job);
451
- return;
591
+ const run: ScheduleRunRecord = { schemaVersion: 1, id: this.randomId(), scheduleId: schedule.id, plannedAt: timestamp(planned), dueReason, state: "running", startedAt: timestamp(now) };
592
+ if (schedule.activeRunId) {
593
+ run.state = "skipped";
594
+ run.completedAt = timestamp(now);
595
+ if (advance) {
596
+ schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
597
+ schedule.updatedAt = timestamp(now);
598
+ store.write(schedule);
599
+ }
600
+ store.writeRun(schedule, run, "schedule.skipped_overlap");
601
+ this.arm(schedule, store);
602
+ return run;
452
603
  }
453
- const maxLatenessMs = resolveMaxLatenessMs(this.deps.config);
454
- if (job.runAt + maxLatenessMs < now) {
455
- store.mutate((data) => {
456
- const stored = data.jobs.find((candidate) => candidate.id === jobId);
457
- if (!stored || stored.state !== "scheduled") return;
458
- stored.state = "missed";
459
- stored.updatedAt = now;
460
- stored.lastError = `Missed scheduled time by more than ${formatDuration(maxLatenessMs)}.`;
461
- });
462
- return;
604
+ const lockPath = path.join(store.directory(schedule.id, true), "active.lock");
605
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true, mode: 0o700 });
606
+ let lock: number;
607
+ try {
608
+ lock = fs.openSync(lockPath, "wx", 0o600);
609
+ fs.writeFileSync(lock, run.id, "utf-8");
610
+ fs.closeSync(lock);
611
+ } catch (error) {
612
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
613
+ run.state = "skipped";
614
+ run.completedAt = timestamp(now);
615
+ if (advance) {
616
+ schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
617
+ schedule.updatedAt = timestamp(now);
618
+ store.write(schedule);
619
+ }
620
+ store.writeRun(schedule, run, "schedule.skipped_overlap");
621
+ this.arm(schedule, store);
622
+ return run;
463
623
  }
464
- store.mutate((data) => {
465
- const stored = data.jobs.find((candidate) => candidate.id === jobId);
466
- if (!stored || stored.state !== "scheduled") return;
467
- stored.state = "running";
468
- stored.firedAt = now;
469
- stored.updatedAt = now;
470
- });
471
- job = store.get(jobId);
472
- if (!job || job.state !== "running") return;
473
- const controller = new AbortController();
624
+ schedule.activeRunId = run.id;
625
+ schedule.lastRunId = run.id;
626
+ if (advance) schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
627
+ schedule.updatedAt = timestamp(now);
628
+ store.write(schedule);
629
+ store.writeRun(schedule, run, "schedule.run.started");
474
630
  try {
475
- const result = await this.deps.launch(job.params, ctx, controller.signal);
476
- const launchRunId = result.details?.asyncId ?? result.details?.runId;
477
- store.mutate((data) => {
478
- const stored = data.jobs.find((candidate) => candidate.id === jobId);
479
- if (!stored) return;
480
- stored.updatedAt = this.now();
481
- if (result.isError || !launchRunId) {
482
- stored.state = "failed";
483
- stored.lastError = result.content.find((item) => item.type === "text")?.text ?? "Scheduled subagent launch failed.";
484
- return;
485
- }
486
- stored.state = "fired";
487
- stored.lastRunId = launchRunId;
488
- stored.lastAsyncDir = result.details?.asyncDir;
489
- });
631
+ const result = await this.deps.launch(executionParams(schedule), this.requireContext(store), new AbortController().signal);
632
+ const asyncId = result.details?.asyncId ?? result.details?.runId;
633
+ if (result.isError || !asyncId) throw new Error(result.content.find((item) => item.type === "text")?.text ?? "Scheduled launch failed.");
634
+ run.asyncId = asyncId;
635
+ run.asyncDir = result.details?.asyncDir;
636
+ store.writeRun(schedule, run, "schedule.run.attached_async");
637
+ this.arm(schedule, store);
638
+ return run;
490
639
  } catch (error) {
491
- const message = error instanceof Error ? error.message : String(error);
492
- store.mutate((data) => {
493
- const stored = data.jobs.find((candidate) => candidate.id === jobId);
494
- if (!stored) return;
495
- stored.state = "failed";
496
- stored.lastError = message;
497
- stored.updatedAt = this.now();
498
- });
640
+ run.state = "failed_launch";
641
+ run.completedAt = timestamp(this.now());
642
+ run.error = error instanceof Error ? error.message : String(error);
643
+ schedule.activeRunId = undefined;
644
+ schedule.updatedAt = timestamp(this.now());
645
+ store.write(schedule);
646
+ store.writeRun(schedule, run, "schedule.run.failed");
647
+ fs.rmSync(lockPath, { force: true });
648
+ this.arm(schedule, store);
649
+ return run;
650
+ }
651
+ }
652
+
653
+ private finishRun(store: ScheduleStore, schedule: ScheduleRecord, run: ScheduleRunRecord, success: boolean, error?: string): void {
654
+ const now = this.now();
655
+ const next = nextRunAt(schedule);
656
+ if (next !== undefined && next <= now) {
657
+ const planned = duePlannedAt(schedule, now)!;
658
+ const skipped: ScheduleRunRecord = {
659
+ schemaVersion: 1,
660
+ id: this.randomId(),
661
+ scheduleId: schedule.id,
662
+ plannedAt: timestamp(planned),
663
+ dueReason: "timer",
664
+ state: "skipped",
665
+ completedAt: timestamp(now),
666
+ };
667
+ schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
668
+ store.writeRun(schedule, skipped, "schedule.skipped_overlap");
499
669
  }
670
+ run.state = success ? "completed" : "failed_run";
671
+ run.completedAt = timestamp(now);
672
+ if (!success && error) run.error = error;
673
+ schedule.activeRunId = undefined;
674
+ schedule.updatedAt = timestamp(now);
675
+ store.write(schedule);
676
+ fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
677
+ store.writeRun(schedule, run, success ? "schedule.run.completed" : "schedule.run.failed");
678
+ this.arm(schedule, store);
679
+ }
680
+
681
+ private recordMissed(store: ScheduleStore, schedule: ScheduleRecord, planned: number, dueReason: ScheduleRunRecord["dueReason"]): ScheduleRunRecord {
682
+ const run: ScheduleRunRecord = {
683
+ schemaVersion: 1,
684
+ id: this.randomId(),
685
+ scheduleId: schedule.id,
686
+ plannedAt: timestamp(planned),
687
+ dueReason,
688
+ state: "missed",
689
+ completedAt: timestamp(this.now()),
690
+ };
691
+ schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, this.now());
692
+ schedule.updatedAt = timestamp(this.now());
693
+ store.write(schedule);
694
+ store.writeRun(schedule, run, "schedule.missed");
695
+ return run;
500
696
  }
501
697
 
502
- private requireStore(): ScheduledRunStore {
503
- if (!this.store) throw new Error("Scheduled subagent store is not bound to a session.");
698
+ private selectProject(cwd: string, ctx: ExtensionContext): void {
699
+ const projectCwd = path.resolve(cwd);
700
+ const root = scheduledRunStorePath(projectCwd, undefined, this.deps.storeRoot);
701
+ if (path.resolve(ctx.cwd) === projectCwd) this.contexts.set(root, snapshotContext(ctx, projectCwd));
702
+ else if (!this.contexts.has(root)) throw new Error(`Cannot use project '${projectCwd}' until that project has been opened in this runtime.`);
703
+ let store = this.stores.get(root);
704
+ if (!store) {
705
+ store = new ScheduleStore(root, this.deps.storeRoot === undefined ? projectCwd : undefined);
706
+ this.stores.set(root, store);
707
+ this.restore(store);
708
+ }
709
+ this.store = store;
710
+ }
711
+
712
+ private resolve(params: SubagentParamsLike): ScheduleRecord {
713
+ const id = params.id?.trim();
714
+ if (!id) throw new Error(`${params.action} requires id.`);
715
+ return this.requireStore().get(id);
716
+ }
717
+
718
+ private requireStore(): ScheduleStore {
719
+ if (!this.store) throw new Error("Schedule store is unavailable.");
504
720
  return this.store;
505
721
  }
506
722
 
507
- private clearTimer(jobId: string): void {
508
- const timer = this.timers.get(jobId);
723
+ private requireContext(store: ScheduleStore): ExtensionContext {
724
+ const ctx = this.contexts.get(store.root);
725
+ if (!ctx) throw new Error("Schedule runtime context is unavailable.");
726
+ return ctx;
727
+ }
728
+
729
+ private timerKey(store: ScheduleStore, id: string): string {
730
+ return `${store.root}\0${id}`;
731
+ }
732
+
733
+ private clearTimer(store: ScheduleStore, id: string): void {
734
+ const key = this.timerKey(store, id);
735
+ const timer = this.timers.get(key);
509
736
  if (!timer) return;
510
737
  this.timersApi.clearTimeout(timer);
511
- this.timers.delete(jobId);
738
+ this.timers.delete(key);
512
739
  }
513
740
 
514
741
  private stopTimers(): void {