@monotykamary/localterm-server 2.80.2 → 2.81.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 (70) hide show
  1. package/dist/agent-runner.d.ts +1 -0
  2. package/dist/agent-runner.d.ts.map +1 -1
  3. package/dist/agent-runner.js +203 -151
  4. package/dist/agent-runner.js.map +1 -1
  5. package/dist/automation-dispatcher.d.ts +27 -0
  6. package/dist/automation-dispatcher.d.ts.map +1 -0
  7. package/dist/automation-dispatcher.js +195 -0
  8. package/dist/automation-dispatcher.js.map +1 -0
  9. package/dist/automation-run-tracker.d.ts +1 -1
  10. package/dist/automation-run-tracker.d.ts.map +1 -1
  11. package/dist/automation-run-tracker.js +2 -2
  12. package/dist/automation-run-tracker.js.map +1 -1
  13. package/dist/automation-scheduler.d.ts +7 -4
  14. package/dist/automation-scheduler.d.ts.map +1 -1
  15. package/dist/automation-scheduler.js +59 -39
  16. package/dist/automation-scheduler.js.map +1 -1
  17. package/dist/automation-store.d.ts +1 -0
  18. package/dist/automation-store.d.ts.map +1 -1
  19. package/dist/automation-store.js +59 -23
  20. package/dist/automation-store.js.map +1 -1
  21. package/dist/constants.d.ts +12 -1
  22. package/dist/constants.d.ts.map +1 -1
  23. package/dist/constants.js +13 -1
  24. package/dist/constants.js.map +1 -1
  25. package/dist/index.d.ts.map +1 -1
  26. package/dist/index.js +195 -213
  27. package/dist/index.js.map +1 -1
  28. package/dist/pi-rpc-client.d.ts +9 -3
  29. package/dist/pi-rpc-client.d.ts.map +1 -1
  30. package/dist/pi-rpc-client.js +133 -32
  31. package/dist/pi-rpc-client.js.map +1 -1
  32. package/dist/protocol.d.ts +2 -0
  33. package/dist/protocol.d.ts.map +1 -1
  34. package/dist/protocol.js +2 -0
  35. package/dist/protocol.js.map +1 -1
  36. package/dist/schemas.d.ts +882 -1
  37. package/dist/schemas.d.ts.map +1 -1
  38. package/dist/schemas.js +57 -8
  39. package/dist/schemas.js.map +1 -1
  40. package/dist/utils/compile-schedule.d.ts +1 -1
  41. package/dist/utils/compile-schedule.d.ts.map +1 -1
  42. package/dist/utils/compile-schedule.js +7 -6
  43. package/dist/utils/compile-schedule.js.map +1 -1
  44. package/dist/utils/compute-next-automation-run-at.d.ts.map +1 -1
  45. package/dist/utils/compute-next-automation-run-at.js +3 -18
  46. package/dist/utils/compute-next-automation-run-at.js.map +1 -1
  47. package/dist/utils/is-active-automation-run.d.ts +3 -0
  48. package/dist/utils/is-active-automation-run.d.ts.map +1 -0
  49. package/dist/utils/is-active-automation-run.js +2 -0
  50. package/dist/utils/is-active-automation-run.js.map +1 -0
  51. package/dist/utils/is-valid-time-zone.d.ts +2 -0
  52. package/dist/utils/is-valid-time-zone.d.ts.map +1 -0
  53. package/dist/utils/is-valid-time-zone.js +14 -0
  54. package/dist/utils/is-valid-time-zone.js.map +1 -0
  55. package/dist/utils/next-schedule-occurrence.d.ts +4 -0
  56. package/dist/utils/next-schedule-occurrence.d.ts.map +1 -0
  57. package/dist/utils/next-schedule-occurrence.js +201 -0
  58. package/dist/utils/next-schedule-occurrence.js.map +1 -0
  59. package/dist/utils/reconcile-downtime.d.ts.map +1 -1
  60. package/dist/utils/reconcile-downtime.js +4 -24
  61. package/dist/utils/reconcile-downtime.js.map +1 -1
  62. package/dist/utils/trim-automation-runs.d.ts +3 -0
  63. package/dist/utils/trim-automation-runs.d.ts.map +1 -0
  64. package/dist/utils/trim-automation-runs.js +7 -0
  65. package/dist/utils/trim-automation-runs.js.map +1 -0
  66. package/package.json +1 -1
  67. package/vendor/node-pty/prebuilds/darwin-arm64/pty.node +0 -0
  68. package/vendor/node-pty/prebuilds/darwin-arm64/spawn-helper +0 -0
  69. package/vendor/node-pty/prebuilds/darwin-x64/pty.node +0 -0
  70. package/vendor/node-pty/prebuilds/darwin-x64/spawn-helper +0 -0
package/dist/index.js CHANGED
@@ -1,4 +1,3 @@
1
- import { randomUUID } from "node:crypto";
2
1
  import { spawn } from "node:child_process";
3
2
  import fs from "node:fs";
4
3
  import os from "node:os";
@@ -9,6 +8,9 @@ import { bodyLimit } from "hono/body-limit";
9
8
  import open from "open";
10
9
  import { WebSocketServer } from "ws";
11
10
  import { AutomationRunTracker } from "./automation-run-tracker.js";
11
+ import { AutomationDispatcher } from "./automation-dispatcher.js";
12
+ import { isActiveAutomationRun } from "./utils/is-active-automation-run.js";
13
+ import { isValidTimeZone } from "./utils/is-valid-time-zone.js";
12
14
  import { AutomationScheduler } from "./automation-scheduler.js";
