pi-subagents 0.75.0 → 0.76.1

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 (170) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/docs/agents.md +1 -1
  3. package/docs/configuration.md +4 -2
  4. package/docs/extension-api.md +3 -3
  5. package/docs/missions.md +12 -2
  6. package/docs/tool-reference.md +21 -6
  7. package/docs/workflows.md +1 -1
  8. package/index.d.ts.map +1 -1
  9. package/index.js +5 -0
  10. package/index.js.map +1 -1
  11. package/package.json +3 -1
  12. package/src/agents/agent-memory.js +3 -3
  13. package/src/agents/agent-memory.js.map +1 -1
  14. package/src/agents/agent-serializer.d.ts.map +1 -1
  15. package/src/agents/agent-serializer.js +8 -1
  16. package/src/agents/agent-serializer.js.map +1 -1
  17. package/src/agents/agents.d.ts.map +1 -1
  18. package/src/agents/agents.js +101 -121
  19. package/src/agents/agents.js.map +1 -1
  20. package/src/extension/doctor.d.ts +3 -1
  21. package/src/extension/doctor.d.ts.map +1 -1
  22. package/src/extension/doctor.js +3 -9
  23. package/src/extension/doctor.js.map +1 -1
  24. package/src/extension/index.d.ts.map +1 -1
  25. package/src/extension/index.js +55 -11
  26. package/src/extension/index.js.map +1 -1
  27. package/src/extension/reply-workflow-script.d.ts.map +1 -1
  28. package/src/extension/reply-workflow-script.js +20 -11
  29. package/src/extension/reply-workflow-script.js.map +1 -1
  30. package/src/extension/schemas.d.ts +1 -1
  31. package/src/extension/schemas.js +8 -8
  32. package/src/extension/schemas.js.map +1 -1
  33. package/src/extension/tool-description.js +1 -1
  34. package/src/extension/tool-description.js.map +1 -1
  35. package/src/integrations/herdr-status.d.ts +0 -1
  36. package/src/integrations/herdr-status.d.ts.map +1 -1
  37. package/src/integrations/herdr-status.js +18 -52
  38. package/src/integrations/herdr-status.js.map +1 -1
  39. package/src/intercom/native-supervisor-channel.d.ts +1 -0
  40. package/src/intercom/native-supervisor-channel.d.ts.map +1 -1
  41. package/src/intercom/native-supervisor-channel.js +1 -1
  42. package/src/intercom/native-supervisor-channel.js.map +1 -1
  43. package/src/intercom/result-intercom.d.ts.map +1 -1
  44. package/src/intercom/result-intercom.js +2 -1
  45. package/src/intercom/result-intercom.js.map +1 -1
  46. package/src/profiles/profiles.d.ts.map +1 -1
  47. package/src/profiles/profiles.js +25 -22
  48. package/src/profiles/profiles.js.map +1 -1
  49. package/src/runs/background/active-async-capacity.d.ts +15 -1
  50. package/src/runs/background/active-async-capacity.d.ts.map +1 -1
  51. package/src/runs/background/active-async-capacity.js +51 -6
  52. package/src/runs/background/active-async-capacity.js.map +1 -1
  53. package/src/runs/background/async-job-tracker.d.ts +3 -2
  54. package/src/runs/background/async-job-tracker.d.ts.map +1 -1
  55. package/src/runs/background/async-job-tracker.js +2 -1
  56. package/src/runs/background/async-job-tracker.js.map +1 -1
  57. package/src/runs/background/calendar-schedule.d.ts +27 -0
  58. package/src/runs/background/calendar-schedule.d.ts.map +1 -0
  59. package/src/runs/background/calendar-schedule.js +94 -0
  60. package/src/runs/background/calendar-schedule.js.map +1 -0
  61. package/src/runs/background/notify.d.ts +13 -2
  62. package/src/runs/background/notify.d.ts.map +1 -1
  63. package/src/runs/background/notify.js +48 -5
  64. package/src/runs/background/notify.js.map +1 -1
  65. package/src/runs/background/result-files.d.ts +2 -0
  66. package/src/runs/background/result-files.d.ts.map +1 -1
  67. package/src/runs/background/result-files.js +4 -0
  68. package/src/runs/background/result-files.js.map +1 -1
  69. package/src/runs/background/result-watcher.d.ts +5 -1
  70. package/src/runs/background/result-watcher.d.ts.map +1 -1
  71. package/src/runs/background/result-watcher.js +44 -7
  72. package/src/runs/background/result-watcher.js.map +1 -1
  73. package/src/runs/background/run-child-session.d.ts +2 -0
  74. package/src/runs/background/run-child-session.d.ts.map +1 -1
  75. package/src/runs/background/run-child-session.js +12 -1
  76. package/src/runs/background/run-child-session.js.map +1 -1
  77. package/src/runs/background/scheduled-runs.d.ts +3 -1
  78. package/src/runs/background/scheduled-runs.d.ts.map +1 -1
  79. package/src/runs/background/scheduled-runs.js +184 -47
  80. package/src/runs/background/scheduled-runs.js.map +1 -1
  81. package/src/runs/background/subagent-runner.d.ts +1 -0
  82. package/src/runs/background/subagent-runner.d.ts.map +1 -1
  83. package/src/runs/background/subagent-runner.js +14 -6
  84. package/src/runs/background/subagent-runner.js.map +1 -1
  85. package/src/runs/background/wait-completions.d.ts.map +1 -1
  86. package/src/runs/background/wait-completions.js +1 -0
  87. package/src/runs/background/wait-completions.js.map +1 -1
  88. package/src/runs/background/workflow-terminal-proof.d.ts +14 -1
  89. package/src/runs/background/workflow-terminal-proof.d.ts.map +1 -1
  90. package/src/runs/background/workflow-terminal-proof.js +41 -28
  91. package/src/runs/background/workflow-terminal-proof.js.map +1 -1
  92. package/src/runs/foreground/async-stop-action.d.ts.map +1 -1
  93. package/src/runs/foreground/async-stop-action.js +30 -11
  94. package/src/runs/foreground/async-stop-action.js.map +1 -1
  95. package/src/runs/foreground/execution.d.ts.map +1 -1
  96. package/src/runs/foreground/execution.js +33 -13
  97. package/src/runs/foreground/execution.js.map +1 -1
  98. package/src/runs/foreground/foreground-history.d.ts.map +1 -1
  99. package/src/runs/foreground/foreground-history.js +1 -0
  100. package/src/runs/foreground/foreground-history.js.map +1 -1
  101. package/src/runs/foreground/subagent-executor.d.ts +2 -1
  102. package/src/runs/foreground/subagent-executor.d.ts.map +1 -1
  103. package/src/runs/foreground/subagent-executor.js +54 -11
  104. package/src/runs/foreground/subagent-executor.js.map +1 -1
  105. package/src/runs/shared/child-session.d.ts.map +1 -1
  106. package/src/runs/shared/child-session.js +10 -2
  107. package/src/runs/shared/child-session.js.map +1 -1
  108. package/src/runs/shared/mcp-direct-tool-allowlist.d.ts.map +1 -1
  109. package/src/runs/shared/mcp-direct-tool-allowlist.js +5 -3
  110. package/src/runs/shared/mcp-direct-tool-allowlist.js.map +1 -1
  111. package/src/runs/shared/parallel-handoff.d.ts +10 -4
  112. package/src/runs/shared/parallel-handoff.d.ts.map +1 -1
  113. package/src/runs/shared/parallel-handoff.js +53 -6
  114. package/src/runs/shared/parallel-handoff.js.map +1 -1
  115. package/src/runs/shared/partial-output.d.ts +18 -0
  116. package/src/runs/shared/partial-output.d.ts.map +1 -0
  117. package/src/runs/shared/partial-output.js +29 -0
  118. package/src/runs/shared/partial-output.js.map +1 -0
  119. package/src/runs/shared/subagent-prompt-runtime.d.ts.map +1 -1
  120. package/src/runs/shared/subagent-prompt-runtime.js +4 -3
  121. package/src/runs/shared/subagent-prompt-runtime.js.map +1 -1
  122. package/src/runs/shared/worktree-cleanup-apply.d.ts +37 -0
  123. package/src/runs/shared/worktree-cleanup-apply.d.ts.map +1 -0
  124. package/src/runs/shared/worktree-cleanup-apply.js +179 -0
  125. package/src/runs/shared/worktree-cleanup-apply.js.map +1 -0
  126. package/src/runs/shared/worktree-cleanup-plan.d.ts +7 -0
  127. package/src/runs/shared/worktree-cleanup-plan.d.ts.map +1 -1
  128. package/src/runs/shared/worktree-cleanup-plan.js +41 -19
  129. package/src/runs/shared/worktree-cleanup-plan.js.map +1 -1
  130. package/src/runs/shared/worktree-lock.d.ts +6 -0
  131. package/src/runs/shared/worktree-lock.d.ts.map +1 -0
  132. package/src/runs/shared/worktree-lock.js +41 -0
  133. package/src/runs/shared/worktree-lock.js.map +1 -0
  134. package/src/runs/shared/worktree.d.ts +3 -0
  135. package/src/runs/shared/worktree.d.ts.map +1 -1
  136. package/src/runs/shared/worktree.js +9 -1
  137. package/src/runs/shared/worktree.js.map +1 -1
  138. package/src/shared/file-lease.d.ts +5 -0
  139. package/src/shared/file-lease.d.ts.map +1 -0
  140. package/src/shared/file-lease.js +101 -0
  141. package/src/shared/file-lease.js.map +1 -0
  142. package/src/shared/package-version.d.ts +3 -0
  143. package/src/shared/package-version.d.ts.map +1 -0
  144. package/src/shared/package-version.js +15 -0
  145. package/src/shared/package-version.js.map +1 -0
  146. package/src/shared/parent-wake.d.ts +18 -0
  147. package/src/shared/parent-wake.d.ts.map +1 -0
  148. package/src/shared/parent-wake.js +47 -0
  149. package/src/shared/parent-wake.js.map +1 -0
  150. package/src/shared/settings-file-lease.d.ts +5 -0
  151. package/src/shared/settings-file-lease.d.ts.map +1 -0
  152. package/src/shared/settings-file-lease.js +39 -0
  153. package/src/shared/settings-file-lease.js.map +1 -0
  154. package/src/shared/types.d.ts +10 -0
  155. package/src/shared/types.d.ts.map +1 -1
  156. package/src/shared/types.js.map +1 -1
  157. package/src/tui/render.d.ts.map +1 -1
  158. package/src/tui/render.js +7 -2
  159. package/src/tui/render.js.map +1 -1
  160. package/src/watchdog/diff-tool.js +2 -2
  161. package/src/watchdog/diff-tool.js.map +1 -1
  162. package/src/watchdog/register-main.js +1 -2
  163. package/src/watchdog/register-main.js.map +1 -1
  164. package/src/watchdog/runtime.d.ts +3 -3
  165. package/src/watchdog/runtime.d.ts.map +1 -1
  166. package/src/watchdog/runtime.js +7 -3
  167. package/src/watchdog/runtime.js.map +1 -1
  168. package/src/watchdog/settings.d.ts.map +1 -1
  169. package/src/watchdog/settings.js +22 -17
  170. package/src/watchdog/settings.js.map +1 -1
