@monotykamary/localterm-server 2.62.4 → 2.63.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.
package/dist/index.js CHANGED
@@ -21,7 +21,7 @@ import { detectWithExplicitPort } from "./cdp/discover-explicit-endpoint.js";
21
21
  import { DaemonConfigStore } from "./daemon-config-store.js";
22
22
  import { formatCandidates, resolveCandidates, resolveCompletionContext, } from "./completion/index.js";
23
23
  import { z } from "zod";
24
- 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, 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_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_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, UPDATE_CHECK_FILENAME, } from "./constants.js";
24
+ 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, 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_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_READY_STATE_OPEN, AUTH_SECRET_FILENAME, AUTH_COOKIE_NAME, UPDATE_CHECK_FILENAME, } from "./constants.js";
25
25
  import { getDefaultShell, listKnownShells, resolveShellOverride } from "./default-shell.js";
26
26
  import { resolveWindowId } from "./utils/resolve-window-id.js";
27
27
  import { shellPathForUserShell } from "./utils/shell-path.js";
@@ -35,6 +35,7 @@ import { SessionEventManager } from "./session-event-manager.js";
35
35
  import { WebhookTriggerManager } from "./webhook-trigger-manager.js";
36
36
  import { getGitBranchInfo, getGitBranchPr, getGitDiff, getGitDiffFilePatch, getGitDiffFiles, getGitDiffSummary, } from "./git-diff.js";
37
37
  import { HeartbeatStore } from "./heartbeat-store.js";
38
+ import { WorkspaceStore } from "./workspace-store.js";
38
39
  import { createDefaultSecretBackend } from "./secret-backend.js";
39
40
  import { SecretStore } from "./secret-store.js";
40
41
  import { ProcessStore } from "./process-store.js";
@@ -187,7 +188,7 @@ const normalizeMouseAction = (input) => {
187
188
  };