13
15
  import { AutomationStore } from "./automation-store.js";
14
16
  import { runAgent, compactAgent, listAgentModels, readAgentSession } from "./agent-runner.js";
@@ -25,7 +27,7 @@ import { detectWithExplicitPort } from "./cdp/discover-explicit-endpoint.js";
25
27
  import { DaemonConfigStore } from "./daemon-config-store.js";
26
28
  import { formatCandidates, resolveCandidates, resolveCompletionContext, } from "./completion/index.js";
27
29
  import { z } from "zod";
28
- import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RECONCILE_MIN_DOWNTIME_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, CDP_EXTENSION_BROWSER_NAME, CDP_EXTENSION_PATH, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_DIRTY_THROTTLE_MS, GIT_MAX_REF_LENGTH, HERDR_THEME_SYNC_DEBOUNCE_MS, HIBERNATE_FILENAME, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, HTTP_STATUS_PAYLOAD_TOO_LARGE, HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, MAX_AUTOMATIONS, MAX_IMAGE_UPLOAD_BYTES, MAX_IMAGE_UPLOAD_REQUEST_BYTES, MAX_PROCESSES, MAX_SECRETS, MS_PER_MINUTE, PROCESSES_FILENAME, SECRETS_FILENAME, THEMES_FILENAME, FONTS_FILENAME, SECRETS_SHIMS_DIRNAME, SECRET_EXPORT_SCRYPT_WORK_FACTOR, AUTOMATION_AGENT_SESSIONS_DIRNAME, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, SESSION_ACTIVITY_WINDOW_MS, WINDOW_ID_QUERY_PARAM, WAIT_DEFAULT_TIMEOUT_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_SPAWN_FAILED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_MAX_INBOUND_PAYLOAD_BYTES, WS_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, UPDATE_CHECK_FILENAME, } from "./constants.js";
30
+ import { ACTIVITY_DIRNAME, ACTIVITY_REFRESH_DEBOUNCE_MS, ACTIVITY_WATCHED_PROGRAMS, AUTOMATION_EVENT_DEBOUNCE_MS, AUTOMATION_RUN_QUERY_PARAM, AUTOMATION_WATCH_DEBOUNCE_MS, AUTOMATION_WATCH_POST_RUN_GRACE_MS, AUTOMATION_WEBHOOK_DEBOUNCE_MS, CDP_EXTENSION_BROWSER_NAME, CDP_EXTENSION_PATH, DEFAULT_HOST, DEFAULT_PORT, FRIENDLY_HOSTNAME, GIT_DIRTY_THROTTLE_MS, GIT_MAX_REF_LENGTH, HERDR_THEME_SYNC_DEBOUNCE_MS, HIBERNATE_FILENAME, HTTP_STATUS_ACCEPTED, HTTP_STATUS_BAD_REQUEST, HTTP_STATUS_CONFLICT, HTTP_STATUS_SERVICE_UNAVAILABLE, HTTP_STATUS_CREATED, HTTP_STATUS_NOT_FOUND, HTTP_STATUS_PAYLOAD_TOO_LARGE, HTTP_STATUS_UNSUPPORTED_MEDIA_TYPE, MAX_AUTOMATIONS, MAX_IMAGE_UPLOAD_BYTES, MAX_IMAGE_UPLOAD_REQUEST_BYTES, MAX_PROCESSES, MAX_SECRETS, PROCESSES_FILENAME, SECRETS_FILENAME, THEMES_FILENAME, FONTS_FILENAME, SECRETS_SHIMS_DIRNAME, SECRET_EXPORT_SCRYPT_WORK_FACTOR, AUTOMATION_AGENT_SESSIONS_DIRNAME, SERVER_STOP_GRACE_MS, SESSION_ID_QUERY_PARAM, SESSION_ACTIVITY_WINDOW_MS, WINDOW_ID_QUERY_PARAM, WAIT_DEFAULT_TIMEOUT_MS, WS_BACKPRESSURE_THRESHOLD_BYTES, WS_CLOSE_BACKPRESSURE, WS_CLOSE_CAPACITY_REACHED, WS_CLOSE_SPAWN_FAILED, WS_CLOSE_POLICY_VIOLATION, WS_HEARTBEAT_GRACE_MS, WS_HEARTBEAT_INTERVAL_MS, WS_HEARTBEAT_TIMEOUT_MS, WS_MAX_INBOUND_PAYLOAD_BYTES, WS_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, UPDATE_CHECK_FILENAME, } from "./constants.js";
29
31
  import { getDefaultShell, listKnownShells, resolveShellOverride } from "./default-shell.js";
30
32
  import { resolveWindowId } from "./utils/resolve-window-id.js";
31
33
  import { resolveTermBackspaceSequence } from "./utils/resolve-term-backspace-sequence.js";
@@ -82,7 +84,6 @@ import { isLocaltermTabUrl } from "./utils/is-localterm-tab-url.js";
82
84
  import { normalizeTriggerInput } from "./utils/normalize-trigger.js";
83
85
  import { buildAutomationSecretEnv } from "./utils/build-automation-secret-env.js";
84
86
  import { migrateSecretsToProcesses } from "./utils/migrate-secrets-to-processes.js";