@@ -4,6 +4,7 @@ import * as fs from "node:fs";
4
4
  import * as path from "node:path";
5
5
  import { getProjectSubagentsDir } from "../../shared/artifacts.js";
6
6
  import { writePrivateAtomicJson } from "../../shared/atomic-json.js";
7
+ import { withFileLease } from "../../shared/file-lease.js";
7
8
  import { shortenPath } from "../../shared/formatters.js";
8
9
  import { validateExecutionAcceptance } from "../shared/acceptance.js";
9
10
  import { previewSimpleWorkflowRun } from "../../workflows/scripted-workflow.js";
@@ -12,6 +13,7 @@ import { getConfigDirName } from "../../shared/utils.js";
12
13
  import { normalizeWorktreeBaseRef } from "../shared/worktree.js";
13
14
  import { deepFreezeWorkflowArgs, normalizeWorkflowArgs } from "../../workflows/workflow-resources.js";
14
15
  import { readMission, resolveMissionStoreLocation, validateMissionId } from "../../missions/store.js";
16
+ import { calendarDateAfter, latestCalendarOccurrence, nextCalendarOccurrence, normalizeCalendarRule, restoreCalendarTrigger } from "./calendar-schedule.js";
15
17
  export const SCHEDULED_RUN_ACTIONS = [
16
18
  "schedule.create",
17
19
  "schedule.list",
@@ -294,6 +296,9 @@ function parseSchedule(value, file) {
294
296
  if (typeof record.trigger.every !== "string" || typeof record.trigger.everyMs !== "number" || typeof record.trigger.anchorAt !== "string" || typeof record.trigger.nextRunAt !== "string")
295
297
  throw new Error(`Schedule record '${file}' has an invalid interval trigger.`);
296
298
  }
299
+ else if (record.trigger.kind === "calendar") {
300
+ restoreCalendarTrigger(record.trigger); // Validate without rewriting during read-only list/show.
301
+ }
297
302
  else
298
303
  throw new Error(`Schedule record '${file}' has an unsupported trigger.`);
299
304
  if (record.sessionOnly !== undefined && typeof record.sessionOnly !== "boolean")
@@ -356,13 +361,30 @@ class ScheduleStore {
356
361
  throw new Error(`Schedule history '${file}' has invalid fields.`);
357
362
  return value.runs;
358
363
  }
364
+ /** The run's own receipt; it is written before history.json and is never trimmed. */
365
+ getRun(id, runId) {
366
+ if (!SCHEDULE_ID.test(runId))
367
+ throw new Error(`Invalid schedule run id '${runId}'.`);
368
+ const file = path.join(scheduleDir(this.root, id, false, this.projectCwd), "runs", `${runId}.json`);
369
+ if (!fs.existsSync(file))
370
+ return undefined;
371
+ const run = readJson(file, "schedule run");
372
+ if (run?.id !== runId || run.scheduleId !== id)
373
+ throw new Error(`Schedule run '${file}' has invalid fields.`);
374
+ return run;
375
+ }
359
376
  writeRun(schedule, run, event) {
360
377
  const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
361
378
  writePrivateAtomicJson(path.join(dir, "runs", `${run.id}.json`), run);
362
- const runs = [run, ...this.history(schedule.id).filter((item) => item.id !== run.id)].slice(0, MAX_HISTORY);
363
- writePrivateAtomicJson(path.join(dir, "history.json"), { schemaVersion: 1, runs });
364
- fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
365
- 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 });
379
+ // Sessions sharing this project update history.json from their own snapshots.
380
+ withFileLease(path.join(dir, "history.json"), () => {
381
+ // An earlier update may have timed out; a run's own receipt is newer than a "running" entry.
382
+ const earlier = this.history(schedule.id).filter((item) => item.id !== run.id)
383
+ .map((item) => item.state === "running" ? this.getRun(schedule.id, item.id) ?? item : item);
384
+ const runs = [run, ...earlier].slice(0, MAX_HISTORY);
385
+ writePrivateAtomicJson(path.join(dir, "history.json"), { schemaVersion: 1, runs });
386
+ 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 });
387
+ });
366
388
  }