188
189
  const buildApiRoutes = (ctx) => {
189
190
  const api = new Hono();
190
- 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;
191
+ 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;
191
192
  // Headless SGR-1006 fallback for `mouse` when no CDP tab is reachable:
192
193
  // encode the gesture as SGR bytes and write them straight to the PTY. Closes
193
194
  // over `registry` so the automation layer stays CDP-agnostic. Coords arrive
@@ -1550,6 +1551,7 @@ const buildApiRoutes = (ctx) => {
1550
1551
  api.get("/config", (context) => context.json({
1551
1552
  cdpPort: getCdpPort(),
1552
1553
  graceSeconds: getGraceSeconds(),
1554
+ workspaceRestore: getWorkspaceRestore(),
1553
1555
  defaultShell: getDefaultShell(),
1554
1556
  shells: listKnownShells(),
1555
1557
  }));
@@ -1562,6 +1564,9 @@ const buildApiRoutes = (ctx) => {
1562
1564
  graceSeconds: parsed.data.graceSeconds === undefined
1563
1565
  ? getGraceSeconds()
1564
1566
  : applyGraceSeconds(parsed.data.graceSeconds),
1567
+ workspaceRestore: parsed.data.workspaceRestore === undefined
1568
+ ? getWorkspaceRestore()
1569
+ : applyWorkspaceRestore(parsed.data.workspaceRestore),
1565
1570
  defaultShell: getDefaultShell(),
1566
1571
  shells: listKnownShells(),
1567
1572
  });
@@ -1632,8 +1637,17 @@ export const createServer = async (options = {}) => {
1632
1637
  sendControl: safeSend,
1633
1638
  hooks: {
1634
1639
  onOutputActivity: () => caffeinateManager.noteOutputActivity(),
1635
- onSessionActivity: () => caffeinateManager.pokeAuto(),
1636
- onSessionEvent: (event, cwd) => sessionEventManager.onSessionEvent(event, cwd),
1640
+ onSessionActivity: () => {
1641
+ caffeinateManager.pokeAuto();
1642
+ scheduleWorkspaceSnapshot();
1643
+ },
1644
+ onSessionEvent: (event, cwd) => {
1645
+ sessionEventManager.onSessionEvent(event, cwd);
1646
+ // A cwd change reshapes the workspace manifest (the tab's respawn cwd
1647
+ // moves), so re-snapshot alongside the attach/detach-driven snapshot.
1648
+ if (event === "cwd")
1649
+ scheduleWorkspaceSnapshot();
1650
+ },
1637
1651
  onAutomationExit: (automationId, runId, exitCode, log) => {
1638
1652
  automationStore.updateRun(automationId, runId, {
1639
1653
  status: exitCode === 0 ? "completed" : "failed",
@@ -1715,6 +1729,7 @@ export const createServer = async (options = {}) => {
1715
1729
  const caffeinateController = options.caffeinateController ?? new CaffeinateController();
1716
1730
  const caffeinatePreferencesStore = new CaffeinatePreferencesStore(path.join(stateDirectory, "caffeinate.json"));
1717
1731
  const worktreeConfigStore = new WorktreeConfigStore(stateDirectory);
1732
+ const workspaceStore = new WorkspaceStore(path.join(stateDirectory, WORKSPACE_FILENAME));
1718
1733
  // Per-process secret injection: a backend (macOS Keychain on darwin) holds
1719
1734
  // secret values; a secret is an identity + the env var it exports
1720
1735
  // (~/.localterm/secrets.json, names + env var only — never values), and a
@@ -2217,6 +2232,11 @@ export const createServer = async (options = {}) => {
2217
2232
  registry.rearmGrace();
2218
2233
  return next;
2219
2234
  };
2235
+ // Persist + read the workspace-restore toggle. Read at restore time, so a
2236
+ // `PUT /api/config` change takes effect on the next start (the restore runs
2237
+ // once at startup, not live-reactively).
2238
+ const getWorkspaceRestore = () => daemonConfigStore.getWorkspaceRestore();
2239
+ const applyWorkspaceRestore = (enabled) => daemonConfigStore.setWorkspaceRestore(enabled);
2220
2240
  // Explicit "Connect now" (Settings → Automation browser → Connect): drop any
2221
2241
  // live socket and await a fresh connect so the caller learns the outcome —
2222
2242
  // connected + which browser, or the error that explains a failure (e.g. a
@@ -2241,6 +2261,110 @@ export const createServer = async (options = {}) => {
2241
2261
  };
2242
2262
  }
2243
2263
  };
2264
+ // Workspace restore: reopen the user's last tabs via CDP on start (a
2265
+ // tmux-resurrect/herdr-style restore of the *layout*; the shells themselves
2266
+ // don't survive a stop). The manifest is snapshotted to disk — debounced on
2267
+ // attach/detach churn (scheduleWorkspaceSnapshot, hooked into onSessionActivity)
2268
+ // and flushed on graceful stop — so a restart, graceful or a crash, can reopen
2269
+ // the same tabs. Triggered once per (owner, windowId) after the first desktop
2270
+ // tab pairs with CDP, on a quiet window so surviving tabs (a daemon restart
2271
+ // with the browser left open) reattach and are counted before the deficit is
2272
+ // opened. Excludes automation-run tabs and dormant shells (filtered in
2273
+ // SessionManager.workspaceEntries), so only tabs that were actively open
2274
+ // come back. Opt out via Settings → Sessions (config.json workspaceRestore).
2275
+ let workspaceSnapshotTimer = null;
2276
+ const scheduleWorkspaceSnapshot = () => {
2277
+ if (workspaceSnapshotTimer !== null)
2278
+ clearTimeout(workspaceSnapshotTimer);
2279
+ const timer = setTimeout(() => {
2280
+ workspaceSnapshotTimer = null;
2281
+ workspaceStore.write(registry.workspaceEntries());
2282
+ }, WORKSPACE_SNAPSHOT_DEBOUNCE_MS);
2283
+ timer.unref?.();
2284
+ workspaceSnapshotTimer = timer;
2285
+ };
2286
+ const flushWorkspaceSnapshot = () => {
2287
+ if (workspaceSnapshotTimer !== null) {
2288
+ clearTimeout(workspaceSnapshotTimer);
2289
+ workspaceSnapshotTimer = null;
2290
+ }
2291
+ workspaceStore.write(registry.workspaceEntries());
2292
+ };
2293
+ const buildSpawnTabUrl = (cwd, shell) => {
2294
+ const url = new URL(localOrigin ?? publicOrigin ?? `http://${FRIENDLY_HOSTNAME}:${actualPort}`);
2295
+ if (cwd)
2296
+ url.searchParams.set("cwd", cwd);
2297
+ if (shell)
2298
+ url.searchParams.set("shell", shell);
2299
+ return url.toString();
2300
+ };
2301
+ const restoredWorkspaceKeys = new Set();
2302
+ const workspaceRestoreTimers = new Map();
2303
+ const scheduleWorkspaceRestore = (owner, windowId) => {
2304
+ if (!windowId)
2305
+ return;
2306
+ const key = `${owner ?? ""}\u0000${windowId}`;
2307
+ if (restoredWorkspaceKeys.has(key))
2308
+ return;
2309
+ const existing = workspaceRestoreTimers.get(key);
2310
+ if (existing)
2311
+ clearTimeout(existing);
2312
+ const timer = setTimeout(() => {
2313
+ workspaceRestoreTimers.delete(key);
2314
+ if (restoredWorkspaceKeys.has(key))
2315
+ return;
2316
+ restoredWorkspaceKeys.add(key);
2317
+ void restoreWorkspace(owner, windowId);
2318
+ }, WORKSPACE_RESTORE_SETTLE_MS);
2319
+ timer.unref?.();
2320
+ workspaceRestoreTimers.set(key, timer);
2321
+ };
2322
+ const restoreWorkspace = async (owner, windowId) => {
2323
+ if (!daemonConfigStore.getWorkspaceRestore())
2324
+ return;
2325
+ if (!cdpClient || !cdpClient.isConnected())
2326
+ return;
2327
+ const entry = workspaceStore
2328
+ .read()
2329
+ .find((candidate) => candidate.owner === owner && candidate.windowId === windowId);
2330
+ if (!entry || entry.tabs.length === 0)
2331
+ return;
2332
+ const manifest = entry.tabs;
2333
+ const openCount = registry.attachedClientCount(owner, windowId);
2334
+ if (openCount >= manifest.length)
2335
+ return;
2336
+ if (openCount === 0) {
2337
+ for (const tab of manifest) {
2338
+ await cdpClient.openBackgroundTab(buildSpawnTabUrl(tab.cwd, tab.shell));
2339
+ }
2340
+ return;
2341
+ }
2342
+ if (openCount === 1) {
2343
+ // Browser was fully closed; the lone bootstrap tab is repointed to the
2344
+ // first restored shell (navigate-the-bootstrap) so the reopen lands
2345
+ // exactly N tabs in the manifest's cwds instead of N−1 + one stray in
2346
+ // the default directory.
2347
+ const firstTargetId = [...wsToTargetId.entries()].find(([ws]) => {
2348
+ const profile = registry.clientProfile(ws);
2349
+ return profile !== null && profile.owner === owner && profile.windowId === windowId;
2350
+ })?.[1];
2351
+ if (firstTargetId) {
2352
+ await cdpClient.navigateTab(firstTargetId, buildSpawnTabUrl(manifest[0].cwd, manifest[0].shell));
2353
+ }
2354
+ for (let index = 1; index < manifest.length; index++) {
2355
+ await cdpClient.openBackgroundTab(buildSpawnTabUrl(manifest[index].cwd, manifest[index].shell));
2356
+ }
2357
+ return;
2358
+ }
2359
+ // Partial survival (a daemon restart with some tabs left open): surviving
2360
+ // tabs self-healed to their live cwds, so only the deficit is opened. Which
2361
+ // manifest cwds the survivors cover can't be matched (their URLs carry the
2362
+ // spawn cwd, stale after a `cd`), so the tail is opened as a best-effort
2363
+ // placement — the count is exact, the cwd placement is approximate.
2364
+ for (let index = openCount; index < manifest.length; index++) {
2365
+ await cdpClient.openBackgroundTab(buildSpawnTabUrl(manifest[index].cwd, manifest[index].shell));
2366
+ }
2367
+ };
2244
2368
  const updateCheckStore = new UpdateCheckStore({
2245
2369
  filePath: path.join(stateDirectory, UPDATE_CHECK_FILENAME),
2246
2370
  currentVersion: options.currentVersion ?? readServerVersion(),
@@ -2276,6 +2400,8 @@ export const createServer = async (options = {}) => {
2276
2400
  applyCdpPort,
2277
2401
  getGraceSeconds,
2278
2402
  applyGraceSeconds,
2403
+ getWorkspaceRestore,
2404
+ applyWorkspaceRestore,
2279
2405
  broadcastThemes,
2280
2406
  broadcastFonts,
2281
2407
  connectCdpNow,
@@ -2557,8 +2683,16 @@ export const createServer = async (options = {}) => {
2557
2683
  const token = parsed.data.token;
2558
2684
  if (token !== null) {
2559
2685
  const targetId = cdpClient?.findTargetIdForToken(token);
2560
- if (targetId)
2686
+ if (targetId) {
2561
2687
  wsToTargetId.set(ws, targetId);
2688
+ // A desktop tab just paired with CDP — arm a one-shot restore
2689
+ // for its (owner, windowId) so missing workspace tabs reopen
2690
+ // once the reconnection burst settles. Phone PWA tabs never pair
2691
+ // (no debug port), so restore stays scoped to the desktop.
2692
+ const profile = registry.clientProfile(ws);
2693
+ if (profile)
2694
+ scheduleWorkspaceRestore(profile.owner, profile.windowId);
2695
+ }
2562
2696
  }
2563
2697
  safeSend(ws, {
2564
2698
  type: "cdp-controlled",
@@ -2693,6 +2827,7 @@ export const createServer = async (options = {}) => {
2693
2827
  });
2694
2828
  }
2695
2829
  const stop = async () => {
2830
+ flushWorkspaceSnapshot();
2696
2831
  automationScheduler.dispose();
2697
2832
  folderWatchManager.dispose();
2698
2833
  automationGitWatcher.dispose();