@monotykamary/localterm-server 2.76.8 → 2.76.9

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
@@ -22,7 +22,7 @@ import { detectWithExplicitPort } from "./cdp/discover-explicit-endpoint.js";
22
22
  import { DaemonConfigStore } from "./daemon-config-store.js";
23
23
  import { formatCandidates, resolveCandidates, resolveCompletionContext, } from "./completion/index.js";
24
24
  import { z } from "zod";
25
- 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, 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_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_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";
25
+ 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, 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_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, WORKSPACE_FILENAME, WORKSPACE_RESTORE_SETTLE_MS, WORKSPACE_SNAPSHOT_DEBOUNCE_MS, 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_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";
26
26
  import { getDefaultShell, listKnownShells, resolveShellOverride } from "./default-shell.js";
27
27
  import { resolveWindowId } from "./utils/resolve-window-id.js";
28
28
  import { resolveTermBackspaceSequence } from "./utils/resolve-term-backspace-sequence.js";
@@ -40,6 +40,7 @@ import { WebhookTriggerManager } from "./webhook-trigger-manager.js";
40
40
  import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFileContents, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, } from "./git-diff.js";
41
41
  import { HeartbeatStore } from "./heartbeat-store.js";
42
42
  import { HibernateStore } from "./hibernate-store.js";
43
+ import { WorkspaceStore } from "./workspace-store.js";
43
44
  import { createDefaultSecretBackend } from "./secret-backend.js";
44
45
  import { SecretStore } from "./secret-store.js";
45
46
  import { ProcessStore } from "./process-store.js";
@@ -193,7 +194,7 @@ const normalizeMouseAction = (input) => {
193
194
  };
194
195
  const buildApiRoutes = (ctx) => {
195
196
  const api = new Hono();
196
- 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;
197
+ 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, getWorkspaceRestore, applyWorkspaceRestore, connectCdpNow, buildTabUrl, mintViewerCookie, stateDirectory, broadcastThemes, broadcastFonts, updateCheckStore, } = ctx;
197
198
  // Headless SGR-1006 fallback for `mouse` when no CDP tab is reachable:
198
199
  // encode the gesture as SGR bytes and write them straight to the PTY. Closes
199
200
  // over `registry` so the automation layer stays CDP-agnostic. Coords arrive
@@ -1583,6 +1584,7 @@ const buildApiRoutes = (ctx) => {
1583
1584
  api.get("/config", (context) => context.json({
1584
1585
  cdpPort: getCdpPort(),
1585
1586
  graceSeconds: getGraceSeconds(),
1587
+ workspaceRestore: getWorkspaceRestore(),
1586
1588
  defaultShell: getDefaultShell(),
1587
1589
  shells: listKnownShells(),
1588
1590
  }));
@@ -1595,6 +1597,9 @@ const buildApiRoutes = (ctx) => {
1595
1597
  graceSeconds: parsed.data.graceSeconds === undefined
1596
1598
  ? getGraceSeconds()
1597
1599
  : applyGraceSeconds(parsed.data.graceSeconds),
1600
+ workspaceRestore: parsed.data.workspaceRestore === undefined
1601
+ ? getWorkspaceRestore()
1602
+ : applyWorkspaceRestore(parsed.data.workspaceRestore),
1598
1603
  defaultShell: getDefaultShell(),
1599
1604
  shells: listKnownShells(),
1600
1605
  });