367
389
  appendEvent(schedule, event) {
368
390
  const dir = scheduleDir(this.root, schedule.id, true, this.projectCwd);
@@ -379,11 +401,14 @@ function hasPendingScheduleWork(schedule) {
379
401
  }
380
402
  function nextAfter(trigger, plannedAt, now) {
381
403
  if (trigger.kind === "once")
382
- return undefined;
383
- let next = plannedAt + trigger.everyMs;
384
- while (next <= now)
385
- next += trigger.everyMs;
386
- return timestamp(next);
404
+ return { ...trigger, nextRunAt: undefined };
405
+ if (trigger.kind === "calendar")
406
+ return {
407
+ ...trigger,
408
+ ...nextCalendarOccurrence(trigger, Math.max(plannedAt, now), calendarDateAfter(trigger, plannedAt, trigger.nextLocalDate)),
409
+ };
410
+ const next = plannedAt + (Math.floor(Math.max(0, now - plannedAt) / trigger.everyMs) + 1) * trigger.everyMs;
411
+ return { ...trigger, nextRunAt: timestamp(next) };
387
412
  }
388
413
  function nextRunAt(schedule) {
389
414
  const value = schedule.trigger.nextRunAt;
@@ -396,9 +421,18 @@ function nextRunAt(schedule) {
396
421
  }
397
422
  function duePlannedAt(schedule, now) {
398
423
  const next = nextRunAt(schedule);
399
- if (next === undefined || next > now || schedule.catchUp !== "latest" || schedule.trigger.kind !== "interval")
424
+ if (next === undefined || next > now || schedule.catchUp !== "latest")
400
425
  return next;
401
- return next + Math.floor((now - next) / schedule.trigger.everyMs) * schedule.trigger.everyMs;
426
+ if (schedule.trigger.kind === "calendar")
427
+ return Date.parse(latestCalendarOccurrence(schedule.trigger, now, schedule.trigger.nextLocalDate).nextRunAt);
428
+ if (schedule.trigger.kind === "interval")
429
+ return next + Math.floor((now - next) / schedule.trigger.everyMs) * schedule.trigger.everyMs;
430
+ return next;
431
+ }
432
+ function triggerLabel(trigger) {
433
+ if (trigger.kind === "calendar")
434
+ return `every ${trigger.every}${trigger.on ? ` (${trigger.on.join(", ")})` : ""} at ${trigger.at} ${trigger.timezone}`;
435
+ return trigger.kind === "once" ? `at ${trigger.at}` : `every ${trigger.every}`;
402
436
  }
403
437
  function textResult(text, schedules, runs, isError = false) {
404
438
  const publicSchedules = schedules?.map(publicScheduleRecord);
@@ -585,8 +619,10 @@ export class ScheduledRunManager {
585
619
  for (const id of ids) {
586
620
  try {
587
621
  const schedule = store.get(id);
588
- const run = store.history(id).find((item) => item.asyncId === asyncId && item.state === "running");
589
- if (!run)
622
+ const listed = store.history(id).find((item) => item.asyncId === asyncId) ?? this.activeRun(store, schedule);
623
+ // history.json can lag the run's receipt when its update timed out.
624
+ const run = listed && (store.getRun(id, listed.id) ?? listed);
625
+ if (run?.asyncId !== asyncId || run.state !== "running")
590
626
  continue;
591
627
  this.finishRun(store, schedule, run, data.success === true, typeof data.summary === "string" ? data.summary : undefined);
592
628
  return;
@@ -604,7 +640,8 @@ export class ScheduledRunManager {
604
640
  return textResult(target.error, undefined, undefined, true);
605
641
  const at = params.at?.trim();
606
642
  const every = params.every?.trim();
607
- if (Boolean(at) === Boolean(every))
643
+ const calendar = every === "day" || every === "week";
644
+ if (!calendar && Boolean(at) === Boolean(every))
608
645
  return textResult("schedule.create requires exactly one trigger: at or every.", undefined, undefined, true);
609
646
  if (params.overlap !== undefined && params.overlap !== "skip")
610
647
  return textResult("This first recurring slice supports overlap='skip' only.", undefined, undefined, true);
@@ -612,11 +649,11 @@ export class ScheduledRunManager {
612
649
  return textResult("catchUp must be 'none' or 'latest'.", undefined, undefined, true);
613
650
  if (params.mission !== undefined || params.missionUpdate !== undefined || params.missionStatus !== undefined || params.missionScope !== undefined)
614
651
  return textResult("Schedules accept only an existing missionId; mission creation and updates are unsupported.", undefined, undefined, true);
615
- if (params.on !== undefined || params.timezone !== undefined || every === "day" || every === "week" || every === "month" || every === "year")
616
- 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);
652
+ if (!calendar && (params.on !== undefined || params.timezone !== undefined))
653
+ return textResult("on and timezone require every:'day' or every:'week'.", undefined, undefined, true);
617
654
  if (params.quiet !== undefined && typeof params.quiet !== "boolean")
618
655
  return textResult("quiet must be a boolean.", undefined, undefined, true);
619
- if (at && params.quiet === true)
656
+ if (!calendar && at && params.quiet === true)
620
657
  return textResult("quiet is only supported for recurring schedules.", undefined, undefined, true);
621
658
  const sessionOnly = params.sessionOnly === true;
622
659
  if (sessionOnly && params.cwd !== undefined && !samePath(params.cwd, ctx.cwd))
@@ -639,7 +676,11 @@ export class ScheduledRunManager {
639
676
  return textResult(`Schedule '${id}' already exists.`, undefined, undefined, true);
640
677
  const now = this.now();
641
678
  let trigger;
642
- if (at) {
679
+ if (calendar) {
680
+ const rule = normalizeCalendarRule({ every, at, on: params.on, timezone: params.timezone });
681
+ trigger = { kind: "calendar", ...rule, ...nextCalendarOccurrence(rule, now) };
682
+ }
683
+ else if (at) {
643
684
  const planned = parseScheduledRunTime(at, now);
644
685
  trigger = { kind: "once", at, nextRunAt: timestamp(planned) };
645
686
  }
@@ -660,14 +701,14 @@ export class ScheduledRunManager {
660
701
  ...(params.timeoutMs === undefined ? {} : { timeoutMs: params.timeoutMs }),
661
702
  paused: false,
662
703
  ...(sessionOnly ? { sessionOnly: true, ownerSessionFile: path.resolve(ownerSessionFile) } : {}),
663
- ...(trigger.kind === "interval" && params.quiet === true ? { quiet: true } : {}),
704
+ ...(trigger.kind !== "once" && params.quiet === true ? { quiet: true } : {}),
664
705
  createdAt: timestamp(now),
665
706
  updatedAt: timestamp(now),
666
707
  };
667
708
  store.write(schedule);
668
709
  store.appendEvent(schedule, "schedule.created");
669
710
  this.arm(schedule, store);
670
- return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${at ? `at ${at}` : `every ${every}`}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nQuiet: ${schedule.quiet === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}${schedule.target.missionId === undefined ? "" : `\nMission: ${schedule.target.missionId}`}`, [schedule]);
711
+ return textResult(`Created schedule ${id}.\nName: ${schedule.name}\nTrigger: ${triggerLabel(trigger)}\nSession only: ${schedule.sessionOnly === true ? "yes" : "no"}\nQuiet: ${schedule.quiet === true ? "yes" : "no"}\nNext: ${schedule.trigger.nextRunAt}\nTarget: ${targetLabel(schedule.target)}${schedule.target.missionId === undefined ? "" : `\nMission: ${schedule.target.missionId}`}`, [schedule]);
671
712
  }
672
713
  list() {
673
714
  const schedules = this.requireStore().list().sort((a, b) => (a.trigger.nextRunAt ?? "").localeCompare(b.trigger.nextRunAt ?? ""));
@@ -677,7 +718,7 @@ export class ScheduledRunManager {
677
718
  }
678
719
  show(params) {
679
720
  const schedule = this.resolve(params);
680
- return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Quiet: ${schedule.quiet === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, schedule.target.missionId === undefined ? undefined : `Mission: ${schedule.target.missionId}`, `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]);
721
+ return textResult([`Schedule: ${schedule.id}`, `Name: ${schedule.name}`, `State: ${schedule.paused ? "paused" : schedule.activeRunId ? "running" : "scheduled"}`, `Session only: ${schedule.sessionOnly === true ? "yes" : "no"}`, `Quiet: ${schedule.quiet === true ? "yes" : "no"}`, `Target: ${targetLabel(schedule.target)}`, schedule.target.missionId === undefined ? undefined : `Mission: ${schedule.target.missionId}`, `CWD: ${shortenPath(schedule.cwd)}`, `Trigger: ${triggerLabel(schedule.trigger)}`, `Next: ${schedule.trigger.nextRunAt ?? "none"}`, `Catch up: ${schedule.catchUp}`, schedule.activeRunId ? `Active run: ${schedule.activeRunId}` : undefined].filter(Boolean).join("\n"), [schedule]);
681
722
  }
682
723
  history(params) {
683
724
  const schedule = this.resolve(params);
@@ -708,11 +749,20 @@ export class ScheduledRunManager {
708
749
  }
709
750
  if (params.quiet !== undefined && typeof params.quiet !== "boolean")
710
751
  return textResult("quiet must be a boolean.", undefined, undefined, true);
752
+ const manualTrigger = schedule.trigger;
711
753
  const run = await this.launch(store, schedule, this.now(), "manual", false, params.quiet === true);
712
754
  const updated = store.get(schedule.id);
713
755
  if (run.state === "running") {
714
756
  const now = this.now();
715
- if (updated.trigger.kind === "interval")
757
+ if (updated.trigger.kind === "calendar") {
758
+ if (manualTrigger.kind !== "calendar")
759
+ throw new Error("Schedule trigger changed during manual launch.");
760
+ const consumed = latestCalendarOccurrence(manualTrigger, now, manualTrigger.nextLocalDate)?.nextRunAt ?? manualTrigger.nextRunAt;
761
+ const advanced = nextAfter(manualTrigger, Date.parse(consumed), now);
762
+ if (advanced.nextLocalDate > updated.trigger.nextLocalDate)
763
+ updated.trigger = advanced;
764
+ }
765
+ else if (updated.trigger.kind === "interval")
716
766
  updated.trigger.nextRunAt = timestamp(now + updated.trigger.everyMs);
717
767
  else
718
768
  updated.trigger.nextRunAt = undefined;
@@ -741,7 +791,7 @@ export class ScheduledRunManager {
741
791
  const schedule = this.resolve(params);
742
792
  const store = this.requireStore();
743
793
  if (schedule.activeRunId) {
744
- const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
794
+ const run = this.activeRun(store, schedule);
745
795
  let terminal = false;
746
796
  if (run?.scheduleId === schedule.id && run.state === "running" && run.asyncId && run.asyncDir) {
747
797
  const status = readJson(path.join(run.asyncDir, "status.json"), "async status");
@@ -762,8 +812,16 @@ export class ScheduledRunManager {
762
812
  restoreOne(store, schedule, notBefore, rearm = true) {
763
813
  if (!scheduleBelongsToSession(schedule, this.requireContext(store)))
764
814
  return;
815
+ if (schedule.trigger.kind === "calendar") {
816
+ const refreshed = restoreCalendarTrigger(schedule.trigger);
817
+ if (JSON.stringify(refreshed) !== JSON.stringify(schedule.trigger)) {
818
+ schedule.trigger = refreshed;
819
+ schedule.updatedAt = timestamp(this.now());
820
+ store.write(schedule);
821
+ }
822
+ }
765
823
  if (schedule.activeRunId) {
766
- const run = store.history(schedule.id).find((item) => item.id === schedule.activeRunId);
824
+ const run = this.activeRun(store, schedule);
767
825
  if (run?.state === "running" && run.asyncId)
768
826
  this.observedAsyncIds.add(run.asyncId);
769
827
  const startedAt = run?.startedAt ? Date.parse(run.startedAt) : Number.NaN;
@@ -833,9 +891,9 @@ export class ScheduledRunManager {
833
891
  if (!schedule)
834
892
  return;
835
893
  const now = this.now();
836
- const planned = schedule.trigger.kind === "interval" ? duePlannedAt(schedule, now) : undefined;
837
- const notBefore = planned !== undefined && planned <= now ? Date.parse(nextAfter(schedule.trigger, planned, now)) : undefined;
838
- this.restoreOne(store, schedule, notBefore, schedule.trigger.kind === "interval");
894
+ const planned = schedule.trigger.kind !== "once" ? duePlannedAt(schedule, now) : undefined;
895
+ const notBefore = planned !== undefined && planned <= now ? Date.parse(nextAfter(schedule.trigger, planned, now).nextRunAt) : undefined;
896
+ this.restoreOne(store, schedule, notBefore, schedule.trigger.kind !== "once");
839
897
  }
840
898
  catch (error) {
841
899
  console.warn(`[pi-subagents] Scheduled run '${id}' could not be restored after fire failure: ${error instanceof Error ? error.message : String(error)}`);
@@ -862,12 +920,13 @@ export class ScheduledRunManager {
862
920
  async launch(store, schedule, planned, dueReason, advance, quiet) {
863
921
  const now = this.now();
864
922
  const nextRunAtBeforeClaim = schedule.trigger.nextRunAt;
923
+ const nextLocalDateBeforeClaim = schedule.trigger.kind === "calendar" ? schedule.trigger.nextLocalDate : undefined;
865
924
  const run = { schemaVersion: 1, id: this.randomId(), scheduleId: schedule.id, plannedAt: timestamp(planned), dueReason, state: "running", startedAt: timestamp(now) };
866
925
  if (schedule.activeRunId) {
867
926
  run.state = "skipped";
868
927
  run.completedAt = timestamp(now);
869
928
  if (advance) {
870
- schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
929
+ schedule.trigger = nextAfter(schedule.trigger, planned, now);
871
930
  schedule.updatedAt = timestamp(now);
872
931
  store.write(schedule);
873
932
  }
@@ -884,26 +943,75 @@ export class ScheduledRunManager {
884
943
  fs.closeSync(lock);
885
944
  }
886
945
  catch (error) {
946
+ if (lock !== undefined) {
947
+ // An empty or partial claim would make every later launch skip. Keep the
948
+ // descriptor open until the inode check so a replacement owner's lock survives.
949
+ try {
950
+ // Bigint stats: Windows file ids exceed 2^53, so number inodes of nearby files can compare equal.
951
+ const owned = fs.fstatSync(lock, { bigint: true });
952
+ const current = fs.lstatSync(lockPath, { bigint: true });
953
+ if (owned.ino !== 0n && owned.dev === current.dev && owned.ino === current.ino)
954
+ fs.rmSync(lockPath);
955
+ }
956
+ catch { /* Preserve the original error. */ }
957
+ try {
958
+ fs.closeSync(lock);
959
+ }
960
+ catch { /* Preserve the original error. */ }
961
+ throw error;
962
+ }
887
963
  if (error.code !== "EEXIST")
888
964
  throw error;
889
965
  run.state = "skipped";
890
966
  run.completedAt = timestamp(now);
891
- if (advance) {
892
- schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
893
- schedule.updatedAt = timestamp(now);
894
- store.write(schedule);
967
+ // Losing the claim gives this snapshot no authority to change the owner.
968
+ const latest = store.find(schedule.id);
969
+ if (!latest) {
970
+ this.clearTimer(store, schedule.id);
971
+ return run;
972
+ }
973
+ store.writeRun(latest, run, "schedule.skipped_overlap");
974
+ if (latest.trigger.kind === "once") {
975
+ this.clearTimer(store, latest.id);
976
+ }
977
+ else {
978
+ // The owner may hold the lock before persisting its cursor. Back off
979
+ // locally without consuming that pending occurrence on disk.
980
+ const next = nextRunAt(latest);
981
+ const notBefore = next !== undefined && next <= now
982
+ ? Date.parse(nextAfter(latest.trigger, duePlannedAt(latest, now) ?? planned, now).nextRunAt)
983
+ : undefined;
984
+ this.arm(latest, store, notBefore);
895
985
  }
896
- store.writeRun(schedule, run, "schedule.skipped_overlap");
897
- this.arm(schedule, store);
898
986
  return run;
899
987
  }
900
988
  schedule.activeRunId = run.id;
901
989
  schedule.lastRunId = run.id;
902
990
  if (advance)
903
- schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
991
+ schedule.trigger = nextAfter(schedule.trigger, planned, now);
904
992
  schedule.updatedAt = timestamp(now);
905
- store.write(schedule);
906
- store.writeRun(schedule, run, "schedule.run.started");
993
+ try {
994
+ store.write(schedule);
995
+ store.writeRun(schedule, run, "schedule.run.started");
996
+ }
997
+ catch (error) {
998
+ // No child has launched: release this run's claim, then report the original error.
999
+ // Another session may already have recovered the claim and launched its own run.
1000
+ try {
1001
+ const latest = store.find(schedule.id);
1002
+ if (latest?.activeRunId === run.id) {
1003
+ latest.activeRunId = undefined;
1004
+ latest.updatedAt = timestamp(this.now());
1005
+ store.write(latest);
1006
+ }
1007
+ if (fs.readFileSync(lockPath, "utf-8") === run.id)
1008
+ fs.rmSync(lockPath);
1009
+ }
1010
+ catch (cleanupError) {
1011
+ console.warn(`[pi-subagents] Could not release schedule claim '${run.id}': ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`);
1012
+ }
1013
+ throw error;
1014
+ }
907
1015
  try {
908
1016
  const result = await this.deps.launch(executionParams(schedule, dueReason === "manual" ? quiet === true : schedule.quiet === true), this.requireContext(store), new AbortController().signal);
909
1017
  const asyncId = result.details?.asyncId ?? result.details?.runId;
@@ -917,27 +1025,45 @@ export class ScheduledRunManager {
917
1025
  return run;
918
1026
  }
919
1027
  catch (error) {
1028
+ // The child is running, so its claim stays until completion.
1029
+ if (run.asyncId)
1030
+ throw new Error(`Scheduled run '${run.id}' attached to async run '${run.asyncId}', but recording it failed: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
920
1031
  run.state = "failed_launch";
921
1032
  run.completedAt = timestamp(this.now());
922
1033
  run.error = error instanceof Error ? error.message : String(error);
923
1034
  const latest = store.get(schedule.id);
924
1035
  latest.activeRunId = undefined;
925
- if (!advance && nextRunAtBeforeClaim)
926
- latest.trigger.nextRunAt = nextRunAtBeforeClaim;
1036
+ if (!advance && nextRunAtBeforeClaim) {
1037
+ if (latest.trigger.kind === "calendar") {
1038
+ // An overlapping timer may have advanced both cursor fields while
1039
+ // this manual attachment was pending. Failure satisfies neither.
1040
+ if (nextLocalDateBeforeClaim !== undefined) {
1041
+ latest.trigger.nextLocalDate = nextLocalDateBeforeClaim;
1042
+ latest.trigger.nextRunAt = nextRunAtBeforeClaim;
1043
+ }
1044
+ }
1045
+ else
1046
+ latest.trigger.nextRunAt = nextRunAtBeforeClaim;
1047
+ }
927
1048
  latest.updatedAt = timestamp(this.now());
928
1049
  store.write(latest);
929
- store.writeRun(latest, run, "schedule.run.failed");
930
1050
  fs.rmSync(lockPath, { force: true });
931
- this.arm(latest, store);
1051
+ try {
1052
+ store.writeRun(latest, run, "schedule.run.failed");
1053
+ }
1054
+ finally {
1055
+ this.arm(latest, store);
1056
+ }
932
1057
  return run;
933
1058
  }
934
1059
  }
935
1060
  finishRun(store, schedule, run, success, error) {
936
1061
  const now = this.now();
937
1062
  const next = nextRunAt(schedule);
1063
+ let skipped;
938
1064
  if (next !== undefined && next <= now) {
939
1065
  const planned = duePlannedAt(schedule, now);
940
- const skipped = {
1066
+ skipped = {
941
1067
  schemaVersion: 1,
942
1068
  id: this.randomId(),
943
1069
  scheduleId: schedule.id,
@@ -946,8 +1072,7 @@ export class ScheduledRunManager {
946
1072
  state: "skipped",
947
1073
  completedAt: timestamp(now),
948
1074
  };
949
- schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, now);
950
- store.writeRun(schedule, skipped, "schedule.skipped_overlap");
1075
+ schedule.trigger = nextAfter(schedule.trigger, planned, now);
951
1076
  }
952
1077
  if (run.asyncId)
953
1078
  this.observedAsyncIds.delete(run.asyncId);
@@ -959,8 +1084,15 @@ export class ScheduledRunManager {
959
1084
  schedule.updatedAt = timestamp(now);
960
1085
  store.write(schedule);
961
1086
  fs.rmSync(path.join(store.directory(schedule.id), "active.lock"), { force: true });
962
- store.writeRun(schedule, run, success ? "schedule.run.completed" : "schedule.run.failed");
963
- this.arm(schedule, store);
1087
+ // The schedule is already released; a history.json timeout must not leave it unarmed.
1088
+ try {
1089
+ store.writeRun(schedule, run, success ? "schedule.run.completed" : "schedule.run.failed");
1090
+ if (skipped)
1091
+ store.writeRun(schedule, skipped, "schedule.skipped_overlap");
1092
+ }
1093
+ finally {
1094
+ this.arm(schedule, store);
1095
+ }
964
1096
  }
965
1097
  recordMissed(store, schedule, planned, dueReason) {
966
1098
  const run = {
@@ -972,7 +1104,7 @@ export class ScheduledRunManager {
972
1104
  state: "missed",
973
1105
  completedAt: timestamp(this.now()),
974
1106
  };
975
- schedule.trigger.nextRunAt = nextAfter(schedule.trigger, planned, this.now());
1107
+ schedule.trigger = nextAfter(schedule.trigger, planned, this.now());
976
1108
  schedule.updatedAt = timestamp(this.now());
977
1109
  store.write(schedule);
978
1110
  store.writeRun(schedule, run, "schedule.missed");
@@ -1018,6 +1150,11 @@ export class ScheduledRunManager {
1018
1150
  throw new Error("Schedule runtime context is unavailable.");
1019
1151
  return ctx;
1020
1152
  }
1153
+ activeRun(store, schedule) {
1154
+ if (!schedule.activeRunId)
1155
+ return undefined;
1156
+ return store.getRun(schedule.id, schedule.activeRunId) ?? store.history(schedule.id).find((run) => run.id === schedule.activeRunId);
1157
+ }
1021
1158
  timerKey(store, id) {
1022
1159
  return `${store.root}\0${id}`;
1023
1160
  }