@nredd/pi-subagents 0.0.0-stage → 0.22.0-nredd.10

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 (88) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +1444 -2
  3. package/dist/chunks/chunk-MSGGFB5Q.ts +6755 -0
  4. package/dist/chunks/chunk-MSGGFB5Q.ts.map +7 -0
  5. package/dist/chunks/conversation-viewer-A7AMRDVY.ts +15 -0
  6. package/dist/chunks/conversation-viewer-A7AMRDVY.ts.map +7 -0
  7. package/dist/index.ts +9874 -0
  8. package/dist/index.ts.map +7 -0
  9. package/package.json +82 -4
  10. package/src/abortable.ts +43 -0
  11. package/src/agent-color.ts +161 -0
  12. package/src/agent-file-toggle.ts +298 -0
  13. package/src/agent-manager.ts +2431 -0
  14. package/src/agent-runner.ts +1703 -0
  15. package/src/agent-source-badge.ts +75 -0
  16. package/src/agent-types.ts +346 -0
  17. package/src/child-context.ts +33 -0
  18. package/src/clock.ts +25 -0
  19. package/src/completion-outbox.ts +144 -0
  20. package/src/context.ts +58 -0
  21. package/src/cross-extension-rpc.ts +200 -0
  22. package/src/custom-agents.ts +441 -0
  23. package/src/default-agents.ts +126 -0
  24. package/src/detached-results.ts +150 -0
  25. package/src/enabled-models.ts +181 -0
  26. package/src/enterprise-local.ts +67 -0
  27. package/src/env.ts +33 -0
  28. package/src/error-message.ts +11 -0
  29. package/src/git-path.ts +66 -0
  30. package/src/group-join.ts +142 -0
  31. package/src/home-paths.ts +27 -0
  32. package/src/index.ts +5047 -0
  33. package/src/invocation-config.ts +158 -0
  34. package/src/memory.ts +179 -0
  35. package/src/mention-clone.ts +200 -0
  36. package/src/mention.ts +141 -0
  37. package/src/model-call.ts +83 -0
  38. package/src/model-resolver.ts +333 -0
  39. package/src/model-scope.ts +71 -0
  40. package/src/model-selection.ts +170 -0
  41. package/src/model-tiers.ts +176 -0
  42. package/src/nested-tools.ts +512 -0
  43. package/src/notification-result.ts +118 -0
  44. package/src/output-file.ts +186 -0
  45. package/src/package-resources.ts +639 -0
  46. package/src/plan-mode-children.ts +176 -0
  47. package/src/process-budget.ts +346 -0
  48. package/src/prompts.ts +161 -0
  49. package/src/quota-admission.ts +121 -0
  50. package/src/resource-loader.ts +65 -0
  51. package/src/schedule-store.ts +153 -0
  52. package/src/schedule.ts +514 -0
  53. package/src/settings.ts +851 -0
  54. package/src/skill-loader.ts +288 -0
  55. package/src/stale-ctx.ts +16 -0
  56. package/src/startup-error.ts +29 -0
  57. package/src/status-note.ts +113 -0
  58. package/src/structured-output.ts +130 -0
  59. package/src/types.ts +487 -0
  60. package/src/ui/agent-mention.ts +219 -0
  61. package/src/ui/agent-row.ts +245 -0
  62. package/src/ui/agent-widget.ts +722 -0
  63. package/src/ui/conversation-viewer.ts +837 -0
  64. package/src/ui/fallback-flag.ts +114 -0
  65. package/src/ui/fleet-list.ts +1020 -0
  66. package/src/ui/fleet-state.ts +154 -0
  67. package/src/ui/notification-view.ts +139 -0
  68. package/src/ui/schedule-menu.ts +105 -0
  69. package/src/ui/select-item.ts +45 -0
  70. package/src/ui/viewer-keys.ts +39 -0
  71. package/src/ui/workflow-card.ts +459 -0
  72. package/src/ui/workflow-dialog.ts +1138 -0
  73. package/src/ui/workflow-menu.ts +193 -0
  74. package/src/usage.ts +167 -0
  75. package/src/workflow/collisions.ts +123 -0
  76. package/src/workflow/entry.ts +47 -0
  77. package/src/workflow/host.ts +398 -0
  78. package/src/workflow/journal.ts +176 -0
  79. package/src/workflow/json-schema.ts +129 -0
  80. package/src/workflow/meta.ts +329 -0
  81. package/src/workflow/progress.ts +554 -0
  82. package/src/workflow/runtime.ts +1249 -0
  83. package/src/workflow/saved.ts +259 -0
  84. package/src/workflow/task.ts +317 -0
  85. package/src/workflow/tool-description.ts +200 -0
  86. package/src/workflow/worker-source.ts +903 -0
  87. package/src/worktree.ts +520 -0
  88. package/src/xml.ts +13 -0
