@monotykamary/localterm-server 2.27.1 → 2.27.3

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.
package/dist/index.js CHANGED
@@ -101,369 +101,9 @@ const safeSend = (ws, payload) => {
101
101
  /* socket closed between readyState check and send */
102
102
  }
103
103
  };
104
- export const createServer = async (options = {}) => {
105
- const port = options.port ?? DEFAULT_PORT;
106
- const host = options.host ?? DEFAULT_HOST;
107
- const staticRoot = typeof options.staticRoot === "string" ? path.resolve(options.staticRoot) : null;
108
- const isLoopbackBind = isLoopbackHost(host);
109
- if (!isLoopbackBind) {
110
- console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
111
- }
112
- // The session manager owns every live PTY for the daemon's lifetime. A PTY
113
- // persists across client detach (closing a tab detaches instead of killing
114
- // it) so the session picker can re-attach to it; it dies on shell exit, an
115
- // explicit kill from the picker, or the dormant-idle sweep. Multiple clients
116
- // may attach to one PTY and fan out output/resize to all of them. The hooks
117
- // close over managers defined further below; they only fire at runtime
118
- // (attach/detach/output/exit), so referencing the later consts here is safe.
119
- const stateDirectory = options.stateDirectory ?? path.join(os.homedir(), ".localterm");
120
- const shimsDir = path.join(stateDirectory, SECRETS_SHIMS_DIRNAME);
121
- const registry = new SessionManager({
122
- shimsDir,
123
- sendControl: safeSend,
124
- hooks: {
125
- onOutputActivity: () => caffeinateManager.noteOutputActivity(),
126
- onSessionActivity: () => caffeinateManager.pokeAuto(),
127
- onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
128
- onAutomationExit: (automationId, runId, exitCode) => {
129
- automationStore.updateRun(automationId, runId, {
130
- status: exitCode === 0 ? "completed" : "failed",
131
- exitCode,
132
- finishedAt: Date.now(),
133
- });
134
- broadcastAutomations();
135
- closeRunTabIfRequested(automationId, runId);
136
- folderWatchManager.notifyRunFinished(automationId);
137
- sessionEventManager.notifyRunFinished(automationId);
138
- },
139
- onClientExit: (ws, exitCode) => {
140
- const targetId = wsToTargetId.get(ws);
141
- if (targetId && (exitCode === null || exitCode === 0))
142
- void cdpClient?.closeTab(targetId);
143
- },
144
- },
145
- });
146
- const app = new Hono();
147
- app.use("*", createNetworkPolicyMiddleware(host, () => publicOrigin));
148
- const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
149
- wss.options.maxPayload = 256 * 1024;
150
- const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
151
- const automationRunTracker = new AutomationRunTracker();
152
- const automationScheduler = new AutomationScheduler(automationStore);
153
- // Folder-watch triggers: one fs.watch per watch automation's cwd (no polling).
154
- // isRunInFlight gates overlap (a launched/running latest run blocks a new
155
- // launch); getAutomation re-reads live state when the debounce fires.
156
- const folderWatchManager = new FolderWatchManager({
157
- debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
158
- postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
159
- isRunInFlight: (automationId) => {
160
- const status = automationStore.get(automationId)?.runs[0]?.status;
161
- return status === "launched" || status === "running";
162
- },
163
- getAutomation: (automationId) => automationStore.get(automationId),
164
- });
165
- const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
166
- const sessionEventManager = new SessionEventManager({
167
- debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
168
- postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
169
- isRunInFlight: (automationId) => {
170
- const status = automationStore.get(automationId)?.runs[0]?.status;
171
- return status === "launched" || status === "running";
172
- },
173
- getAutomation: (automationId) => automationStore.get(automationId),
174
- });
175
- const syncSessionEventListeners = () => sessionEventManager.sync(automationStore.list());
176
- // Webhook triggers: a POST to /api/webhooks/:id arms a trailing debounce per
177
- // automation (coalesces duplicate delivery) with an in-flight overlap guard.
178
- // Stateless vs the watch/event managers — nothing to arm, so no sync().
179
- const webhookTriggerManager = new WebhookTriggerManager({
180
- debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
181
- isRunInFlight: (automationId) => {
182
- const status = automationStore.get(automationId)?.runs[0]?.status;
183
- return status === "launched" || status === "running";
184
- },
185
- getAutomation: (automationId) => automationStore.get(automationId),
186
- });
187
- const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
188
- const caffeinateController = options.caffeinateController ?? new CaffeinateController();
189
- const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
190
- const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
191
- // Per-process secret injection: a backend (macOS Keychain on darwin) holds
192
- // secret values; a secret is an identity + the env var it exports
193
- // (~/.localterm/secrets.json, names + env var only — never values), and a
194
- // process is a binary name plus the secret names it should receive
195
- // (~/.localterm/processes.json) — the same multi-select model automations use
196
- // for requestedSecrets. The daemon generates a PATH shim per process in
197
- // ~/.localterm/shims; localterm's shell hook prepends the shims dir so the
198
- // shims shadow the real binaries and inject the secret(s) at exec time.
199
- // The one-time migrator rewrites a pre-flip secrets.json (with `programs`) to
200
- // the new shape + a processes.json before the stores load either file.
201
- migrateSecretsToProcesses(stateDirectory);
202
- const secretBackend = options.secretBackend ?? createDefaultSecretBackend();
203
- const secretStore = new SecretStore({
204
- filePath: path.join(stateDirectory, SECRETS_FILENAME),
205
- shimsDir,
206
- });
207
- const processStore = new ProcessStore(path.join(stateDirectory, PROCESSES_FILENAME));
208
- const syncSecretShims = () => regenerateShims(processStore.list(), secretStore.envVarByName(), shimsDir, secretBackend);
209
- syncSecretShims();
210
- const caffeinateManager = new CaffeinateManager({
211
- controller: caffeinateController,
212
- store: caffeinatePreferencesStore,
213
- listSessionPids: () => registry.pids(),
214
- snapshotProcesses: options.caffeinateSnapshotProcesses,
215
- batteryProbe: options.caffeinateBatteryProbe,
216
- hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
217
- });
218
- // Open dev ports: the daemon reads the process tree (ps) and the listening
219
- // socket table (lsof) on demand while the ports modal is open. Both are
220
- // injectable so tests can drive the list deterministically without a real
221
- // listener; the tree snapshot defaults to the same `ps` read keep-awake's
222
- // automatic mode uses (one shared subprocess per poll).
223
- const portsSnapshotProcesses = options.portsSnapshotProcesses ?? defaultCaffeinateSnapshotProcesses;
224
- const portsSnapshotListeners = options.portsSnapshotListeners ?? defaultSnapshotListeners;
225
- const clientSockets = new Set();
226
- // CDP target paired with each WS via the {type:"identify"} handshake, so the
227
- // manager's onClientExit hook can drive closeTab on a clean shell exit for
228
- // that specific socket. Per-WS (a CDP target belongs to one page); cleared
229
- // on detach.
230
- const wsToTargetId = new Map();
231
- const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
232
- // One persistent CDP socket for the daemon's lifetime — opened once at start
233
- // (below), so the user clears the browser's remote-debugging prompt a single
234
- // time rather than on every run. Skipped when a caller injects its own
235
- // `tabController` (it owns tab control) or when disabled via env.
236
- const cdpClient = options.tabController || cdpBackgroundTabsDisabled
237
- ? null
238
- : new CdpClient({
239
- detect: options.cdpDetect,
240
- // Only page-type targets on the daemon's own origin get an ambient token
241
- // injected — unrelated tabs the user has open in their debugged browser
242
- // stay untouched. `actualPort` is bound by the http server's listen
243
- // callback below; the filter is only invoked at targetCreated event
244
- // time (after CdpClient.connect, which runs after listen), so it reads
245
- // the resolved port rather than the pre-bind placeholder. `publicOrigin`
246
- // is read live for the same reason — set post-bind via setPublicUrl.
247
- tabUrlFilter: (candidateUrl) => isLocaltermTabUrl(candidateUrl, actualPort, host, publicOrigin, localOrigin),
248
- });
249
- const tabController = options.tabController ?? {
250
- open: async (url) => {
251
- // Best case: if a debug-enabled Chromium browser is running, open the run
252
- // tab via CDP with `background: true` so it lands *behind* the active tab
253
- // (a true background tab, no focus steal) and stays closeable. This is how
254
- // browser-harness-js does it, over a connection we keep alive across runs.
255
- if (cdpClient) {
256
- const handle = await cdpClient.openBackgroundTab(url);
257
- if (handle)
258
- return handle;
259
- }
260
- // Fallback: the OS opener. `background: true` is macOS `open -g`, which at
261
- // least keeps the browser app from coming to the foreground; ignored
262
- // elsewhere. Used whenever CDP isn't available (non-Chromium default
263
- // browser, remote debugging off, or LOCALTERM_DISABLE_CDP_TABS=1). Not
264
- // closeable, so `closeOnFinish` is silently a no-op on this path.
265
- await open(url, { background: true });
266
- return null;
267
- },
268
- close: async (handle) => {
269
- if (cdpClient)
270
- await cdpClient.closeTab(handle);
271
- },
272
- };
273
- // Maps a run id -> the tab handle that ran it, so we can close the tab when
274
- // the command finishes (only set when the opener returned a closeable handle).
275
- const runTabHandles = new Map();
276
- // Announced REMOTE surface origin for mobile/remote tabs + the `--open`
277
- // browser + the network-policy host allowlist (tailnet / portless / null =
278
- // loopback). A `let` rather than a const so the CLI can swap it in after
279
- // `listen` resolves the bound port and surface; `tryLaunch` and the CDP
280
- // `tabUrlFilter` read it live, so a post-bind `setPublicUrl` takes effect
281
- // for runs and token injection without re-wiring either closure.
282
- let publicOrigin = options.publicUrl ?? null;
283
- const setPublicUrl = (url) => {
284
- publicOrigin = url;
285
- };
286
- // Announced LOCAL surface origin automation-run tabs open at — a daemon-local
287
- // origin (portless / loopback) that doesn't ride the tailnet, so a flapping
288
- // `tailscale serve` can't fail the run-tab load and the automation. Read live
289
- // by `tryLaunch` and the CDP `tabUrlFilter` for the same reason as
290
- // `publicOrigin`; falls back to `publicOrigin` (then the loopback default)
291
- // when unset so a caller that only set `publicUrl` keeps the prior behavior.
292
- let localOrigin = options.localUrl ?? null;
293
- const setLocalUrl = (url) => {
294
- localOrigin = url;
295
- };
296
- // Project the newest run as the legacy `lastRun` for back-compat clients.
297
- const deriveLastRun = (automation) => {
298
- const latest = automation.runs[0];
299
- if (!latest)
300
- return null;
301
- return {
302
- runId: latest.runId,
303
- at: latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor,
304
- status: latest.status,
305
- exitCode: latest.exitCode,
306
- };
307
- };
308
- const toAutomationWithNextRun = (automation, from) => ({
309
- ...automation,
310
- nextRunAt: computeNextAutomationRunAt(automation, from),
311
- cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
312
- lastRun: deriveLastRun(automation),
313
- });
314
- const listAutomationsWithNextRun = () => {
315
- const from = new Date();
316
- return automationStore.list().map((automation) => toAutomationWithNextRun(automation, from));
317
- };
318
- const broadcastAutomations = () => {
319
- const payload = {
320
- type: "automations",
321
- automations: listAutomationsWithNextRun(),
322
- };
323
- for (const clientSocket of clientSockets) {
324
- safeSend(clientSocket, payload);
325
- }
326
- };
327
- const caffeinateStatePayload = () => ({
328
- type: "caffeinate",
329
- supported: caffeinateManager.supported,
330
- active: caffeinateManager.active,
331
- mode: caffeinateManager.mode,
332
- activityGate: caffeinateManager.activityGate,
333
- batteryThreshold: caffeinateManager.batteryThreshold,
334
- defaultCommands: [...caffeinateManager.defaultCommands],
335
- commands: caffeinateManager.commands,
336
- activeTrigger: caffeinateManager.activeTrigger,
337
- });
338
- // The daemon owns the single keep-awake process, so its state is broadcast to
339
- // every tab — exactly like automations — and the coffee controls stay in sync.
340
- const broadcastCaffeinate = () => {
341
- const payload = caffeinateStatePayload();
342
- for (const clientSocket of clientSockets) {
343
- safeSend(clientSocket, payload);
344
- }
345
- };
346
- caffeinateManager.on("change", broadcastCaffeinate);
347
- // Open a browser tab for a run and record it in history. Scheduled and watch
348
- // launches count toward the limit (and can finish the automation); manual
349
- // launches never count and are allowed even on a finished/disabled automation.
350
- const tryLaunch = (automation, trigger) => {
351
- if (trigger !== "manual") {
352
- const current = automationStore.get(automation.id);
353
- if (!current || !current.enabled || current.lifecycle === "finished")
354
- return null;
355
- }
356
- const run = automationRunTracker.create(automation);
357
- const counts = trigger !== "manual";
358
- automationStore.appendRun(automation.id, {
359
- runId: run.runId,
360
- scheduledFor: trigger === "schedule"
361
- ? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
362
- : run.createdAt,
363
- startedAt: run.createdAt,
364
- finishedAt: null,
365
- status: "launched",
366
- exitCode: null,
367
- trigger,
368
- countsTowardLimit: counts,
369
- });
370
- if (counts)
371
- automationStore.incrementRunCount(automation.id);
372
- broadcastAutomations();
373
- // A watch automation that just reached its limit is now "finished"; stop
374
- // watching its folder promptly instead of waiting for the next mutation.
375
- syncFolderWatchers();
376
- syncSessionEventListeners();
377
- // Open the run tab at the announced LOCAL surface origin when the CLI
378
- // resolved one (portless / loopback) — run tabs open in the daemon's own
379
- // debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
380
- // relay, cert renewal) would fail the tab load and the automation, so they
381
- // never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
382
- // back to `publicOrigin` (then the loopback form) so a caller that only set
383
- // `publicUrl` keeps the prior single-surface behavior. A bare origin (no
384
- // path) is the contract, so the `new URL` base rewrites any stray path and
385
- // searchParams encodes the id.
386
- const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
387
- runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
388
- // Resolve requested secrets before opening the run tab so the env is set on
389
- // the pending run by the time the WS claims it. The claim happens only after
390
- // the browser loads this tab, which is gated on the resolution below, so
391
- // `onOpen` always sees the resolved env. The launch stays synchronous up to
392
- // here — the pending run + "launched" history are already recorded, so the
393
- // `isRunInFlight` overlap guard holds across the await. A secret-resolution
394
- // error is logged but does not block the tab (the run still starts, just
395
- // without the failed secret).
396
- void (async () => {
397
- try {
398
- const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
399
- automationRunTracker.setEnv(run.runId, secretEnv);
400
- }
401
- catch (error) {
402
- const message = error instanceof Error ? error.message : String(error);
403
- console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
404
- }
405
- try {
406
- const handle = await tabController.open(runUrl.href);
407
- // Remember the tab so `automation-exit` can close it if closeOnFinish.
408
- if (handle)
409
- runTabHandles.set(run.runId, handle);
410
- }
411
- catch (error) {
412
- const message = error instanceof Error ? error.message : String(error);
413
- console.warn(`failed to open a browser tab for automation "${automation.name}": ${message}`);
414
- }
415
- })();
416
- return run;
417
- };
418
- // Close a finished run's tab when the automation opted into closeOnFinish.
419
- const closeRunTabIfRequested = (automationId, runId) => {
420
- const handle = runTabHandles.get(runId);
421
- runTabHandles.delete(runId);
422
- if (!handle)
423
- return;
424
- const automation = automationStore.get(automationId);
425
- if (!automation?.closeOnFinish)
426
- return;
427
- void tabController.close(handle).catch((error) => {
428
- const message = error instanceof Error ? error.message : String(error);
429
- console.warn(`failed to close automation tab (run ${runId}): ${message}`);
430
- });
431
- };
432
- // On boot, settle the state the dead process left behind: any run still
433
- // "launched"/"running" can never resume (the run tracker is in-memory), so it
434
- // becomes "missed"; and if the daemon was down across scheduled times, record
435
- // those as "skipped" so the user can see what didn't run while the machine was
436
- // off. Skipped runs never launch and never count toward a limit. No clients
437
- // exist yet, so nothing is broadcast.
438
- const reconcileOnStartup = (now) => {
439
- const lastAliveAt = heartbeatStore.read();
440
- const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
441
- for (const automation of automationStore.list()) {
442
- for (const run of automation.runs) {
443
- if (run.status === "launched" || run.status === "running") {
444
- automationStore.updateRun(automation.id, run.runId, {
445
- status: "missed",
446
- finishedAt: now,
447
- });
448
- }
449
- }
450
- if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
451
- continue;
452
- for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
453
- automationStore.appendRun(automation.id, {
454
- runId: randomUUID(),
455
- scheduledFor: occurrence,
456
- startedAt: null,
457
- finishedAt: occurrence,
458
- status: "skipped",
459
- exitCode: null,
460
- trigger: "schedule",
461
- countsTowardLimit: false,
462
- });
463
- }
464
- }
465
- };
104
+ const buildApiRoutes = (ctx) => {
466
105
  const api = new Hono();
106
+ const { registry, cdpClient, secretBackend, secretStore, shimsDir, processStore, syncSecretShims, automationStore, broadcastAutomations, syncFolderWatchers, syncSessionEventListeners, webhookTriggerManager, worktreeConfigStore, portsSnapshotProcesses, portsSnapshotListeners, toAutomationWithNextRun, listAutomationsWithNextRun, tryLaunch, } = ctx;
467
107
  api.get("/health", (context) => context.json({
468
108
  ok: true,
469
109
  sessions: registry.size(),
@@ -881,220 +521,605 @@ export const createServer = async (options = {}) => {
881
521
  return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
882
522
  return context.json(await worktreeConfigStore.update(cwd, parsed.data));
883
523
  });
884
- // Repo-root `.worktreeinclude` file: a gitignore-syntax allowlist of gitignored
885
- // files copied from the main worktree into each fresh worktree. Exposed so the
886
- // UI can show the current contents (or an empty editor when none exists) and
887
- // let the user create or update it.
888
- api.get("/git/worktrees/include-file", async (context) => {
889
- const cwd = resolveCwdQuery(context.req.query("cwd"));
890
- if (!cwd)
891
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
892
- const file = await readWorktreeIncludeFile(cwd);
893
- if (!file)
894
- return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
895
- return context.json(file);
524
+ // Repo-root `.worktreeinclude` file: a gitignore-syntax allowlist of gitignored
525
+ // files copied from the main worktree into each fresh worktree. Exposed so the
526
+ // UI can show the current contents (or an empty editor when none exists) and
527
+ // let the user create or update it.
528
+ api.get("/git/worktrees/include-file", async (context) => {
529
+ const cwd = resolveCwdQuery(context.req.query("cwd"));
530
+ if (!cwd)
531
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
532
+ const file = await readWorktreeIncludeFile(cwd);
533
+ if (!file)
534
+ return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
535
+ return context.json(file);
536
+ });
537
+ api.put("/git/worktrees/include-file", async (context) => {
538
+ const cwd = resolveCwdQuery(context.req.query("cwd"));
539
+ if (!cwd)
540
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
541
+ const parsed = worktreeIncludeFileInputSchema.safeParse(await readJsonBody(context));
542
+ if (!parsed.success)
543
+ return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
544
+ const file = await writeWorktreeIncludeFile(cwd, parsed.data.content);
545
+ if (!file)
546
+ return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
547
+ return context.json(file);
548
+ });
549
+ // Remove stale, clean, auto-created worktrees older than the sweep threshold.
550
+ // Never removes the current/main worktree or any the user made manually; a
551
+ // dirty worktree is left untouched. Returns the paths removed. The branch
552
+ // refs survive `git worktree remove`, so swept work is recoverable.
553
+ api.post("/git/worktrees/sweep", async (context) => {
554
+ const cwd = resolveCwdQuery(context.req.query("cwd"));
555
+ if (!cwd)
556
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
557
+ try {
558
+ return context.json(await sweepStaleWorktrees(cwd, Date.now(), (worktreePath) => registry.sessionsInPath(worktreePath).length > 0));
559
+ }
560
+ catch (error) {
561
+ return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
562
+ }
563
+ });
564
+ // Launch an external command (an "Open in…" entry) detached in a worktree via
565
+ // the user's login shell so rc-sourced PATH entries resolve. Fire-and-forget:
566
+ // the daemon never waits on the launched process, and its output is discarded
567
+ // (these are GUI launches like `code .`, `fork .`). The daemon already hands
568
+ // out unrestricted shells, so running a user-configured command is not an
569
+ // escalation.
570
+ api.post("/launch", async (context) => {
571
+ const parsed = launchInputSchema.safeParse(await readJsonBody(context));
572
+ if (!parsed.success)
573
+ return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
574
+ if (!resolveCwdQuery(parsed.data.cwd)) {
575
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
576
+ }
577
+ try {
578
+ const child = spawn(getDefaultShell(), ["-l", "-c", parsed.data.command], {
579
+ cwd: parsed.data.cwd,
580
+ detached: true,
581
+ stdio: "ignore",
582
+ env: { ...process.env, PATH: shellPathForUserShell() },
583
+ });
584
+ child.unref();
585
+ }
586
+ catch (error) {
587
+ return context.json({ error: "launch_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
588
+ }
589
+ return context.json({ ok: true });
590
+ });
591
+ api.delete("/git/worktrees", async (context) => {
592
+ const cwd = resolveCwdQuery(context.req.query("cwd"));
593
+ if (!cwd)
594
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
595
+ const targetPath = resolveWorktreePath(cwd, context.req.query("path"));
596
+ if (!targetPath)
597
+ return context.json({ error: "invalid_path" }, HTTP_STATUS_BAD_REQUEST);
598
+ // A live shell sitting in the worktree (attached, dormant in the
599
+ // no-clients grace window, or running an automation) blocks removal —
600
+ // `git worktree remove` would pull the directory out from under the PTY.
601
+ const sessionsOnWorktree = registry.sessionsInPath(targetPath);
602
+ if (sessionsOnWorktree.length > 0) {
603
+ const count = sessionsOnWorktree.length;
604
+ return context.json({
605
+ error: "active_pty",
606
+ message: `${count} shell${count === 1 ? "" : "s"} still open in this worktree — close ${count === 1 ? "it" : "them"} first`,
607
+ }, HTTP_STATUS_CONFLICT);
608
+ }
609
+ try {
610
+ await removeGitWorktree(cwd, targetPath);
611
+ return context.json({ ok: true });
612
+ }
613
+ catch (error) {
614
+ return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
615
+ }
616
+ });
617
+ // A trigger is valid iff a schedule trigger compiles to ≥1 parseable cron;
618
+ // watch and event triggers are always valid (their cwd is validated
619
+ // separately).
620
+ const isValidTriggerInput = (trigger) => {
621
+ const normalized = normalizeTriggerInput(trigger);
622
+ if (normalized.kind === "watch" || normalized.kind === "event" || normalized.kind === "webhook")
623
+ return true;
624
+ const crons = compileScheduleAll(normalized.schedule);
625
+ return crons.length > 0 && crons.every((cron) => parseCronExpression(cron) !== null);
626
+ };
627
+ api.get("/automations", (context) => context.json({ automations: listAutomationsWithNextRun() }));
628
+ api.post("/automations", async (context) => {
629
+ const parsed = createAutomationInputSchema.safeParse(await readJsonBody(context));
630
+ if (!parsed.success)
631
+ return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
632
+ if (automationStore.size() >= MAX_AUTOMATIONS) {
633
+ return context.json({ error: "too_many_automations" }, HTTP_STATUS_BAD_REQUEST);
634
+ }
635
+ if (!isValidTriggerInput(parsed.data.trigger)) {
636
+ return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
637
+ }
638
+ if (!resolveCwdQuery(parsed.data.cwd)) {
639
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
640
+ }
641
+ if (parsed.data.requestedSecrets !== undefined) {
642
+ const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
643
+ if (unknown.length > 0)
644
+ return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
645
+ }
646
+ const automation = automationStore.create(parsed.data);
647
+ broadcastAutomations();
648
+ syncFolderWatchers();
649
+ syncSessionEventListeners();
650
+ return context.json({ automation: toAutomationWithNextRun(automation, new Date()) }, HTTP_STATUS_CREATED);
651
+ });
652
+ api.patch("/automations/:id", async (context) => {
653
+ const parsed = updateAutomationInputSchema.safeParse(await readJsonBody(context));
654
+ if (!parsed.success)
655
+ return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
656
+ if (parsed.data.trigger !== undefined && !isValidTriggerInput(parsed.data.trigger)) {
657
+ return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
658
+ }
659
+ if (parsed.data.cwd !== undefined && !resolveCwdQuery(parsed.data.cwd)) {
660
+ return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
661
+ }
662
+ if (parsed.data.requestedSecrets !== undefined) {
663
+ const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
664
+ if (unknown.length > 0)
665
+ return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
666
+ }
667
+ const existing = automationStore.get(context.req.param("id"));
668
+ if (!existing)
669
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
670
+ // A PATCH never un-finishes — re-enabling a finished automation must go
671
+ // through reset so it can't accidentally fire past its limit.
672
+ if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
673
+ return context.json({ error: "automation_finished" }, HTTP_STATUS_BAD_REQUEST);
674
+ }
675
+ const automation = automationStore.update(context.req.param("id"), parsed.data);
676
+ if (!automation)
677
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
678
+ broadcastAutomations();
679
+ syncFolderWatchers();
680
+ syncSessionEventListeners();
681
+ return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
682
+ });
683
+ api.delete("/automations/:id", (context) => {
684
+ if (!automationStore.remove(context.req.param("id"))) {
685
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
686
+ }
687
+ broadcastAutomations();
688
+ syncFolderWatchers();
689
+ syncSessionEventListeners();
690
+ return context.json({ ok: true });
691
+ });
692
+ api.post("/automations/:id/run", (context) => {
693
+ const automation = automationStore.get(context.req.param("id"));
694
+ if (!automation)
695
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
696
+ // tryLaunch only returns null for non-manual triggers (the finished/disabled
697
+ // guard); a manual launch always succeeds. The guard satisfies the shared
698
+ // nullable return type before reading run.runId.
699
+ const run = tryLaunch(automation, "manual");
700
+ if (!run)
701
+ return context.json({ error: "launch_failed" }, HTTP_STATUS_BAD_REQUEST);
702
+ return context.json({ runId: run.runId });
703
+ });
704
+ api.post("/automations/:id/reset", async (context) => {
705
+ const parsed = resetAutomationInputSchema.safeParse((await readJsonBody(context)) ?? {});
706
+ if (!parsed.success)
707
+ return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
708
+ const automation = automationStore.reset(context.req.param("id"), parsed.data.clearHistory);
709
+ if (!automation)
710
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
711
+ broadcastAutomations();
712
+ // Reset re-enables + reactivates; a watch automation resumes watching.
713
+ syncFolderWatchers();
714
+ syncSessionEventListeners();
715
+ return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
716
+ });
717
+ // Fire a webhook-triggered automation. The :id is the automation's webhook
718
+ // capability token (Discord-style: anyone with the URL can fire it). The body
719
+ // is intentionally ignored — the command/cwd are fixed at create time, so a
720
+ // webhook is a pure signal like schedule/watch/event. The network policy
721
+ // middleware already gates this to the bound surface (loopback, or any
722
+ // private host on a tailnet/non-loopback bind, which covers tailscale's
723
+ // 100.64.0.0/10 CGNAT range), so a POST from another tailnet device reaches
724
+ // it with no extra wiring. Always 2xx on a valid+active id so a CI retry
725
+ // loop never amplifies: duplicates inside the debounce window coalesce, and a
726
+ // POST while a run is in flight is silently dropped (both return 202).
727
+ api.post("/webhooks/:id", (context) => {
728
+ const automation = automationStore.getByWebhookId(context.req.param("id"));
729
+ if (!automation)
730
+ return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
731
+ if (!automation.enabled || automation.lifecycle === "finished") {
732
+ return context.json({ error: "automation_not_active" }, HTTP_STATUS_CONFLICT);
733
+ }
734
+ webhookTriggerManager.trigger(automation);
735
+ return context.json({ accepted: true }, HTTP_STATUS_ACCEPTED);
736
+ });
737
+ api.notFound((context) => context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND));
738
+ return api;
739
+ };
740
+ export const createServer = async (options = {}) => {
741
+ const port = options.port ?? DEFAULT_PORT;
742
+ const host = options.host ?? DEFAULT_HOST;
743
+ const staticRoot = typeof options.staticRoot === "string" ? path.resolve(options.staticRoot) : null;
744
+ const isLoopbackBind = isLoopbackHost(host);
745
+ if (!isLoopbackBind) {
746
+ console.warn(`⚠ non-loopback bind (${host}): any client on the private network can open an unauthenticated shell`);
747
+ }
748
+ // The session manager owns every live PTY for the daemon's lifetime. A PTY
749
+ // persists across client detach (closing a tab detaches instead of killing
750
+ // it) so the session picker can re-attach to it; it dies on shell exit, an
751
+ // explicit kill from the picker, or the dormant-idle sweep. Multiple clients
752
+ // may attach to one PTY and fan out output/resize to all of them. The hooks
753
+ // close over managers defined further below; they only fire at runtime
754
+ // (attach/detach/output/exit), so referencing the later consts here is safe.
755
+ const stateDirectory = options.stateDirectory ?? path.join(os.homedir(), ".localterm");
756
+ const shimsDir = path.join(stateDirectory, SECRETS_SHIMS_DIRNAME);
757
+ const registry = new SessionManager({
758
+ shimsDir,
759
+ sendControl: safeSend,
760
+ hooks: {
761
+ onOutputActivity: () => caffeinateManager.noteOutputActivity(),
762
+ onSessionActivity: () => caffeinateManager.pokeAuto(),
763
+ onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
764
+ onAutomationExit: (automationId, runId, exitCode) => {
765
+ automationStore.updateRun(automationId, runId, {
766
+ status: exitCode === 0 ? "completed" : "failed",
767
+ exitCode,
768
+ finishedAt: Date.now(),
769
+ });
770
+ broadcastAutomations();
771
+ closeRunTabIfRequested(automationId, runId);
772
+ folderWatchManager.notifyRunFinished(automationId);
773
+ sessionEventManager.notifyRunFinished(automationId);
774
+ },
775
+ onClientExit: (ws, exitCode) => {
776
+ const targetId = wsToTargetId.get(ws);
777
+ if (targetId && (exitCode === null || exitCode === 0))
778
+ void cdpClient?.closeTab(targetId);
779
+ },
780
+ },
781
+ });
782
+ const app = new Hono();
783
+ app.use("*", createNetworkPolicyMiddleware(host, () => publicOrigin));
784
+ const { injectWebSocket, upgradeWebSocket, wss } = createNodeWebSocket({ app });
785
+ wss.options.maxPayload = 256 * 1024;
786
+ const automationStore = new AutomationStore(path.join(stateDirectory, "automations.json"));
787
+ const automationRunTracker = new AutomationRunTracker();
788
+ const automationScheduler = new AutomationScheduler(automationStore);
789
+ // Folder-watch triggers: one fs.watch per watch automation's cwd (no polling).
790
+ // isRunInFlight gates overlap (a launched/running latest run blocks a new
791
+ // launch); getAutomation re-reads live state when the debounce fires.
792
+ const folderWatchManager = new FolderWatchManager({
793
+ debounceMs: AUTOMATION_WATCH_DEBOUNCE_MS,
794
+ postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
795
+ isRunInFlight: (automationId) => {
796
+ const status = automationStore.get(automationId)?.runs[0]?.status;
797
+ return status === "launched" || status === "running";
798
+ },
799
+ getAutomation: (automationId) => automationStore.get(automationId),
896
800
  });
897
- api.put("/git/worktrees/include-file", async (context) => {
898
- const cwd = resolveCwdQuery(context.req.query("cwd"));
899
- if (!cwd)
900
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
901
- const parsed = worktreeIncludeFileInputSchema.safeParse(await readJsonBody(context));
902
- if (!parsed.success)
903
- return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
904
- const file = await writeWorktreeIncludeFile(cwd, parsed.data.content);
905
- if (!file)
906
- return context.json({ error: "not_a_git_repo" }, HTTP_STATUS_BAD_REQUEST);
907
- return context.json(file);
801
+ const syncFolderWatchers = () => folderWatchManager.sync(automationStore.list());
802
+ const sessionEventManager = new SessionEventManager({
803
+ debounceMs: AUTOMATION_EVENT_DEBOUNCE_MS,
804
+ postRunGraceMs: AUTOMATION_WATCH_POST_RUN_GRACE_MS,
805
+ isRunInFlight: (automationId) => {
806
+ const status = automationStore.get(automationId)?.runs[0]?.status;
807
+ return status === "launched" || status === "running";
808
+ },
809
+ getAutomation: (automationId) => automationStore.get(automationId),
908
810
  });
909
- // Remove stale, clean, auto-created worktrees older than the sweep threshold.
910
- // Never removes the current/main worktree or any the user made manually; a
911
- // dirty worktree is left untouched. Returns the paths removed. The branch
912
- // refs survive `git worktree remove`, so swept work is recoverable.
913
- api.post("/git/worktrees/sweep", async (context) => {
914
- const cwd = resolveCwdQuery(context.req.query("cwd"));
915
- if (!cwd)
916
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
917
- try {
918
- return context.json(await sweepStaleWorktrees(cwd, Date.now(), (worktreePath) => registry.sessionsInPath(worktreePath).length > 0));
919
- }
920
- catch (error) {
921
- return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
922
- }
811
+ const syncSessionEventListeners = () => sessionEventManager.sync(automationStore.list());
812
+ // Webhook triggers: a POST to /api/webhooks/:id arms a trailing debounce per
813
+ // automation (coalesces duplicate delivery) with an in-flight overlap guard.
814
+ // Stateless vs the watch/event managers nothing to arm, so no sync().
815
+ const webhookTriggerManager = new WebhookTriggerManager({
816
+ debounceMs: AUTOMATION_WEBHOOK_DEBOUNCE_MS,
817
+ isRunInFlight: (automationId) => {
818
+ const status = automationStore.get(automationId)?.runs[0]?.status;
819
+ return status === "launched" || status === "running";
820
+ },
821
+ getAutomation: (automationId) => automationStore.get(automationId),
923
822
  });
924
- // Launch an external command (an "Open in…" entry) detached in a worktree via
925
- // the user's login shell so rc-sourced PATH entries resolve. Fire-and-forget:
926
- // the daemon never waits on the launched process, and its output is discarded
927
- // (these are GUI launches like `code .`, `fork .`). The daemon already hands
928
- // out unrestricted shells, so running a user-configured command is not an
929
- // escalation.
930
- api.post("/launch", async (context) => {
931
- const parsed = launchInputSchema.safeParse(await readJsonBody(context));
932
- if (!parsed.success)
933
- return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
934
- if (!resolveCwdQuery(parsed.data.cwd)) {
935
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
936
- }
937
- try {
938
- const child = spawn(getDefaultShell(), ["-l", "-c", parsed.data.command], {
939
- cwd: parsed.data.cwd,
940
- detached: true,
941
- stdio: "ignore",
942
- env: { ...process.env, PATH: shellPathForUserShell() },
943
- });
944
- child.unref();
945
- }
946
- catch (error) {
947
- return context.json({ error: "launch_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
948
- }
949
- return context.json({ ok: true });
823
+ const heartbeatStore = new HeartbeatStore(path.join(stateDirectory, "daemon-heartbeat.json"));
824
+ const caffeinateController = options.caffeinateController ?? new CaffeinateController();
825
+ const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
826
+ const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
827
+ // Per-process secret injection: a backend (macOS Keychain on darwin) holds
828
+ // secret values; a secret is an identity + the env var it exports
829
+ // (~/.localterm/secrets.json, names + env var only — never values), and a
830
+ // process is a binary name plus the secret names it should receive
831
+ // (~/.localterm/processes.json) — the same multi-select model automations use
832
+ // for requestedSecrets. The daemon generates a PATH shim per process in
833
+ // ~/.localterm/shims; localterm's shell hook prepends the shims dir so the
834
+ // shims shadow the real binaries and inject the secret(s) at exec time.
835
+ // The one-time migrator rewrites a pre-flip secrets.json (with `programs`) to
836
+ // the new shape + a processes.json before the stores load either file.
837
+ migrateSecretsToProcesses(stateDirectory);
838
+ const secretBackend = options.secretBackend ?? createDefaultSecretBackend();
839
+ const secretStore = new SecretStore({
840
+ filePath: path.join(stateDirectory, SECRETS_FILENAME),
841
+ shimsDir,
950
842
  });
951
- api.delete("/git/worktrees", async (context) => {
952
- const cwd = resolveCwdQuery(context.req.query("cwd"));
953
- if (!cwd)
954
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
955
- const targetPath = resolveWorktreePath(cwd, context.req.query("path"));
956
- if (!targetPath)
957
- return context.json({ error: "invalid_path" }, HTTP_STATUS_BAD_REQUEST);
958
- // A live shell sitting in the worktree (attached, dormant in the
959
- // no-clients grace window, or running an automation) blocks removal —
960
- // `git worktree remove` would pull the directory out from under the PTY.
961
- const sessionsOnWorktree = registry.sessionsInPath(targetPath);
962
- if (sessionsOnWorktree.length > 0) {
963
- const count = sessionsOnWorktree.length;
964
- return context.json({
965
- error: "active_pty",
966
- message: `${count} shell${count === 1 ? "" : "s"} still open in this worktree — close ${count === 1 ? "it" : "them"} first`,
967
- }, HTTP_STATUS_CONFLICT);
968
- }
969
- try {
970
- await removeGitWorktree(cwd, targetPath);
971
- return context.json({ ok: true });
972
- }
973
- catch (error) {
974
- return context.json({ error: "git_failed", message: worktreeErrorMessage(error) }, HTTP_STATUS_BAD_REQUEST);
975
- }
843
+ const processStore = new ProcessStore(path.join(stateDirectory, PROCESSES_FILENAME));
844
+ const syncSecretShims = () => regenerateShims(processStore.list(), secretStore.envVarByName(), shimsDir, secretBackend);
845
+ syncSecretShims();
846
+ const caffeinateManager = new CaffeinateManager({
847
+ controller: caffeinateController,
848
+ store: caffeinatePreferencesStore,
849
+ listSessionPids: () => registry.pids(),
850
+ snapshotProcesses: options.caffeinateSnapshotProcesses,
851
+ batteryProbe: options.caffeinateBatteryProbe,
852
+ hasRecentOutput: (pids, withinMs) => registry.hasRecentOutput(pids, withinMs),
976
853
  });
977
- // A trigger is valid iff a schedule trigger compiles to ≥1 parseable cron;
978
- // watch and event triggers are always valid (their cwd is validated
979
- // separately).
980
- const isValidTriggerInput = (trigger) => {
981
- const normalized = normalizeTriggerInput(trigger);
982
- if (normalized.kind === "watch" || normalized.kind === "event" || normalized.kind === "webhook")
983
- return true;
984
- const crons = compileScheduleAll(normalized.schedule);
985
- return crons.length > 0 && crons.every((cron) => parseCronExpression(cron) !== null);
854
+ // Open dev ports: the daemon reads the process tree (ps) and the listening
855
+ // socket table (lsof) on demand while the ports modal is open. Both are
856
+ // injectable so tests can drive the list deterministically without a real
857
+ // listener; the tree snapshot defaults to the same `ps` read keep-awake's
858
+ // automatic mode uses (one shared subprocess per poll).
859
+ const portsSnapshotProcesses = options.portsSnapshotProcesses ?? defaultCaffeinateSnapshotProcesses;
860
+ const portsSnapshotListeners = options.portsSnapshotListeners ?? defaultSnapshotListeners;
861
+ const clientSockets = new Set();
862
+ // CDP target paired with each WS via the {type:"identify"} handshake, so the
863
+ // manager's onClientExit hook can drive closeTab on a clean shell exit for
864
+ // that specific socket. Per-WS (a CDP target belongs to one page); cleared
865
+ // on detach.
866
+ const wsToTargetId = new Map();
867
+ const cdpBackgroundTabsDisabled = process.env.LOCALTERM_DISABLE_CDP_TABS === "1";
868
+ // One persistent CDP socket for the daemon's lifetime — opened once at start
869
+ // (below), so the user clears the browser's remote-debugging prompt a single
870
+ // time rather than on every run. Skipped when a caller injects its own
871
+ // `tabController` (it owns tab control) or when disabled via env.
872
+ const cdpClient = options.tabController || cdpBackgroundTabsDisabled
873
+ ? null
874
+ : new CdpClient({
875
+ detect: options.cdpDetect,
876
+ // Only page-type targets on the daemon's own origin get an ambient token
877
+ // injected — unrelated tabs the user has open in their debugged browser
878
+ // stay untouched. `actualPort` is bound by the http server's listen
879
+ // callback below; the filter is only invoked at targetCreated event
880
+ // time (after CdpClient.connect, which runs after listen), so it reads
881
+ // the resolved port rather than the pre-bind placeholder. `publicOrigin`
882
+ // is read live for the same reason — set post-bind via setPublicUrl.
883
+ tabUrlFilter: (candidateUrl) => isLocaltermTabUrl(candidateUrl, actualPort, host, publicOrigin, localOrigin),
884
+ });
885
+ const tabController = options.tabController ?? {
886
+ open: async (url) => {
887
+ // Best case: if a debug-enabled Chromium browser is running, open the run
888
+ // tab via CDP with `background: true` so it lands *behind* the active tab
889
+ // (a true background tab, no focus steal) and stays closeable. This is how
890
+ // browser-harness-js does it, over a connection we keep alive across runs.
891
+ if (cdpClient) {
892
+ const handle = await cdpClient.openBackgroundTab(url);
893
+ if (handle)
894
+ return handle;
895
+ }
896
+ // Fallback: the OS opener. `background: true` is macOS `open -g`, which at
897
+ // least keeps the browser app from coming to the foreground; ignored
898
+ // elsewhere. Used whenever CDP isn't available (non-Chromium default
899
+ // browser, remote debugging off, or LOCALTERM_DISABLE_CDP_TABS=1). Not
900
+ // closeable, so `closeOnFinish` is silently a no-op on this path.
901
+ await open(url, { background: true });
902
+ return null;
903
+ },
904
+ close: async (handle) => {
905
+ if (cdpClient)
906
+ await cdpClient.closeTab(handle);
907
+ },
908
+ };
909
+ // Maps a run id -> the tab handle that ran it, so we can close the tab when
910
+ // the command finishes (only set when the opener returned a closeable handle).
911
+ const runTabHandles = new Map();
912
+ // Announced REMOTE surface origin for mobile/remote tabs + the `--open`
913
+ // browser + the network-policy host allowlist (tailnet / portless / null =
914
+ // loopback). A `let` rather than a const so the CLI can swap it in after
915
+ // `listen` resolves the bound port and surface; `tryLaunch` and the CDP
916
+ // `tabUrlFilter` read it live, so a post-bind `setPublicUrl` takes effect
917
+ // for runs and token injection without re-wiring either closure.
918
+ let publicOrigin = options.publicUrl ?? null;
919
+ const setPublicUrl = (url) => {
920
+ publicOrigin = url;
921
+ };
922
+ // Announced LOCAL surface origin automation-run tabs open at — a daemon-local
923
+ // origin (portless / loopback) that doesn't ride the tailnet, so a flapping
924
+ // `tailscale serve` can't fail the run-tab load and the automation. Read live
925
+ // by `tryLaunch` and the CDP `tabUrlFilter` for the same reason as
926
+ // `publicOrigin`; falls back to `publicOrigin` (then the loopback default)
927
+ // when unset so a caller that only set `publicUrl` keeps the prior behavior.
928
+ let localOrigin = options.localUrl ?? null;
929
+ const setLocalUrl = (url) => {
930
+ localOrigin = url;
931
+ };
932
+ // Project the newest run as the legacy `lastRun` for back-compat clients.
933
+ const deriveLastRun = (automation) => {
934
+ const latest = automation.runs[0];
935
+ if (!latest)
936
+ return null;
937
+ return {
938
+ runId: latest.runId,
939
+ at: latest.finishedAt ?? latest.startedAt ?? latest.scheduledFor,
940
+ status: latest.status,
941
+ exitCode: latest.exitCode,
942
+ };
986
943
  };
987
- api.get("/automations", (context) => context.json({ automations: listAutomationsWithNextRun() }));
988
- api.post("/automations", async (context) => {
989
- const parsed = createAutomationInputSchema.safeParse(await readJsonBody(context));
990
- if (!parsed.success)
991
- return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
992
- if (automationStore.size() >= MAX_AUTOMATIONS) {
993
- return context.json({ error: "too_many_automations" }, HTTP_STATUS_BAD_REQUEST);
994
- }
995
- if (!isValidTriggerInput(parsed.data.trigger)) {
996
- return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
997
- }
998
- if (!resolveCwdQuery(parsed.data.cwd)) {
999
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
1000
- }
1001
- if (parsed.data.requestedSecrets !== undefined) {
1002
- const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
1003
- if (unknown.length > 0)
1004
- return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
1005
- }
1006
- const automation = automationStore.create(parsed.data);
1007
- broadcastAutomations();
1008
- syncFolderWatchers();
1009
- syncSessionEventListeners();
1010
- return context.json({ automation: toAutomationWithNextRun(automation, new Date()) }, HTTP_STATUS_CREATED);
944
+ const toAutomationWithNextRun = (automation, from) => ({
945
+ ...automation,
946
+ nextRunAt: computeNextAutomationRunAt(automation, from),
947
+ cron: automation.trigger.kind === "schedule" ? compileSchedule(automation.trigger.schedule) : null,
948
+ lastRun: deriveLastRun(automation),
1011
949
  });
1012
- api.patch("/automations/:id", async (context) => {
1013
- const parsed = updateAutomationInputSchema.safeParse(await readJsonBody(context));
1014
- if (!parsed.success)
1015
- return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
1016
- if (parsed.data.trigger !== undefined && !isValidTriggerInput(parsed.data.trigger)) {
1017
- return context.json({ error: "invalid_schedule" }, HTTP_STATUS_BAD_REQUEST);
1018
- }
1019
- if (parsed.data.cwd !== undefined && !resolveCwdQuery(parsed.data.cwd)) {
1020
- return context.json({ error: "invalid_cwd" }, HTTP_STATUS_BAD_REQUEST);
1021
- }
1022
- if (parsed.data.requestedSecrets !== undefined) {
1023
- const unknown = unknownRequestedSecrets(parsed.data.requestedSecrets);
1024
- if (unknown.length > 0)
1025
- return context.json({ error: "invalid_secret" }, HTTP_STATUS_BAD_REQUEST);
1026
- }
1027
- const existing = automationStore.get(context.req.param("id"));
1028
- if (!existing)
1029
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1030
- // A PATCH never un-finishes — re-enabling a finished automation must go
1031
- // through reset so it can't accidentally fire past its limit.
1032
- if (existing.lifecycle === "finished" && parsed.data.enabled === true) {
1033
- return context.json({ error: "automation_finished" }, HTTP_STATUS_BAD_REQUEST);
950
+ const listAutomationsWithNextRun = () => {
951
+ const from = new Date();
952
+ return automationStore.list().map((automation) => toAutomationWithNextRun(automation, from));
953
+ };
954
+ const broadcastAutomations = () => {
955
+ const payload = {
956
+ type: "automations",
957
+ automations: listAutomationsWithNextRun(),
958
+ };
959
+ for (const clientSocket of clientSockets) {
960
+ safeSend(clientSocket, payload);
1034
961
  }
1035
- const automation = automationStore.update(context.req.param("id"), parsed.data);
1036
- if (!automation)
1037
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1038
- broadcastAutomations();
1039
- syncFolderWatchers();
1040
- syncSessionEventListeners();
1041
- return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
962
+ };
963
+ const caffeinateStatePayload = () => ({
964
+ type: "caffeinate",
965
+ supported: caffeinateManager.supported,
966
+ active: caffeinateManager.active,
967
+ mode: caffeinateManager.mode,
968
+ activityGate: caffeinateManager.activityGate,
969
+ batteryThreshold: caffeinateManager.batteryThreshold,
970
+ defaultCommands: [...caffeinateManager.defaultCommands],
971
+ commands: caffeinateManager.commands,
972
+ activeTrigger: caffeinateManager.activeTrigger,
1042
973
  });
1043
- api.delete("/automations/:id", (context) => {
1044
- if (!automationStore.remove(context.req.param("id"))) {
1045
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
974
+ // The daemon owns the single keep-awake process, so its state is broadcast to
975
+ // every tab — exactly like automations — and the coffee controls stay in sync.
976
+ const broadcastCaffeinate = () => {
977
+ const payload = caffeinateStatePayload();
978
+ for (const clientSocket of clientSockets) {
979
+ safeSend(clientSocket, payload);
1046
980
  }
981
+ };
982
+ caffeinateManager.on("change", broadcastCaffeinate);
983
+ // Open a browser tab for a run and record it in history. Scheduled and watch
984
+ // launches count toward the limit (and can finish the automation); manual
985
+ // launches never count and are allowed even on a finished/disabled automation.
986
+ const tryLaunch = (automation, trigger) => {
987
+ if (trigger !== "manual") {
988
+ const current = automationStore.get(automation.id);
989
+ if (!current || !current.enabled || current.lifecycle === "finished")
990
+ return null;
991
+ }
992
+ const run = automationRunTracker.create(automation);
993
+ const counts = trigger !== "manual";
994
+ automationStore.appendRun(automation.id, {
995
+ runId: run.runId,
996
+ scheduledFor: trigger === "schedule"
997
+ ? Math.floor(run.createdAt / MS_PER_MINUTE) * MS_PER_MINUTE
998
+ : run.createdAt,
999
+ startedAt: run.createdAt,
1000
+ finishedAt: null,
1001
+ status: "launched",
1002
+ exitCode: null,
1003
+ trigger,
1004
+ countsTowardLimit: counts,
1005
+ });
1006
+ if (counts)
1007
+ automationStore.incrementRunCount(automation.id);
1047
1008
  broadcastAutomations();
1009
+ // A watch automation that just reached its limit is now "finished"; stop
1010
+ // watching its folder promptly instead of waiting for the next mutation.
1048
1011
  syncFolderWatchers();
1049
1012
  syncSessionEventListeners();
1050
- return context.json({ ok: true });
1051
- });
1052
- api.post("/automations/:id/run", (context) => {
1053
- const automation = automationStore.get(context.req.param("id"));
1054
- if (!automation)
1055
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1056
- // tryLaunch only returns null for non-manual triggers (the finished/disabled
1057
- // guard); a manual launch always succeeds. The guard satisfies the shared
1058
- // nullable return type before reading run.runId.
1059
- const run = tryLaunch(automation, "manual");
1060
- if (!run)
1061
- return context.json({ error: "launch_failed" }, HTTP_STATUS_BAD_REQUEST);
1062
- return context.json({ runId: run.runId });
1063
- });
1064
- api.post("/automations/:id/reset", async (context) => {
1065
- const parsed = resetAutomationInputSchema.safeParse((await readJsonBody(context)) ?? {});
1066
- if (!parsed.success)
1067
- return context.json({ error: "invalid_body" }, HTTP_STATUS_BAD_REQUEST);
1068
- const automation = automationStore.reset(context.req.param("id"), parsed.data.clearHistory);
1069
- if (!automation)
1070
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1071
- broadcastAutomations();
1072
- // Reset re-enables + reactivates; a watch automation resumes watching.
1073
- syncFolderWatchers();
1074
- syncSessionEventListeners();
1075
- return context.json({ automation: toAutomationWithNextRun(automation, new Date()) });
1076
- });
1077
- // Fire a webhook-triggered automation. The :id is the automation's webhook
1078
- // capability token (Discord-style: anyone with the URL can fire it). The body
1079
- // is intentionally ignored — the command/cwd are fixed at create time, so a
1080
- // webhook is a pure signal like schedule/watch/event. The network policy
1081
- // middleware already gates this to the bound surface (loopback, or any
1082
- // private host on a tailnet/non-loopback bind, which covers tailscale's
1083
- // 100.64.0.0/10 CGNAT range), so a POST from another tailnet device reaches
1084
- // it with no extra wiring. Always 2xx on a valid+active id so a CI retry
1085
- // loop never amplifies: duplicates inside the debounce window coalesce, and a
1086
- // POST while a run is in flight is silently dropped (both return 202).
1087
- api.post("/webhooks/:id", (context) => {
1088
- const automation = automationStore.getByWebhookId(context.req.param("id"));
1089
- if (!automation)
1090
- return context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND);
1091
- if (!automation.enabled || automation.lifecycle === "finished") {
1092
- return context.json({ error: "automation_not_active" }, HTTP_STATUS_CONFLICT);
1013
+ // Open the run tab at the announced LOCAL surface origin when the CLI
1014
+ // resolved one (portless / loopback) — run tabs open in the daemon's own
1015
+ // debugged browser, where a flapping `tailscale serve` (laptop wake, DERP
1016
+ // relay, cert renewal) would fail the tab load and the automation, so they
1017
+ // never ride the tailnet even when `publicOrigin` is the tailnet URL. Fall
1018
+ // back to `publicOrigin` (then the loopback form) so a caller that only set
1019
+ // `publicUrl` keeps the prior single-surface behavior. A bare origin (no
1020
+ // path) is the contract, so the `new URL` base rewrites any stray path and
1021
+ // searchParams encodes the id.
1022
+ const runUrl = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
1023
+ runUrl.searchParams.set(AUTOMATION_RUN_QUERY_PARAM, run.runId);
1024
+ // Resolve requested secrets before opening the run tab so the env is set on
1025
+ // the pending run by the time the WS claims it. The claim happens only after
1026
+ // the browser loads this tab, which is gated on the resolution below, so
1027
+ // `onOpen` always sees the resolved env. The launch stays synchronous up to
1028
+ // here the pending run + "launched" history are already recorded, so the
1029
+ // `isRunInFlight` overlap guard holds across the await. A secret-resolution
1030
+ // error is logged but does not block the tab (the run still starts, just
1031
+ // without the failed secret).
1032
+ void (async () => {
1033
+ try {
1034
+ const secretEnv = await buildAutomationSecretEnv(automation.requestedSecrets, secretStore, secretBackend);
1035
+ automationRunTracker.setEnv(run.runId, secretEnv);
1036
+ }
1037
+ catch (error) {
1038
+ const message = error instanceof Error ? error.message : String(error);
1039
+ console.warn(`failed to resolve secrets for automation "${automation.name}": ${message}`);
1040
+ }
1041
+ try {
1042
+ const handle = await tabController.open(runUrl.href);
1043
+ // Remember the tab so `automation-exit` can close it if closeOnFinish.
1044
+ if (handle)
1045
+ runTabHandles.set(run.runId, handle);
1046
+ }
1047
+ catch (error) {
1048
+ const message = error instanceof Error ? error.message : String(error);
1049
+ console.warn(`failed to open a browser tab for automation "${automation.name}": ${message}`);
1050
+ }
1051
+ })();
1052
+ return run;
1053
+ };
1054
+ // Close a finished run's tab when the automation opted into closeOnFinish.
1055
+ const closeRunTabIfRequested = (automationId, runId) => {
1056
+ const handle = runTabHandles.get(runId);
1057
+ runTabHandles.delete(runId);
1058
+ if (!handle)
1059
+ return;
1060
+ const automation = automationStore.get(automationId);
1061
+ if (!automation?.closeOnFinish)
1062
+ return;
1063
+ void tabController.close(handle).catch((error) => {
1064
+ const message = error instanceof Error ? error.message : String(error);
1065
+ console.warn(`failed to close automation tab (run ${runId}): ${message}`);
1066
+ });
1067
+ };
1068
+ // On boot, settle the state the dead process left behind: any run still
1069
+ // "launched"/"running" can never resume (the run tracker is in-memory), so it
1070
+ // becomes "missed"; and if the daemon was down across scheduled times, record
1071
+ // those as "skipped" so the user can see what didn't run while the machine was
1072
+ // off. Skipped runs never launch and never count toward a limit. No clients
1073
+ // exist yet, so nothing is broadcast.
1074
+ const reconcileOnStartup = (now) => {
1075
+ const lastAliveAt = heartbeatStore.read();
1076
+ const hadOutage = lastAliveAt !== null && now - lastAliveAt >= AUTOMATION_RECONCILE_MIN_DOWNTIME_MS;
1077
+ for (const automation of automationStore.list()) {
1078
+ for (const run of automation.runs) {
1079
+ if (run.status === "launched" || run.status === "running") {
1080
+ automationStore.updateRun(automation.id, run.runId, {
1081
+ status: "missed",
1082
+ finishedAt: now,
1083
+ });
1084
+ }
1085
+ }
1086
+ if (!hadOutage || !automation.enabled || automation.lifecycle === "finished")
1087
+ continue;
1088
+ for (const occurrence of enumerateMissedOccurrences(automation, lastAliveAt, now)) {
1089
+ automationStore.appendRun(automation.id, {
1090
+ runId: randomUUID(),
1091
+ scheduledFor: occurrence,
1092
+ startedAt: null,
1093
+ finishedAt: occurrence,
1094
+ status: "skipped",
1095
+ exitCode: null,
1096
+ trigger: "schedule",
1097
+ countsTowardLimit: false,
1098
+ });
1099
+ }
1093
1100
  }
1094
- webhookTriggerManager.trigger(automation);
1095
- return context.json({ accepted: true }, HTTP_STATUS_ACCEPTED);
1096
- });
1097
- api.notFound((context) => context.json({ error: "not_found" }, HTTP_STATUS_NOT_FOUND));
1101
+ };
1102
+ const ctx = {
1103
+ registry,
1104
+ cdpClient,
1105
+ secretBackend,
1106
+ secretStore,
1107
+ shimsDir,
1108
+ processStore,
1109
+ syncSecretShims,
1110
+ automationStore,
1111
+ broadcastAutomations,
1112
+ syncFolderWatchers,
1113
+ syncSessionEventListeners,
1114
+ webhookTriggerManager,
1115
+ worktreeConfigStore,
1116
+ portsSnapshotProcesses,
1117
+ portsSnapshotListeners,
1118
+ toAutomationWithNextRun,
1119
+ listAutomationsWithNextRun,
1120
+ tryLaunch,
1121
+ };
1122
+ const api = buildApiRoutes(ctx);
1098
1123
  app.route("/api", api);
1099
1124
  app.get("/ws", upgradeWebSocket((context) => {
1100
1125
  let activeWs = null;