@@ -1672,10 +1677,15 @@ export const createServer = async (options = {}) => {
1672
1677
  onOutputActivity: () => caffeinateManager.noteOutputActivity(),
1673
1678
  onSessionActivity: () => {
1674
1679
  caffeinateManager.pokeAuto();
1680
+ scheduleWorkspaceSnapshot();
1675
1681
  refreshHerdrThemeSyncState();
1676
1682
  },
1677
1683
  onSessionEvent: (event, cwd) => {
1678
1684
  sessionEventManager.onSessionEvent(event, cwd);
1685
+ // A cwd change reshapes the workspace manifest (the tab's respawn cwd
1686
+ // moves), so re-snapshot alongside the attach/detach-driven snapshot.
1687
+ if (event === "cwd")
1688
+ scheduleWorkspaceSnapshot();
1679
1689
  },
1680
1690
  onAutomationExit: (automationId, runId, exitCode, log) => {
1681
1691
  automationStore.updateRun(automationId, runId, {
@@ -1760,9 +1770,10 @@ export const createServer = async (options = {}) => {
1760
1770
  const caffeinateController = options.caffeinateController ?? new CaffeinateController();
1761
1771
  const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
1762
1772
  const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
1773
+ const workspaceStore = new WorkspaceStore(path.join(stateDirectory, WORKSPACE_FILENAME));
1763
1774
  const hibernateStore = new HibernateStore(path.join(stateDirectory, HIBERNATE_FILENAME));
1764
1775
  // Immutable shutdown snapshot for this daemon lifetime. Exact old session ids
1765
- // let surviving browser tabs recover only their own text.
1776
+ // let surviving or CDP-restored browser tabs recover only their own text.
1766
1777
  const startupHibernateEntries = hibernateStore.read();
1767
1778
  // Per-process secret injection: a backend (macOS Keychain on darwin) holds
1768
1779
  // secret values; a secret is an identity + the env var it exports
@@ -2291,6 +2302,11 @@ export const createServer = async (options = {}) => {
2291
2302
  registry.rearmGrace();
2292
2303
  return next;
2293
2304
  };
2305
+ // Persist + read the workspace-restore toggle. Read at restore time, so a
2306
+ // `PUT /api/config` change takes effect on the next start (the restore runs
2307
+ // once at startup, not live-reactively).
2308
+ const getWorkspaceRestore = () => daemonConfigStore.getWorkspaceRestore();
2309
+ const applyWorkspaceRestore = (enabled) => daemonConfigStore.setWorkspaceRestore(enabled);
2294
2310
  // Explicit "Connect now" (Settings → Automation browser → Connect): drop any
2295
2311
  // live socket and await a fresh connect so the caller learns the outcome —
2296
2312
  // connected + which browser, or the error that explains a failure (e.g. a
@@ -2315,6 +2331,45 @@ export const createServer = async (options = {}) => {
2315
2331
  };
2316
2332
  }
2317
2333
  };
2334
+ // Workspace restore: reopen the user's last tabs via CDP on start (a
2335
+ // tmux-resurrect/herdr-style restore of the *layout*; the shells themselves
2336
+ // don't survive a stop). The manifest is snapshotted to disk — debounced on
2337
+ // attach/detach churn (scheduleWorkspaceSnapshot, hooked into onSessionActivity)
2338
+ // and flushed on graceful stop — so a restart, graceful or a crash, can reopen
2339
+ // the same tabs. Triggered once per (owner, windowId) after the first desktop
2340
+ // tab pairs with CDP, on a quiet window so surviving tabs (a daemon restart
2341
+ // with the browser left open) reattach and are counted before the deficit is
2342
+ // opened. Excludes automation-run tabs and dormant shells (filtered in
2343
+ // SessionManager.workspaceEntries), so only tabs that were actively open
2344
+ // come back. Opt out via Settings → Sessions (config.json workspaceRestore).
2345
+ let workspaceSnapshotTimer = null;
2346
+ const scheduleWorkspaceSnapshot = () => {
2347
+ if (workspaceSnapshotTimer !== null)
2348
+ clearTimeout(workspaceSnapshotTimer);
2349
+ const timer = setTimeout(() => {
2350
+ workspaceSnapshotTimer = null;
2351
+ workspaceStore.write(registry.workspaceEntries());
2352
+ }, WORKSPACE_SNAPSHOT_DEBOUNCE_MS);
2353
+ timer.unref?.();
2354
+ workspaceSnapshotTimer = timer;
2355
+ };
2356
+ const flushWorkspaceSnapshot = () => {
2357
+ if (workspaceSnapshotTimer !== null) {
2358
+ clearTimeout(workspaceSnapshotTimer);
2359
+ workspaceSnapshotTimer = null;
2360
+ }
2361
+ workspaceStore.write(registry.workspaceEntries());
2362
+ };
2363
+ const buildSpawnTabUrl = (cwd, shell, sessionId) => {
2364
+ const url = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
2365
+ if (cwd)
2366
+ url.searchParams.set("cwd", cwd);
2367
+ if (shell)
2368
+ url.searchParams.set("shell", shell);
2369
+ if (sessionId)
2370
+ url.searchParams.set(SESSION_ID_QUERY_PARAM, sessionId);
2371
+ return url.toString();
2372
+ };
2318
2373
  const hibernatedTabFor = (owner, windowId, sessionId) => {
2319
2374
  if (!windowId || !sessionId)
2320
2375
  return undefined;
@@ -2322,6 +2377,70 @@ export const createServer = async (options = {}) => {
2322
2377
  .find((entry) => entry.owner === owner && entry.windowId === windowId)
2323
2378
  ?.tabs.find((tab) => tab.sessionId === sessionId);
2324
2379
  };
2380
+ const restoredWorkspaceKeys = new Set();
2381
+ const workspaceRestoreTimers = new Map();
2382
+ const scheduleWorkspaceRestore = (owner, windowId) => {
2383
+ if (!windowId)
2384
+ return;
2385
+ const key = `${owner ?? ""}\u0000${windowId}`;
2386
+ if (restoredWorkspaceKeys.has(key))
2387
+ return;
2388
+ const existing = workspaceRestoreTimers.get(key);
2389
+ if (existing)
2390
+ clearTimeout(existing);
2391
+ const timer = setTimeout(() => {
2392
+ workspaceRestoreTimers.delete(key);
2393
+ if (restoredWorkspaceKeys.has(key))
2394
+ return;
2395
+ restoredWorkspaceKeys.add(key);
2396
+ void restoreWorkspace(owner, windowId);
2397
+ }, WORKSPACE_RESTORE_SETTLE_MS);
2398
+ timer.unref?.();
2399
+ workspaceRestoreTimers.set(key, timer);
2400
+ };
2401
+ const restoreWorkspace = async (owner, windowId) => {
2402
+ if (!daemonConfigStore.getWorkspaceRestore())
2403
+ return;
2404
+ if (!cdpClient || !cdpClient.isConnected())
2405
+ return;
2406
+ const hibernatedEntry = startupHibernateEntries.find((candidate) => candidate.owner === owner && candidate.windowId === windowId);
2407
+ const workspaceEntry = workspaceStore
2408
+ .read()
2409
+ .find((candidate) => candidate.owner === owner && candidate.windowId === windowId);
2410
+ const manifest = hibernatedEntry?.tabs ?? workspaceEntry?.tabs ?? [];
2411
+ if (manifest.length === 0)
2412
+ return;
2413
+ const urlFor = (index) => {
2414
+ const tab = manifest[index];
2415
+ const sessionId = "sessionId" in tab && typeof tab.sessionId === "string" ? tab.sessionId : undefined;
2416
+ return buildSpawnTabUrl(tab.cwd, tab.shell, sessionId);
2417
+ };
2418
+ const openCount = registry.attachedClientCount(owner, windowId);
2419
+ if (openCount >= manifest.length)
2420
+ return;
2421
+ if (openCount === 0) {
2422
+ for (let index = 0; index < manifest.length; index += 1) {
2423
+ await cdpClient.openBackgroundTab(urlFor(index));
2424
+ }
2425
+ return;
2426
+ }
2427
+ if (openCount === 1) {
2428
+ // Browser was fully closed; repoint the lone bootstrap to the first tab.
2429
+ const firstTargetId = [...wsToTargetId.entries()].find(([ws]) => {
2430
+ const profile = registry.clientProfile(ws);
2431
+ return profile !== null && profile.owner === owner && profile.windowId === windowId;
2432
+ })?.[1];
2433
+ if (firstTargetId)
2434
+ await cdpClient.navigateTab(firstTargetId, urlFor(0));
2435
+ for (let index = 1; index < manifest.length; index += 1) {
2436
+ await cdpClient.openBackgroundTab(urlFor(index));
2437
+ }
2438
+ return;
2439
+ }
2440
+ for (let index = openCount; index < manifest.length; index += 1) {
2441
+ await cdpClient.openBackgroundTab(urlFor(index));
2442
+ }
2443
+ };
2325
2444
  const updateCheckStore = new UpdateCheckStore({
2326
2445
  filePath: path.join(stateDirectory, UPDATE_CHECK_FILENAME),
2327
2446
  currentVersion: options.currentVersion ?? readServerVersion(),
@@ -2357,6 +2476,8 @@ export const createServer = async (options = {}) => {
2357
2476
  applyCdpPort,
2358
2477
  getGraceSeconds,
2359
2478
  applyGraceSeconds,
2479
+ getWorkspaceRestore,
2480
+ applyWorkspaceRestore,
2360
2481
  broadcastThemes,
2361
2482
  broadcastFonts,
2362
2483
  connectCdpNow,
@@ -2658,6 +2779,13 @@ export const createServer = async (options = {}) => {
2658
2779
  const targetId = cdpClient?.findTargetIdForToken(token);
2659
2780
  if (targetId) {
2660
2781
  wsToTargetId.set(ws, targetId);
2782
+ // A desktop tab just paired with CDP — arm a one-shot restore
2783
+ // for its (owner, windowId) so missing workspace tabs reopen
2784
+ // once the reconnection burst settles. Phone PWA tabs never pair
2785
+ // (no debug port), so restore stays scoped to the desktop.
2786
+ const profile = registry.clientProfile(ws);
2787
+ if (profile)
2788
+ scheduleWorkspaceRestore(profile.owner, profile.windowId);
2661
2789
  }
2662
2790
  }
2663
2791
  safeSend(ws, {
@@ -2797,6 +2925,7 @@ export const createServer = async (options = {}) => {
2797
2925
  if (stopPromise)
2798
2926
  return stopPromise;
2799
2927
  stopPromise = (async () => {
2928
+ flushWorkspaceSnapshot();
2800
2929
  hibernateStore.write(await registry.hibernateEntries());
2801
2930
  automationScheduler.dispose();
2802
2931
  folderWatchManager.dispose();