@@ -0,0 +1,514 @@
1
+ /**
2
+ * schedule.ts — `SubagentScheduler`: timer-driven dispatcher of scheduled subagents.
3
+ *
4
+ * Mirrors the engine shape of pi-cron-schedule/src/scheduler.ts:
5
+ * - two-Map split (jobs = croner Cron, intervals = setInterval/setTimeout)
6
+ * - addJob/removeJob/updateJob/scheduleJob/unscheduleJob/executeJob
7
+ * - static parsers for cron / "+10m" / "5m" / ISO formats
8
+ *
9
+ * Differences vs pi-cron-schedule:
10
+ * - Persistence is via ScheduleStore (PID-locked, session-scoped, atomic).
11
+ * - `executeJob` calls `manager.spawn(..., { bypassQueue: true })` instead
12
+ * of dispatching a user message — schedule fires bypass maxConcurrent so
13
+ * a 5-minute interval can't be deferred behind 4 long-running agents.
14
+ * - Result delivery is implicit: spawn → background completion → existing
15
+ * `subagent-notification` path. No new delivery code.
16
+ */
17
+
18
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
19
+ import { Cron } from "croner";
20
+ import { nanoid } from "nanoid";
21
+ import type { AgentManager } from "./agent-manager.js";
22
+ import { normalizeMaxTurns } from "./agent-runner.js";
23
+ import { getAgentConfig, resolveSpawnType } from "./agent-types.js";
24
+ import { type Clock, realClock, type TimerHandle } from "./clock.js";
25
+ import { admitModelSelection, type ModelSelection, selectModel } from "./model-selection.js";
26
+ import { checkAdmission, describeBlock, mayPark, modelRef, PARKED_RESUME_DELAY_MS, QUOTA_RESTART_GRACE_MS } from "./quota-admission.js";
27
+ import type { ScheduleStore } from "./schedule-store.js";
28
+ import type { IsolationMode, ScheduledSubagent, SubagentType, ThinkingLevel } from "./types.js";
29
+
30
+ /** Event emitted on `pi.events` for cross-extension consumers. */
31
+ export type ScheduleChangeEvent =
32
+ | { type: "added"; job: ScheduledSubagent }
33
+ | { type: "removed"; jobId: string }
34
+ | { type: "updated"; job: ScheduledSubagent }
35
+ | { type: "fired"; jobId: string; agentId: string; name: string }
36
+ | { type: "error"; jobId: string; error: string };
37
+
38
+ /** Params accepted at job creation — ID, timestamps, and state are derived. */
39
+ export interface NewJobInput {
40
+ name: string;
41
+ description: string;
42
+ schedule: string;
43
+ subagent_type: SubagentType;
44
+ prompt: string;
45
+ model?: string;
46
+ thinking?: ThinkingLevel;
47
+ max_turns?: number;
48
+ isolated?: boolean;
49
+ isolation?: IsolationMode;
50
+ /** Set by quota admission; see `ScheduledSubagent.quotaParked`. */
51
+ quotaParked?: boolean;
52
+ }
53
+
54
+ /** What `start()` binds, captured by a fire so a later session switch cannot leak into it. */
55
+ interface BoundSession {
56
+ store: ScheduleStore;
57
+ pi: ExtensionAPI;
58
+ ctx: ExtensionContext;
59
+ manager: AgentManager;
60
+ }
61
+
62
+ /** Optional in-process scheduling dependencies; omitted clock uses native time. */
63
+ export interface SubagentSchedulerOptions {
64
+ homeDir?: string;
65
+ clock?: Clock;
66
+ }
67
+
68
+ export class SubagentScheduler {
69
+ private readonly clock: Clock;
70
+ private jobs = new Map<string, Cron>();
71
+ private intervals = new Map<string, TimerHandle>();
72
+ private scheduled = new Map<string, object>();
73
+ private admissions = new Map<string, Set<AbortController>>();
74
+ /** Newest pending admission per job that wrote "running", with the status it replaced. */
75
+ private pendingRuns = new Map<string, { token: AbortController; prior: ScheduledSubagent["lastStatus"] }>();
76
+ private store: ScheduleStore | undefined;
77
+ private pi: ExtensionAPI | undefined;
78
+ private ctx: ExtensionContext | undefined;
79
+ private manager: AgentManager | undefined;
80
+
81
+ constructor(private readonly options: SubagentSchedulerOptions = {}) {
82
+ this.clock = options.clock ?? realClock;
83
+ }
84
+
85
+ /** Start the scheduler: bind to a session's store and arm enabled jobs. */
86
+ start(pi: ExtensionAPI, ctx: ExtensionContext, manager: AgentManager, store: ScheduleStore): void {
87
+ this.stop();
88
+ this.pi = pi;
89
+ this.ctx = ctx;
90
+ this.manager = manager;
91
+ this.store = store;
92
+
93
+ for (const job of store.list()) {
94
+ if (job.enabled) this.scheduleJob(job);
95
+ }
96
+ }
97
+
98
+ /** Stop all timers; drop refs. Safe to call repeatedly. */
99
+ stop(): void {
100
+ for (const cron of this.jobs.values()) cron.stop();
101
+ this.jobs.clear();
102
+ for (const t of this.intervals.values()) t.cancel();
103
+ this.intervals.clear();
104
+ for (const admissions of this.admissions.values()) for (const admission of admissions) admission.abort();
105
+ this.admissions.clear();
106
+ this.pendingRuns.clear();
107
+ this.scheduled.clear();
108
+ this.store = undefined;
109
+ this.pi = undefined;
110
+ this.ctx = undefined;
111
+ this.manager = undefined;
112
+ }
113
+
114
+ /** True if start() has bound a store and the scheduler is active. */
115
+ isActive(): boolean {
116
+ return this.store !== undefined;
117
+ }
118
+
119
+ list(): ScheduledSubagent[] {
120
+ return this.store?.list() ?? [];
121
+ }
122
+
123
+ /**
124
+ * Build a `ScheduledSubagent` from user input. Validates the schedule
125
+ * format and tags `scheduleType`. Throws on invalid input.
126
+ */
127
+ buildJob(input: NewJobInput): ScheduledSubagent {
128
+ const detected = SubagentScheduler.detectSchedule(input.schedule, this.clock.now());
129
+ return {
130
+ id: nanoid(10),
131
+ name: input.name,
132
+ description: input.description,
133
+ schedule: detected.normalized,
134
+ scheduleType: detected.type,
135
+ intervalMs: detected.intervalMs,
136
+ subagent_type: input.subagent_type,
137
+ prompt: input.prompt,
138
+ model: input.model,
139
+ thinking: input.thinking,
140
+ max_turns: input.max_turns,
141
+ isolated: input.isolated,
142
+ isolation: input.isolation,
143
+ quotaParked: input.quotaParked,
144
+ enabled: true,
145
+ createdAt: new Date(this.clock.now()).toISOString(),
146
+ runCount: 0,
147
+ };
148
+ }
149
+
150
+ /** Add a job, persist, and arm if enabled. Returns the stored job. */
151
+ addJob(input: NewJobInput): ScheduledSubagent {
152
+ const store = this.requireStore();
153
+ if (store.hasName(input.name)) {
154
+ throw new Error(`A scheduled job named "${input.name}" already exists.`);
155
+ }
156
+ const job = this.buildJob(input);
157
+ store.add(job);
158
+ this.scheduleJob(job); // buildJob always creates an enabled job
159
+ this.emit({ type: "added", job });
160
+ return job;
161
+ }
162
+
163
+ removeJob(id: string): boolean {
164
+ const store = this.requireStore();
165
+ if (!store.get(id)) return false;
166
+ this.unscheduleJob(id);
167
+ const ok = store.remove(id);
168
+ if (ok) this.emit({ type: "removed", jobId: id });
169
+ return ok;
170
+ }
171
+
172
+ /** Toggle / mutate a job. Re-arms based on the new `enabled` state. */
173
+ updateJob(id: string, patch: Partial<ScheduledSubagent>): ScheduledSubagent | undefined {
174
+ const store = this.requireStore();
175
+ const updated = store.update(id, patch);
176
+ if (!updated) return undefined;
177
+ this.unscheduleJob(id);
178
+ if (updated.enabled) this.scheduleJob(updated);
179
+ this.emit({ type: "updated", job: updated });
180
+ return updated;
181
+ }
182
+
183
+ /** Next-run time as ISO, or undefined if not currently armed. */
184
+ getNextRun(jobId: string): string | undefined {
185
+ const cron = this.jobs.get(jobId);
186
+ if (cron) return cron.nextRun()?.toISOString();
187
+ const job = this.store?.get(jobId);
188
+ if (!job?.enabled) return undefined;
189
+ if (job.scheduleType === "once") return job.schedule;
190
+ if (job.scheduleType === "interval" && job.intervalMs) {
191
+ // Before the first fire there's no `lastRun`, so fall back to "now" —
192
+ // accurate at create time (setInterval was just armed) and within
193
+ // intervalMs of correct in any pre-first-fire view.
194
+ const base = job.lastRun ? new Date(job.lastRun).getTime() : this.clock.now();
195
+ return new Date(base + job.intervalMs).toISOString();
196
+ }
197
+ return undefined;
198
+ }
199
+
200
+ // ── Scheduling primitives ────────────────────────────────────────────
201
+
202
+ private scheduleJob(job: ScheduledSubagent): void {
203
+ const store = this.requireStore();
204
+ this.scheduled.set(job.id, {});
205
+ try {
206
+ if (job.scheduleType === "interval" && job.intervalMs) {
207
+ const t = this.clock.setInterval(() => this.executeJob(job.id), job.intervalMs);
208
+ this.intervals.set(job.id, t);
209
+ } else if (job.scheduleType === "once") {
210
+ const target = new Date(job.schedule).getTime();
211
+ let delay = target - this.clock.now();
212
+ // A quota-parked job whose reset passed while pi was closed still owes a
213
+ // dispatch: re-admit it shortly after start instead of erroring it.
214
+ if (delay <= 0 && job.quotaParked) delay = PARKED_RESUME_DELAY_MS;
215
+ if (delay > 0) {
216
+ const t = this.clock.setTimeout(() => {
217
+ this.executeJob(job.id);
218
+ // Auto-disable one-shots after they fire (mirrors pi-cron-schedule)
219
+ store.update(job.id, { enabled: false });
220
+ const updated = store.get(job.id);
221
+ if (updated) this.emit({ type: "updated", job: updated });
222
+ }, delay);
223
+ this.intervals.set(job.id, t);
224
+ } else {
225
+ // Past timestamp — disable, mark error, never fire
226
+ this.scheduled.delete(job.id);
227
+ store.update(job.id, { enabled: false, lastStatus: "error" });
228
+ this.emit({ type: "error", jobId: job.id, error: `Scheduled time ${job.schedule} is in the past` });
229
+ }
230
+ } else {
231
+ const cron = new Cron(job.schedule, () => this.executeJob(job.id));
232
+ this.jobs.set(job.id, cron);
233
+ }
234
+ } catch (err) {
235
+ // Nothing armed, so no token: a stale entry would admit a fire for a job with no timer.
236
+ this.scheduled.delete(job.id);
237
+ this.emit({ type: "error", jobId: job.id, error: err instanceof Error ? err.message : String(err) });
238
+ }
239
+ }
240
+
241
+ private unscheduleJob(id: string): void {
242
+ this.scheduled.delete(id);
243
+ for (const admission of this.admissions.get(id) ?? []) admission.abort();
244
+ this.admissions.delete(id);
245
+ const cron = this.jobs.get(id);
246
+ if (cron) {
247
+ cron.stop();
248
+ this.jobs.delete(id);
249
+ }
250
+ const t = this.intervals.get(id);
251
+ if (t) {
252
+ t.cancel();
253
+ this.intervals.delete(id);
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Fire a job: persist running state, spawn (bypassing the concurrency
259
+ * queue), persist completion. Fire-and-forget: the timer tick returns
260
+ * immediately so other jobs keep firing.
261
+ */
262
+ private executeJob(id: string): void {
263
+ const store = this.store;
264
+ const pi = this.pi;
265
+ const ctx = this.ctx;
266
+ const manager = this.manager;
267
+ const scheduled = this.scheduled.get(id);
268
+ if (!store || !pi || !ctx || !manager || !scheduled) return;
269
+ const job = store.get(id);
270
+ if (!job?.enabled) return;
271
+
272
+ // A fire that overlaps a pending admission inherits its prior status.
273
+ const previous = this.pendingRuns.get(id);
274
+ const prior = previous ? previous.prior : job.lastStatus;
275
+ this.pendingRuns.delete(id);
276
+ store.update(id, { lastStatus: "running" });
277
+
278
+ // Resolve the original request at fire time, using the same type and model
279
+ // policy as a manual dispatch. Never drop a failed explicit override.
280
+ const dispatch = resolveSpawnType(job.subagent_type);
281
+ const config = dispatch.ok ? getAgentConfig(dispatch.type) : undefined;
282
+ const selection = dispatch.ok ? selectModel({
283
+ homeDir: this.options.homeDir,
284
+ agentType: dispatch.type, definitionModel: config?.model, agentLabel: config?.displayName,
285
+ paramModel: job.model, parentModel: ctx.model, registry: ctx.modelRegistry, cwd: ctx.cwd,
286
+ }) : dispatch.message;
287
+ if (typeof selection === "string") {
288
+ store.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
289
+ this.emit({ type: "error", jobId: id, error: selection });
290
+ return;
291
+ }
292
+
293
+ if (selection.scopeWarning) ctx.ui.notify(selection.scopeWarning, "warning");
294
+
295
+ // A quota-parked job re-asks the router before starting: the window it
296
+ // waited for may have refilled while another is still exhausted.
297
+ const admissionModel = selection.model;
298
+ if ((job.quotaParked || selection.tierBinding) && admissionModel) {
299
+ const controller = new AbortController();
300
+ const admissions = this.admissions.get(id) ?? new Set<AbortController>();
301
+ admissions.add(controller);
302
+ this.admissions.set(id, admissions);
303
+ this.pendingRuns.set(id, { token: controller, prior });
304
+ // Cancelled before spawn: undo this admission's "running", never a newer one's.
305
+ controller.signal.addEventListener("abort", () => {
306
+ if (this.pendingRuns.get(id)?.token !== controller) return;
307
+ this.pendingRuns.delete(id);
308
+ if (store.get(id)?.lastStatus === "running") store.update(id, { lastStatus: prior });
309
+ }, { once: true });
310
+ const admission = selection.tierBinding
311
+ ? admitModelSelection(selection, ctx.modelRegistry, pi.events, this.clock, controller.signal)
312
+ : checkAdmission(pi.events, modelRef(admissionModel), undefined, this.clock, controller.signal);
313
+ const settle = (): void => {
314
+ if (this.pendingRuns.get(id)?.token === controller) this.pendingRuns.delete(id);
315
+ };
316
+ void admission.then(block => {
317
+ if (this.store !== store || this.ctx !== ctx || this.scheduled.get(id) !== scheduled) return;
318
+ settle();
319
+ if (!block) {
320
+ this.spawnJob({ store, pi, ctx, manager }, id, selection);
321
+ } else if (mayPark(block) && block.resetAt > this.clock.now()) {
322
+ this.repark(id, block.resetAt);
323
+ } else {
324
+ // Refused and it cannot wait (`wait: false`, no reset, or a reset
325
+ // already past): only an explicit admission may spawn, so persist the
326
+ // failure instead of starting on an exhausted provider.
327
+ this.store?.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
328
+ this.emit({ type: "error", jobId: id, error: `${describeBlock(block)} The agent was not started.` });
329
+ }
330
+ }).catch(error => {
331
+ if (this.store !== store || this.scheduled.get(id) !== scheduled) return;
332
+ settle();
333
+ store.update(id, { lastStatus: "error" });
334
+ this.emit({ type: "error", jobId: id, error: error instanceof Error ? error.message : String(error) });
335
+ }).finally(() => {
336
+ admissions.delete(controller);
337
+ if (admissions.size === 0 && this.admissions.get(id) === admissions) this.admissions.delete(id);
338
+ });
339
+ return;
340
+ }
341
+ this.spawnJob({ store, pi, ctx, manager }, id, selection);
342
+ }
343
+
344
+ /**
345
+ * Re-arm a quota-parked one-shot at a later reset instead of starting it.
346
+ * A job removed while admission was pending stays removed: `updateJob` is a
347
+ * no-op for an unknown id.
348
+ */
349
+ private repark(id: string, resetAt: number): void {
350
+ this.updateJob(id, {
351
+ enabled: true,
352
+ schedule: new Date(resetAt + QUOTA_RESTART_GRACE_MS).toISOString(),
353
+ lastStatus: undefined,
354
+ quotaParked: true,
355
+ });
356
+ }
357
+
358
+ /** Spawn a fired job (bypassing the concurrency queue) and persist its outcome. */
359
+ private spawnJob({ store, pi, ctx, manager }: BoundSession, id: string, selection: ModelSelection): void {
360
+ const job = store.get(id);
361
+ if (!job) return;
362
+
363
+ let agentId: string;
364
+ try {
365
+ // Re-resolve at fire time against the registry as it stands. This does not
366
+ // reload from disk (the scheduler has no reason to rebuild process-global
367
+ // state from a timer), so it catches changes that went through /agents or
368
+ // an Agent call — not a file deleted directly from a shell. The catch below turns
369
+ // this into lastStatus: "error" plus an error event, like any other
370
+ // fire-time failure.
371
+ const dispatch = resolveSpawnType(job.subagent_type);
372
+ if (!dispatch.ok) throw new Error(dispatch.message);
373
+ agentId = manager.spawn(pi, ctx, dispatch.type, job.prompt, {
374
+ description: job.description,
375
+ isBackground: true,
376
+ bypassQueue: true,
377
+ model: selection.model,
378
+ modelSelection: selection,
379
+ maxTurns: job.max_turns,
380
+ isolated: job.isolated,
381
+ thinkingLevel: job.thinking,
382
+ isolation: job.isolation,
383
+ // A scheduled run has no tool call to build this, so without it the
384
+ // conversation viewer shows nothing about how the job was configured.
385
+ // The model is left out on purpose: agent-manager fills in the effective
386
+ // one when the session reports it, and naming the pre-session pick here
387
+ // would only be right until then.
388
+ invocation: {
389
+ thinking: job.thinking,
390
+ // Normalized like the Agent tool's own snapshot: `0` means unlimited,
391
+ // and rendering it as "max turns: 0" would read as a limit of none.
392
+ maxTurns: normalizeMaxTurns(job.max_turns),
393
+ isolated: job.isolated,
394
+ runInBackground: true,
395
+ isolation: job.isolation,
396
+ },
397
+ });
398
+ } catch (err) {
399
+ const error = err instanceof Error ? err.message : String(err);
400
+ store.update(id, { lastRun: new Date(this.clock.now()).toISOString(), lastStatus: "error" });
401
+ this.emit({ type: "error", jobId: id, error });
402
+ return;
403
+ }
404
+
405
+ this.emit({ type: "fired", jobId: id, agentId, name: job.name });
406
+
407
+ const finalize = (status: "success" | "error") => {
408
+ const next = this.getNextRun(id);
409
+ const current = store.get(id);
410
+ store.update(id, {
411
+ lastRun: new Date(this.clock.now()).toISOString(),
412
+ lastStatus: status,
413
+ runCount: (current?.runCount ?? 0) + 1,
414
+ nextRun: next,
415
+ });
416
+ };
417
+
418
+ // AgentManager's promise resolves either way (its .catch returns ""), so we
419
+ // can't infer success/failure from the promise — read record.status instead.
420
+ // Terminal states: completed/steered = success; error/aborted/stopped = error.
421
+ // awaitStartup first: with isolation: "worktree" the run promise only exists
422
+ // once the repo copy is made, and a failed copy rejects here.
423
+ // The start gate comes first: `bypassQueue` skips the pool but not the
424
+ // process budget (`maxActiveSubagents`), so a fired job can sit "queued"
425
+ // with no startup and no promise yet, and would otherwise finalize at once.
426
+ Promise.resolve(manager.getRecord(agentId)?.startGate)
427
+ .then(() => manager.awaitStartup(agentId))
428
+ .then(() => manager.getRecord(agentId)?.promise)
429
+ .then(() => {
430
+ const r = manager.getRecord(agentId);
431
+ const failed = r?.status === "error" || r?.status === "aborted" || r?.status === "stopped";
432
+ finalize(failed ? "error" : "success");
433
+ })
434
+ .catch(() => finalize("error"));
435
+ }
436
+
437
+ private emit(event: ScheduleChangeEvent): void {
438
+ if (this.pi) this.pi.events.emit("subagents:scheduled", event);
439
+ }
440
+
441
+ private requireStore(): ScheduleStore {
442
+ if (!this.store) throw new Error("Scheduler not started — no active session.");
443
+ return this.store;
444
+ }
445
+
446
+ // ── Format detection / parsers (statics — pure) ──────────────────────
447
+
448
+ /**
449
+ * Sniff a schedule string and tag its type. Throws on invalid input.
450
+ * Order matters: relative ("+10m") and interval ("5m") both match digit+unit;
451
+ * relative requires the leading "+" to disambiguate.
452
+ */
453
+ static detectSchedule(s: string, now = Date.now()): { type: "cron" | "once" | "interval"; intervalMs?: number; normalized: string } {
454
+ const trimmed = s.trim();
455
+ // "+10m" — relative one-shot
456
+ const rel = SubagentScheduler.parseRelativeTime(trimmed, now);
457
+ if (rel !== null) return { type: "once", normalized: rel };
458
+ // "5m" — interval
459
+ const ivl = SubagentScheduler.parseInterval(trimmed);
460
+ if (ivl !== null) return { type: "interval", intervalMs: ivl, normalized: trimmed };
461
+ // ISO timestamp — one-shot. Reject past timestamps upfront so we never
462
+ // create a dead-on-arrival record (scheduleJob's safety net still catches
463
+ // micro-races from `+0s`-style relatives).
464
+ if (/^\d{4}-\d{2}-\d{2}T/.test(trimmed)) {
465
+ const d = new Date(trimmed);
466
+ if (!Number.isNaN(d.getTime())) {
467
+ if (d.getTime() <= now) {
468
+ throw new Error(`Scheduled time ${d.toISOString()} is in the past.`);
469
+ }
470
+ return { type: "once", normalized: d.toISOString() };
471
+ }
472
+ }
473
+ // Cron — 6-field
474
+ const cronCheck = SubagentScheduler.validateCronExpression(trimmed);
475
+ if (cronCheck.valid) return { type: "cron", normalized: trimmed };
476
+ throw new Error(
477
+ `Invalid schedule "${s}". Use 6-field cron (e.g. "0 0 9 * * 1" — 9am every Monday), interval ("5m"/"1h"), or one-shot ("+10m" / ISO).`
478
+ );
479
+ }
480
+
481
+ /** 6-field cron — 'second minute hour dom month dow'. */
482
+ static validateCronExpression(expr: string): { valid: boolean; error?: string } {
483
+ const fields = expr.trim().split(/\s+/);
484
+ if (fields.length !== 6) {
485
+ return {
486
+ valid: false,
487
+ error: `Cron must have 6 fields (second minute hour dom month dow), got ${fields.length}. Example: "0 0 9 * * 1" for 9am every Monday.`,
488
+ };
489
+ }
490
+ try {
491
+ // Croner validates by construction.
492
+ new Cron(expr, () => {});
493
+ return { valid: true };
494
+ } catch (e) {
495
+ // Croner only throws Error subclasses (TypeError, RangeError).
496
+ return { valid: false, error: (e as Error).message };
497
+ }
498
+ }
499
+
500
+ /** "+10s"/"+5m"/"+1h"/"+2d" → ISO timestamp. */
501
+ static parseRelativeTime(s: string, now = Date.now()): string | null {
502
+ const m = s.match(/^\+(\d+)(s|m|h|d)$/);
503
+ if (!m) return null;
504
+ const ms = parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
505
+ return new Date(now + ms).toISOString();
506
+ }
507
+
508
+ /** "10s"/"5m"/"1h"/"2d" → milliseconds. */
509
+ static parseInterval(s: string): number | null {
510
+ const m = s.match(/^(\d+)(s|m|h|d)$/);
511
+ if (!m) return null;
512
+ return parseInt(m[1], 10) * { s: 1000, m: 60_000, h: 3_600_000, d: 86_400_000 }[m[2] as "s" | "m" | "h" | "d"];
513
+ }
514
+ }