85
- import { enumerateMissedOccurrences } from "./utils/reconcile-downtime.js";
86
87
  const callRawMethod = (raw, method) => {
87
88
  if (!raw || typeof raw !== "object")
88
89
  return false;
@@ -197,7 +198,7 @@ const normalizeMouseAction = (input) => {
197
198
  };
198
199
  const buildApiRoutes = (ctx) => {
199
200
  const api = new Hono();
200
- const { registry, ownerFor, cdpClient, secretBackend, secretExportScryptWorkFactor, secretStore, shimsDir, processStore, themeStore, fontStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, mintViewerCookie, stateDirectory, broadcastThemes, broadcastFonts, updateCheckStore, } = ctx;
201
+ const { registry, ownerFor, cdpClient, secretBackend, secretExportScryptWorkFactor, secretStore, shimsDir, processStore, themeStore, fontStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, automationDispatcher, refreshAutomationSchedule, getCdpPort, applyCdpPort, getGraceSeconds, applyGraceSeconds, connectCdpNow, buildTabUrl, mintViewerCookie, stateDirectory, broadcastThemes, broadcastFonts, updateCheckStore, } = ctx;
201
202
  // Headless SGR-1006 fallback for `mouse` when no CDP tab is reachable:
202
203
  // encode the gesture as SGR bytes and write them straight to the PTY. Closes
203
204
  // over `registry` so the automation layer stays CDP-agnostic. Coords arrive
@@ -1353,6 +1354,8 @@ const buildApiRoutes = (ctx) => {
1353
1354
  const normalized = normalizeTriggerInput(trigger);
1354
1355
  if (normalized.kind === "watch" || normalized.kind === "event" || normalized.kind === "webhook")
1355
1356
  return true;
1357
+ if (normalized.schedule.kind === "interval")
1358
+ return true;
1356
1359
  const crons = compileScheduleAll(normalized.schedule);
1357
1360
  return crons.length > 0 && crons.every((cron) => parseCronExpression(cron) !== null);
1358
1361
  };
@@ -1375,7 +1378,16 @@ const buildApiRoutes = (ctx) => {
1375
1378
  if (unknown.length > 0)
1376
1379
  return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
1377
1380
  }
1381
+ if (parsed.data.timezone !== undefined && !isValidTimeZone(parsed.data.timezone)) {
1382
+ return context.json({ error: "invalid_timezone" }, HTTP_STATUS_BAD_REQUEST);
1383
+ }
1384
+ if (parsed.data.runner.kind === "agent" &&
1385
+ parsed.data.runner.sessionMode === "thread" &&
1386
+ parsed.data.concurrencyPolicy === "allow") {
1387
+ return context.json({ error: "thread_requires_serial_execution" }, HTTP_STATUS_BAD_REQUEST);
1388
+ }
1378
1389
  const automation = automationStore.create(parsed.data);
1390
+ refreshAutomationSchedule();
1379
1391
  broadcastAutomations();
1380
1392
  syncFolderWatchers();
1381
1393
  syncSessionEventListeners();
@@ -1399,6 +1411,16 @@ const buildApiRoutes = (ctx) => {
1399
1411
  const existing = automationStore.get(context.req.param("id"));
1400
1412
  if (!existing)
1401
1413
  return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1414
+ if (parsed.data.timezone !== undefined && !isValidTimeZone(parsed.data.timezone)) {
1415
+ return context.json({ error: "invalid_timezone" }, HTTP_STATUS_BAD_REQUEST);
1416
+ }
1417
+ const runner = parsed.data.runner ?? existing.runner;
1418
+ if ((parsed.data.runner !== undefined || parsed.data.concurrencyPolicy !== undefined) &&
1419
+ runner.kind === "agent" &&
1420
+ runner.sessionMode === "thread" &&
1421
+ (parsed.data.concurrencyPolicy ?? existing.concurrencyPolicy) === "allow") {
1422
+ return context.json({ error: "thread_requires_serial_execution" }, HTTP_STATUS_BAD_REQUEST);
1423
+ }
1402
1424
  // A PATCH never un-finishes — re-enabling a finished automation must go
1403
1425
  // through reset so it can't accidentally fire past its limit.
1404
1426
  if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
@@ -1407,14 +1429,24 @@ const buildApiRoutes = (ctx) => {
1407
1429
  const automation = automationStore.update(context.req.param("id"), parsed.data);
1408
1430
  if (!automation)
1409
1431
  return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1432
+ automationDispatcher.drain(automation.id);
1433
+ refreshAutomationSchedule();
1410
1434
  broadcastAutomations();
1411
1435
  syncFolderWatchers();
1412
1436
  syncSessionEventListeners();
1413
- return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
1437
+ return context.json({
1438
+ automation: toAutomationWithNextRun(automationStore.get(automation.id) ?? automation, new Date()),
1439
+ });
1414
1440
  });
1415
1441
  api.delete("/automations/:id", (context) => {
1416
1442
  const id = context.req.param("id");
1417
1443
  const automation = automationStore.get(id);
1444
+ if (automation && automationDispatcher.isBusy(automation.id)) {
1445
+ return context.json({
1446
+ error: "automation_busy",
1447
+ message: "Wait for active runs or cancel queued work before deleting.",
1448
+ }, HTTP_STATUS_CONFLICT);
1449
+ }
1418
1450
  if (!automation || !automationStore.remove(id)) {
1419
1451
  return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1420
1452
  }
@@ -1442,7 +1474,17 @@ const buildApiRoutes = (ctx) => {
1442
1474
  const runId = tryLaunch(automation, "manual");
1443
1475
  if (!runId)
1444
1476
  return context.json({ error: "launch_failed" }, HTTP_STATUS_BAD_REQUEST);
1445
- return context.json({ runId });
1477
+ const run = automationStore.get(automation.id)?.runs.find((entry) => entry.runId === runId);
1478
+ return context.json({ runId, status: run?.status });
1479
+ });
1480
+ api.post("/automations/:id/runs/:runId/cancel", (context) => {
1481
+ const id = context.req.param("id");
1482
+ if (!automationStore.get(id))
1483
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1484
+ if (!automationDispatcher.cancel(id, context.req.param("runId"))) {
1485
+ return context.json({ error: "run_not_queued" }, HTTP_STATUS_CONFLICT);
1486
+ }
1487
+ return context.json({ ok: true });
1446
1488
  });
1447
1489
  api.post("/automations/:id/reset", async (context) => {
1448
1490
  const parsed = resetAutomationInputSchema.safeParse((await readJsonBody(context)) ?? {});
@@ -1451,6 +1493,7 @@ const buildApiRoutes = (ctx) => {
1451
1493
  const automation = automationStore.reset(context.req.param("id"), parsed.data.clearHistory);
1452
1494
  if (!automation)
1453
1495
  return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1496
+ refreshAutomationSchedule();
1454
1497
  broadcastAutomations();
1455
1498
  // Reset re-enables + reactivates; a watch automation resumes watching.
1456
1499
  syncFolderWatchers();
@@ -1569,26 +1612,34 @@ const buildApiRoutes = (ctx) => {
1569
1612
  if (automation.runner.kind !== "agent" || automation.runner.sessionMode !== "thread") {
1570
1613
  return context.json({ error: "not_compactable" }, HTTP_STATUS_CONFLICT);
1571
1614
  }
1572
- const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
1573
- let secretEnv = {};
1615
+ if (!automationDispatcher.acquireExclusive(automation.id)) {
1616
+ return context.json({ error: "automation_busy" }, HTTP_STATUS_CONFLICT);
1617
+ }
1574
1618
  try {
1575
- secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
1619
+ const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
1620
+ let secretEnv = {};
1621
+ try {
1622
+ secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
1623
+ }
1624
+ catch (error) {
1625
+ const message = error instanceof Error ? error.message : String(error);
1626
+ console.warn(`failed to resolve secrets for compaction of "${automation.name}": ${message}`);
1627
+ }
1628
+ const result = await compactAgent({
1629
+ harness: automation.runner.harness,
1630
+ cwd: automation.cwd,
1631
+ env: secretEnv,
1632
+ sessionFile,
1633
+ shimsDir,
1634
+ });
1635
+ if (!result.ok) {
1636
+ return context.json({ error: "compact_failed", message: result.message }, HTTP_STATUS_BAD_REQUEST);
1637
+ }
1638
+ return context.json({ ok: true, message: result.message });
1576
1639
  }
1577
- catch (error) {
1578
- const message = error instanceof Error ? error.message : String(error);
1579
- console.warn(`failed to resolve secrets for compaction of "${automation.name}": ${message}`);
1580
- }
1581
- const result = await compactAgent({
1582
- harness: automation.runner.harness,
1583
- cwd: automation.cwd,
1584
- env: secretEnv,
1585
- sessionFile,
1586
- shimsDir,
1587
- });
1588
- if (!result.ok) {
1589
- return context.json({ error: "compact_failed", message: result.message }, HTTP_STATUS_BAD_REQUEST);
1640
+ finally {
1641
+ automationDispatcher.releaseExclusive(automation.id);
1590
1642
  }
1591
- return context.json({ ok: true, message: result.message });
1592
1643
  });
1593
1644
  // Restart a thread-mode agent automation from a fresh session: delete the
1594
1645
  // persisted session file so the next fire starts a blank branch instead of
@@ -1601,15 +1652,23 @@ const buildApiRoutes = (ctx) => {
1601
1652
  if (automation.runner.kind !== "agent" || automation.runner.sessionMode !== "thread") {
1602
1653
  return context.json({ error: "not_thread" }, HTTP_STATUS_CONFLICT);
1603
1654
  }
1604
- const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
1655
+ if (!automationDispatcher.acquireExclusive(automation.id)) {
1656
+ return context.json({ error: "automation_busy" }, HTTP_STATUS_CONFLICT);
1657
+ }
1605
1658
  try {
1606
- await fs.promises.rm(sessionFile, { force: true });
1659
+ const sessionFile = path.join(stateDirectory, AUTOMATION_AGENT_SESSIONS_DIRNAME, `${automation.id}.jsonl`);
1660
+ try {
1661
+ await fs.promises.rm(sessionFile, { force: true });
1662
+ }
1663
+ catch (error) {
1664
+ const message = error instanceof Error ? error.message : String(error);
1665
+ return context.json({ error: "clear_thread_failed", message }, HTTP_STATUS_BAD_REQUEST);
1666
+ }
1667
+ return context.json({ ok: true });
1607
1668
  }
1608
- catch (error) {
1609
- const message = error instanceof Error ? error.message : String(error);
1610
- return context.json({ error: "clear_thread_failed", message }, HTTP_STATUS_BAD_REQUEST);
1669
+ finally {
1670
+ automationDispatcher.releaseExclusive(automation.id);
1611
1671
  }
1612
- return context.json({ ok: true });
1613
1672
  });
1614
1673
  // Daemon config (the editable CDP port). GET is a cheap read of the live
1615
1674
  // value; PUT persists it, drops the persistent CDP socket so the next
@@ -1714,6 +1773,8 @@ export const createServer = async (options = {}) => {
1714
1773
  sessionEventManager.onSessionEvent(event, cwd);
1715
1774
  },
1716
1775
  onAutomationExit: (automationId, runId, exitCode, log) => {
1776
+ if (automationExecutionStopped)
1777
+ return;
1717
1778
  automationStore.updateRun(automationId, runId, {
1718
1779
  status: exitCode === 0 ? "completed" : "failed",
1719
1780
  exitCode,
@@ -1724,6 +1785,7 @@ export const createServer = async (options = {}) => {
1724
1785
  closeRunTabIfRequested(automationId, runId);
1725
1786
  folderWatchManager.notifyRunFinished(automationId);
1726
1787
  sessionEventManager.notifyRunFinished(automationId);
1788
+ automationDispatcher.drain(automationId);
1727
1789
  },
1728
1790
  onClientExit: (ws, exitCode) => {
1729
1791
  const targetId = wsToTargetId.get(ws);
@@ -1744,6 +1806,7 @@ export const createServer = async (options = {}) => {
1744
1806
  // server-side offer goes unanswered and the connection stays uncompressed
1745
1807
  // (verified: a no-extension-header client negotiates ""). See
1746
1808
  // session-manager.ts broadcastBytes and the client's DecompressionStream path.
1809
+ let automationExecutionStopped = false;
1747
1810
  const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
1748
1811
  const automationRunTracker = new AutomationRunTracker();
1749
1812
  const automationScheduler = new AutomationScheduler(automationStore);
@@ -1753,20 +1816,14 @@ export const createServer = async (options = {}) => {
1753
1816
  const folderWatchManager = new FolderWatchManager({
1754
1817
  debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
1755
1818
  postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
1756
- isRunInFlight: (automationId) => {
1757
- const status = automationStore.get(automationId)?.runs[0]?.status;
1758
- return status === "launched" || status === "running";
1759
- },
1819
+ isRunInFlight: (automationId) => automationStore.get(automationId)?.runs.some(isActiveAutomationRun) ?? false,
1760
1820
  getAutomation: (automationId) => automationStore.get(automationId),
1761
1821
  });
1762
1822
  const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
1763
1823
  const sessionEventManager = new SessionEventManager({
1764
1824
  debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
1765
1825
  postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
1766
- isRunInFlight: (automationId) => {
1767
- const status = automationStore.get(automationId)?.runs[0]?.status;
1768
- return status === "launched" || status === "running";
1769
- },
1826
+ isRunInFlight: (automationId) => automationStore.get(automationId)?.runs.some(isActiveAutomationRun) ?? false,
1770
1827
  getAutomation: (automationId) => automationStore.get(automationId),
1771
1828
  });
1772
1829
  // Daemon-global git detection for event automations: arms a recursive
@@ -1786,10 +1843,7 @@ export const createServer = async (options = {}) => {
1786
1843
  // Stateless vs the watch/event managers — nothing to arm, so no sync().
1787
1844
  const webhookTriggerManager = new WebhookTriggerManager({
1788
1845
  debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
1789
- isRunInFlight: (automationId) => {
1790
- const status = automationStore.get(automationId)?.runs[0]?.status;
1791
- return status === "launched" || status === "running";
1792
- },
1846
+ isRunInFlight: () => false,
1793
1847
  getAutomation: (automationId) => automationStore.get(automationId),
1794
1848
  });
1795
1849
  const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
@@ -2014,9 +2068,12 @@ export const createServer = async (options = {}) => {
2014
2068
  // log-stripped records (see automationRunWireSchema) so list/broadcast
2015
2069
  // payloads stay small.
2016
2070
  runs: automation.runs.map((run) => {
2017
- const { log, ...wire } = run;
2071
+ const { log, execution: _execution, ...wire } = run;
2018
2072
  return { ...wire, hasLog: log !== null || wire.findings !== null };
2019
2073
  }),
2074
+ timezone: automation.timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone,
2075
+ concurrencyPolicy: automation.concurrencyPolicy ?? "skip",
2076
+ missedRunPolicy: automation.missedRunPolicy ?? "skip",
2020
2077
  nextRunAt: computeNextAutomationRunAt(automation, from),
2021
2078
  cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
2022
2079
  lastRun: deriveLastRun(automation),
@@ -2109,61 +2166,29 @@ export const createServer = async (options = {}) => {
2109
2166
  }
2110
2167
  };
2111
2168
  caffeinateManager.on("change", broadcastCaffeinate);
2112
- // Run an agent automation headlessly via `pi -p`. No tab, no PTY, no WS
2113
- // claim — the daemon spawns the subprocess, captures its stdout as the
2114
- // run's findings, diffs git status for changedFiles, and lands a completed/
2115
- // failed run with an unread Triage flag (when there are findings). The run
2116
- // goes straight to "running" (there is no "launched -> tab claim" step), so
2117
- // reconcileOnStartup's running->missed sweep covers a daemon restart mid-run.
2118
- const launchAgentRun = (automation, trigger) => {
2119
- const now = Date.now();
2120
- const runId = randomUUID();
2121
- const counts = trigger !== "manual";
2122
- automationStore.appendRun(automation.id, {
2123
- runId,
2124
- scheduledFor: trigger === "schedule" ? Math.floor(now / MS_PER_MINUTE) * MS_PER_MINUTE : now,
2125
- startedAt: now,
2126
- finishedAt: null,
2127
- status: "running",
2128
- exitCode: null,
2129
- trigger,
2130
- countsTowardLimit: counts,
2131
- findings: null,
2132
- changedFiles: [],
2133
- unread: false,
2134
- log: null,
2135
- });
2136
- if (counts)
2137
- automationStore.incrementRunCount(automation.id);
2138
- broadcastAutomations();
2139
- syncFolderWatchers();
2140
- syncSessionEventListeners();
2169
+ const agentRuns = new Map();
2170
+ const launchAgentRun = (automation, run) => {
2141
2171
  if (automation.runner.kind !== "agent")
2142
- return runId;
2172
+ return Promise.resolve();
2143
2173
  const runner = automation.runner;
2144
- const sessionFile = runner.sessionMode === "thread"
2145
- ? path.join(agentSessionsDir, `${automation.id}.jsonl`)
2146
- : null;
2147
- void (async () => {
2148
- let secretEnv = {};
2149
- try {
2150
- secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
2151
- }
2152
- catch (error) {
2153
- const message = error instanceof Error ? error.message : String(error);
2154
- console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
2155
- }
2174
+ const controller = new AbortController();
2175
+ const promise = (async () => {
2176
+ const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
2177
+ if (automationExecutionStopped)
2178
+ return;
2156
2179
  const result = await runAgent({
2157
2180
  runner,
2158
2181
  cwd: automation.cwd,
2159
2182
  env: secretEnv,
2160
- sessionFile,
2161
2183
  shimsDir,
2184
+ signal: controller.signal,
2185
+ sessionFile: runner.sessionMode === "thread"
2186
+ ? path.join(agentSessionsDir, `${automation.id}.jsonl`)
2187
+ : null,
2162
2188
  });
2163
- // The automation may have been deleted mid-run; drop the result.
2164
- if (!automationStore.get(automation.id))
2189
+ if (automationExecutionStopped || !automationStore.get(automation.id))
2165
2190
  return;
2166
- automationStore.updateRun(automation.id, runId, {
2191
+ automationStore.updateRun(automation.id, run.runId, {
2167
2192
  status: result.exitCode === 0 ? "completed" : "failed",
2168
2193
  exitCode: result.exitCode,
2169
2194
  finishedAt: Date.now(),
@@ -2175,91 +2200,49 @@ export const createServer = async (options = {}) => {
2175
2200
  broadcastAutomations();
2176
2201
  folderWatchManager.notifyRunFinished(automation.id);
2177
2202
  sessionEventManager.notifyRunFinished(automation.id);
2178
- })();
2179
- return runId;
2203
+ automationDispatcher.drain(automation.id);
2204
+ })().finally(() => agentRuns.delete(run.runId));
2205
+ agentRuns.set(run.runId, { controller, promise });
2206
+ return promise;
2180
2207
  };
2181
- // Open a browser tab for a run and record it in history. Scheduled and watch
2182
- // launches count toward the limit (and can finish the automation); manual
2183
- // launches never count and are allowed even on a finished/disabled automation.
2184
- // Agent runs bypass the tab/PTY path entirely (headless). Returns the runId
2185
- // (null only when a non-manual trigger hits the finished/disabled guard).
2186
- const tryLaunch = (automation, trigger) => {
2187
- if (trigger !== "manual") {
2188
- const current = automationStore.get(automation.id);
2189
- if (!current || !current.enabled || current.lifecycle === "finished")
2190
- return null;
2191
- }
2192
- if (automation.runner.kind === "agent")
2193
- return launchAgentRun(automation, trigger);
2194
- const run = automationRunTracker.create(automation);
2195
- const counts = trigger !== "manual";
2196
- automationStore.appendRun(automation.id, {
2197
- runId: run.runId,
2198
- scheduledFor: trigger === "schedule"
2199
- ? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
2200
- : run.createdAt,
2201
- startedAt: run.createdAt,
2202
- finishedAt: null,
2203
- status: "launched",
2204
- exitCode: null,
2205
- trigger,
2206
- countsTowardLimit: counts,
2207
- findings: null,
2208
- changedFiles: [],
2209
- unread: false,
2210
- log: null,
2211
- });
2212
- if (counts)
2213
- automationStore.incrementRunCount(automation.id);
2214
- broadcastAutomations();
2215
- // A watch automation that just reached its limit is now "finished"; stop
2216
- // watching its folder promptly instead of waiting for the next mutation.
2217
- syncFolderWatchers();
2218
- syncSessionEventListeners();
2219
- // Open the run tab at the announced LOCAL surface origin when the CLI
2220
- // resolved one (portless / loopback) — run tabs open in the daemon's own
2221
- // debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
2222
- // relay, cert renewal) would fail the tab load and the automation, so they
2223
- // never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
2224
- // back to `publicOrigin` (then the loopback form) so a caller that only set
2225
- // `publicUrl` keeps the prior single-surface behavior. A bare origin (no
2226
- // path) is the contract, so the `new URL` base rewrites any stray path and
2227
- // searchParams encodes the id.
2208
+ const launchShellRun = async (automation, record) => {
2209
+ const run = automationRunTracker.create(automation, record.startedAt ?? Date.now(), record.runId);
2210
+ // Use the local surface: a waking laptop's tailnet may not be available yet.
2228
2211
  const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
2229
2212
  runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
2230
- // Resolve requested secrets before opening the run tab so the env is set on
2231
- // the pending run by the time the WS claims it. The claim happens only after
2232
- // the browser loads this tab, which is gated on the resolution below, so
2233
- // `onOpen` always sees the resolved env. The launch stays synchronous up to
2234
- // here — the pending run + "launched" history are already recorded, so the
2235
- // `isRunInFlight` overlap guard holds across the await. A secret-resolution
2236
- // error is logged but does not block the tab (the run still starts, just
2237
- // without the failed secret).
2238
- void (async () => {
2239
- try {
2240
- const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
2241
- automationRunTracker.setEnv(run.runId, secretEnv);
2242
- if (automation.redactOutput) {
2243
- automationRunTracker.setRedactionValues(run.runId, Object.values(secretEnv));
2244
- }
2245
- }
2246
- catch (error) {
2247
- const message = error instanceof Error ? error.message : String(error);
2248
- console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
2249
- }
2250
- try {
2251
- const handle = await tabController.open(runUrl.href);
2252
- // Remember the tab so `automation-exit` can close it if closeOnFinish.
2253
- if (handle)
2213
+ try {
2214
+ const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
2215
+ automationRunTracker.setEnv(run.runId, secretEnv);
2216
+ if (automation.redactOutput)
2217
+ automationRunTracker.setRedactionValues(run.runId, Object.values(secretEnv));
2218
+ if (automationExecutionStopped)
2219
+ return;
2220
+ const handle = await tabController.open(runUrl.href);
2221
+ if (handle) {
2222
+ if (automationExecutionStopped)
2223
+ await tabController.close(handle);
2224
+ else
2254
2225
  runTabHandles.set(run.runId, handle);
2255
2226
  }
2256
- catch (error) {
2257
- const message = error instanceof Error ? error.message : String(error);
2258
- console.warn(`failed to open a browser tab for automation "${automation.name}": ${message}`);
2259
- }
2260
- })();
2261
- return run.runId;
2227
+ }
2228
+ catch (error) {
2229
+ automationRunTracker.claim(run.runId);
2230
+ throw error;
2231
+ }
2262
2232
  };
2233
+ const automationDispatcher = new AutomationDispatcher({
2234
+ store: automationStore,
2235
+ launch: (automation, run) => automation.runner.kind === "agent"
2236
+ ? launchAgentRun(automation, run)
2237
+ : launchShellRun(automation, run),
2238
+ changed: () => {
2239
+ broadcastAutomations();
2240
+ syncFolderWatchers();
2241
+ syncSessionEventListeners();
2242
+ },
2243
+ });
2244
+ const refreshAutomationSchedule = () => automationScheduler.refresh();
2245
+ const tryLaunch = (automation, trigger) => automationDispatcher.request(automation.id, trigger);
2263
2246
  // Close a finished run's tab when the automation opted into closeOnFinish.
2264
2247
  const closeRunTabIfRequested = (automationId, runId) => {
2265
2248
  const handle = runTabHandles.get(runId);
@@ -2267,51 +2250,14 @@ export const createServer = async (options = {}) => {
2267
2250
  if (!handle)
2268
2251
  return;
2269
2252
  const automation = automationStore.get(automationId);
2270
- if (!automation?.closeOnFinish)
2253
+ const run = automation?.runs.find((entry) => entry.runId === runId);
2254
+ if (!(run?.execution?.closeOnFinish ?? automation?.closeOnFinish))
2271
2255
  return;
2272
2256
  void tabController.close(handle).catch((error) => {
2273
2257
  const message = error instanceof Error ? error.message : String(error);
2274
2258
  console.warn(`failed to close automation tab (run ${runId}): ${message}`);
2275
2259
  });
2276
2260
  };
2277
- // On boot, settle the state the dead process left behind: any run still
2278
- // "launched"/"running" can never resume (the run tracker is in-memory), so it
2279
- // becomes "missed"; and if the daemon was down across scheduled times, record
2280
- // those as "skipped" so the user can see what didn't run while the machine was
2281
- // off. Skipped runs never launch and never count toward a limit. No clients
2282
- // exist yet, so nothing is broadcast.
2283
- const reconcileOnStartup = (now) => {
2284
- const lastAliveAt = heartbeatStore.read();
2285
- const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
2286
- for (const automation of automationStore.list()) {
2287
- for (const run of automation.runs) {
2288
- if (run.status === "launched" || run.status === "running") {
2289
- automationStore.updateRun(automation.id, run.runId, {
2290
- status: "missed",
2291
- finishedAt: now,
2292
- });
2293
- }
2294
- }
2295
- if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
2296
- continue;
2297
- for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
2298
- automationStore.appendRun(automation.id, {
2299
- runId: randomUUID(),
2300
- scheduledFor: occurrence,
2301
- startedAt: null,
2302
- finishedAt: occurrence,
2303
- status: "skipped",
2304
- exitCode: null,
2305
- trigger: "schedule",
2306
- countsTowardLimit: false,
2307
- findings: null,
2308
- changedFiles: [],
2309
- unread: false,
2310
- log: null,
2311
- });
2312
- }
2313
- }
2314
- };
2315
2261
  const getCdpPort = () => cdpPort;
2316
2262
  // Live read of the persisted no-clients grace window (seconds; `null` = never
2317
2263
  // reap). The SessionManager's getGraceMs closure converts to ms at arm time.
@@ -2401,6 +2347,8 @@ export const createServer = async (options = {}) => {
2401
2347
  toAutomationWithNextRun,
2402
2348
  listAutomationsWithNextRun,
2403
2349
  tryLaunch,
2350
+ automationDispatcher,
2351
+ refreshAutomationSchedule,
2404
2352
  getCdpPort,
2405
2353
  applyCdpPort,
2406
2354
  getGraceSeconds,
@@ -2615,11 +2563,21 @@ export const createServer = async (options = {}) => {
2615
2563
  });
2616
2564
  runTabHandles.delete(claimedRun.runId);
2617
2565
  broadcastAutomations();
2566
+ automationDispatcher.drain(claimedRun.automationId);
2618
2567
  }
2619
2568
  ws.close(WS_CLOSE_SPAWN_FAILED, error.message);
2620
2569
  return;
2621
2570
  }
2622
2571
  if (!spawned) {
2572
+ if (claimedRun) {
2573
+ automationStore.updateRun(claimedRun.automationId, claimedRun.runId, {
2574
+ status: "failed",
2575
+ reason: "capacity",
2576
+ finishedAt: Date.now(),
2577
+ });
2578
+ broadcastAutomations();
2579
+ automationDispatcher.drain(claimedRun.automationId);
2580
+ }
2623
2581
  ws.close(WS_CLOSE_CAPACITY_REACHED, "session capacity reached");
2624
2582
  return;
2625
2583
  }
@@ -2846,9 +2804,13 @@ export const createServer = async (options = {}) => {
2846
2804
  if (!httpServer) {
2847
2805
  throw new ServerErrorException(serverError.listenFailed(host, port, new Error("hono serve() resolved without binding an http server")));
2848
2806
  }
2849
- automationScheduler.on("due", (automation) => {
2850
- tryLaunch(automation, "schedule");
2807
+ automationScheduler.on("due", (automation, scheduledFor) => {
2808
+ automationDispatcher.request(automation.id, "schedule", scheduledFor);
2851
2809
  });
2810
+ automationScheduler.on("skipped", (automation, scheduledFor) => {
2811
+ automationDispatcher.request(automation.id, "schedule", scheduledFor, "downtime");
2812
+ });
2813
+ automationScheduler.on("fault", (error) => console.error("automation scheduling failed", error));
2852
2814
  folderWatchManager.on("due", (automation) => {
2853
2815
  tryLaunch(automation, "watch");
2854
2816
  });
@@ -2858,9 +2820,21 @@ export const createServer = async (options = {}) => {
2858
2820
  webhookTriggerManager.on("due", (automation) => {
2859
2821
  tryLaunch(automation, "webhook");
2860
2822
  });
2861
- automationScheduler.on("tick", (now) => {
2862
- // Liveness heartbeat for downtime detection on the next boot.
2863
- heartbeatStore.write(now.getTime());
2823
+ automationScheduler.on("tick", (now, healthy) => {
2824
+ let dispatchHealthy = healthy;
2825
+ // A queue commit can succeed while the subsequent launch commit fails.
2826
+ // Retry accepted work even when its occurrence watermark already advanced.
2827
+ for (const automation of automationStore.list()) {
2828
+ try {
2829
+ automationDispatcher.drain(automation.id);
2830
+ }
2831
+ catch (error) {
2832
+ dispatchHealthy = false;
2833
+ console.error("automation queue dispatch failed", error);
2834
+ }
2835
+ }
2836
+ if (dispatchHealthy)
2837
+ heartbeatStore.write(now.getTime());
2864
2838
  let didExpireAny = false;
2865
2839
  for (const expiredRun of automationRunTracker.sweepExpired(now.getTime())) {
2866
2840
  const automation = automationStore.get(expiredRun.automationId);
@@ -2871,13 +2845,14 @@ export const createServer = async (options = {}) => {
2871
2845
  status: "missed",
2872
2846
  finishedAt: now.getTime(),
2873
2847
  });
2848
+ automationDispatcher.drain(automation.id);
2874
2849
  didExpireAny = true;
2875
2850
  }
2876
2851
  if (didExpireAny)
2877
2852
  broadcastAutomations();
2878
2853
  });
2879
- reconcileOnStartup(Date.now());
2880
- automationScheduler.start();
2854
+ automationDispatcher.recover();
2855
+ automationScheduler.start(heartbeatStore.read());
2881
2856
  // Arm folder-watch triggers for the automations loaded at boot.
2882
2857
  syncFolderWatchers();
2883
2858
  syncSessionEventListeners();
@@ -2909,10 +2884,17 @@ export const createServer = async (options = {}) => {
2909
2884
  const stop = () => {
2910
2885
  if (stopPromise)
2911
2886
  return stopPromise;
2887
+ automationExecutionStopped = true;
2888
+ automationDispatcher.dispose();
2889
+ automationScheduler.dispose();
2890
+ for (const run of agentRuns.values())
2891
+ run.controller.abort();
2912
2892
  stopPromise = (async () => {
2893
+ await Promise.allSettled([...agentRuns.values()].map((run) => run.promise));
2913
2894
  hibernateStore.write(await registry.hibernateEntries());
2914
2895
  automationScheduler.dispose();
2915
2896
  folderWatchManager.dispose();
2897
+ sessionEventManager.dispose();
2916
2898
  automationGitWatcher.dispose();
2917
2899
  updateCheckStore.dispose();
2918
2900
  webhookTriggerManager.dispose();