@roamcode.ai/server 1.0.0 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. package/dist/index.d.ts +2406 -82
  2. package/dist/index.js +8314 -44
  3. package/dist/managed-update-helper.js +621 -26
  4. package/dist/mcp-send.js +76 -90
  5. package/dist/start.d.ts +591 -9
  6. package/dist/start.js +7629 -353
  7. package/package.json +3 -3
  8. package/dist/auth.d.ts +0 -63
  9. package/dist/auth.js +0 -133
  10. package/dist/claude-auth-service.d.ts +0 -76
  11. package/dist/claude-auth-service.js +0 -217
  12. package/dist/claude-latest-service.d.ts +0 -34
  13. package/dist/claude-latest-service.js +0 -61
  14. package/dist/config.d.ts +0 -78
  15. package/dist/config.js +0 -59
  16. package/dist/data-dir.d.ts +0 -42
  17. package/dist/data-dir.js +0 -70
  18. package/dist/diag.d.ts +0 -43
  19. package/dist/diag.js +0 -83
  20. package/dist/fs-service.d.ts +0 -90
  21. package/dist/fs-service.js +0 -290
  22. package/dist/managed-runtime.d.ts +0 -51
  23. package/dist/managed-runtime.js +0 -411
  24. package/dist/managed-update-helper.d.ts +0 -2
  25. package/dist/mcp-send.d.ts +0 -33
  26. package/dist/origin-check.d.ts +0 -37
  27. package/dist/origin-check.js +0 -101
  28. package/dist/pane-status.d.ts +0 -61
  29. package/dist/pane-status.js +0 -145
  30. package/dist/providers/claude-metadata-service.d.ts +0 -58
  31. package/dist/providers/claude-metadata-service.js +0 -352
  32. package/dist/providers/claude-provider.d.ts +0 -11
  33. package/dist/providers/claude-provider.js +0 -166
  34. package/dist/providers/codex-activity.d.ts +0 -21
  35. package/dist/providers/codex-activity.js +0 -122
  36. package/dist/providers/codex-app-server-client.d.ts +0 -90
  37. package/dist/providers/codex-app-server-client.js +0 -485
  38. package/dist/providers/codex-latest-service.d.ts +0 -50
  39. package/dist/providers/codex-latest-service.js +0 -174
  40. package/dist/providers/codex-metadata-service.d.ts +0 -161
  41. package/dist/providers/codex-metadata-service.js +0 -686
  42. package/dist/providers/codex-profile-client.d.ts +0 -16
  43. package/dist/providers/codex-profile-client.js +0 -52
  44. package/dist/providers/codex-profile-security.d.ts +0 -23
  45. package/dist/providers/codex-profile-security.js +0 -161
  46. package/dist/providers/codex-provider.d.ts +0 -15
  47. package/dist/providers/codex-provider.js +0 -174
  48. package/dist/providers/codex-thread-coordinator.d.ts +0 -18
  49. package/dist/providers/codex-thread-coordinator.js +0 -93
  50. package/dist/providers/codex-thread-persistence.d.ts +0 -9
  51. package/dist/providers/codex-thread-persistence.js +0 -45
  52. package/dist/providers/codex-thread-resolver.d.ts +0 -59
  53. package/dist/providers/codex-thread-resolver.js +0 -322
  54. package/dist/providers/options.d.ts +0 -7
  55. package/dist/providers/options.js +0 -155
  56. package/dist/providers/provider-artifacts.d.ts +0 -3
  57. package/dist/providers/provider-artifacts.js +0 -30
  58. package/dist/providers/registry.d.ts +0 -7
  59. package/dist/providers/registry.js +0 -23
  60. package/dist/providers/types.d.ts +0 -95
  61. package/dist/providers/types.js +0 -8
  62. package/dist/push-dispatch.d.ts +0 -81
  63. package/dist/push-dispatch.js +0 -100
  64. package/dist/push-store.d.ts +0 -25
  65. package/dist/push-store.js +0 -79
  66. package/dist/rate-limit.d.ts +0 -52
  67. package/dist/rate-limit.js +0 -72
  68. package/dist/server-config.d.ts +0 -60
  69. package/dist/server-config.js +0 -77
  70. package/dist/service-install.d.ts +0 -60
  71. package/dist/service-install.js +0 -221
  72. package/dist/session-defaults.d.ts +0 -26
  73. package/dist/session-defaults.js +0 -60
  74. package/dist/session-store.d.ts +0 -81
  75. package/dist/session-store.js +0 -654
  76. package/dist/static-routes.d.ts +0 -101
  77. package/dist/static-routes.js +0 -188
  78. package/dist/terminal-capability.d.ts +0 -5
  79. package/dist/terminal-capability.js +0 -27
  80. package/dist/terminal-manager.d.ts +0 -224
  81. package/dist/terminal-manager.js +0 -917
  82. package/dist/terminal-process.d.ts +0 -85
  83. package/dist/terminal-process.js +0 -238
  84. package/dist/terminal-shared.d.ts +0 -36
  85. package/dist/terminal-shared.js +0 -43
  86. package/dist/tmux-list.d.ts +0 -11
  87. package/dist/tmux-list.js +0 -39
  88. package/dist/transport.d.ts +0 -123
  89. package/dist/transport.js +0 -1559
  90. package/dist/updater.d.ts +0 -161
  91. package/dist/updater.js +0 -451
  92. package/dist/usage-service.d.ts +0 -118
  93. package/dist/usage-service.js +0 -173
  94. package/dist/vapid.d.ts +0 -17
  95. package/dist/vapid.js +0 -31
  96. package/dist/web-push-send.d.ts +0 -20
  97. package/dist/web-push-send.js +0 -21
  98. package/dist/ws-ticket.d.ts +0 -47
  99. package/dist/ws-ticket.js +0 -62
@@ -1,101 +0,0 @@
1
- import type { FastifyInstance } from "fastify";
2
- /**
3
- * Server-side mirror of the web SW's `apiNavigationDenylist` (packages/web/src/pwa/sw-exclusions.ts),
4
- * EXTENDED with /health, /push and the /ws suffix. A request whose path matches one of these is a
5
- * live API/WS/health/push route — the SPA navigation fallback must NOT serve index.html for it (it
6
- * must hit the real handler), and the auth gate must NOT treat it as a public static asset.
7
- *
8
- * SYNC INVARIANT: this denylist (and `isPublicForRequest` below) decides which requests skip the
9
- * token gate, while Fastify's find-my-way router decides which handler a request reaches. The two
10
- * MUST agree on path normalization, or a request can look public to the gate yet reach a protected
11
- * handler (an auth bypass). We gate on the DECODED path (`pathForGate`) to match the router's
12
- * percent-decoding, and we reject encoded separators (`hasEncodedSep`). If Fastify is ever
13
- * configured with `caseSensitive:false` or `ignoreDuplicateSlashes:true`, this gate must apply the
14
- * SAME case/slash normalization first, or those options will silently reopen the bypass.
15
- */
16
- export declare const API_PATH_DENYLIST: RegExp[];
17
- /**
18
- * The EXPLICIT public-shell allowlist — the only paths the auth gate lets a REGISTERED route serve
19
- * without a token. It must cover exactly what an unauthenticated PWA boot needs (the login screen has to
20
- * render before a token exists): the root shell, the Vite asset dir, and the top-level bundle files
21
- * (index.html, sw.js, manifest.webmanifest, icons — matched by extension so a new icon the web build adds
22
- * doesn't need a server change). Everything else — including any FUTURE route nobody remembered to gate —
23
- * is token-gated by default (see the transport preHandler).
24
- *
25
- * SAFE because only static handlers live at these shapes: every API route is extensionless and no API
26
- * route sits at `/` or under `/assets/`. Keep it that way — an API route named like a top-level static
27
- * file (e.g. `/report.json`) would be silently public.
28
- */
29
- export declare const SHELL_PATH_ALLOWLIST: RegExp[];
30
- /** True for the static PWA shell paths above (the auth gate's explicit allowlist). */
31
- export declare function isShellPath(path: string): boolean;
32
- /**
33
- * Normalize a raw request URL to the path the FASTIFY ROUTER will route, for the auth gate.
34
- * find-my-way routes the percent-DECODED path, so we must gate on the decoded path too — otherwise
35
- * `GET /%73essions` (`%73`=`s`) looks public to a raw-path gate but routes to `/sessions`.
36
- * A malformed escape (decodeURIComponent throws) falls back to the raw path (still gated by the
37
- * caller's encoded-separator check), never crashing the gate.
38
- */
39
- export declare function pathForGate(rawUrl: string): string;
40
- /**
41
- * True if the RAW (pre-decode) path carries an encoded slash or backslash (`%2f`/`%2F`/`%5c`/`%5C`).
42
- * find-my-way's handling of encoded slashes can desync from a single `decodeURIComponent`, and any
43
- * request that needs an encoded separator to look public is inherently suspicious — so we treat such
44
- * requests as NON-public (gated) regardless of what they decode to.
45
- */
46
- export declare function hasEncodedSep(rawUrl: string): boolean;
47
- /**
48
- * The auth-boundary decision for a real request: a request is public (skips the token gate / may get
49
- * the SPA shell) IFF it has no encoded separator AND its decoded path is a public static/shell path.
50
- * Both the preHandler bypass (transport.ts) and the SPA `setNotFoundHandler` MUST use this, so the
51
- * gate and the router agree on what is reachable.
52
- */
53
- export declare function isPublicForRequest(rawUrl: string): boolean;
54
- /**
55
- * True for the PUBLIC static shell (HTML/JS/CSS/icons/manifest/sw + SPA navigations) — served WITHOUT
56
- * a token so the login screen can load and THEN authenticate. A path is public iff it is on the shell
57
- * allowlist, OR it is an extensionless NAVIGATION path (an SPA client route like `/login` gets
58
- * index.html on a hard refresh) outside the reserved API namespace. (The served bundle carries no
59
- * secret: the token lives only in the browser localStorage.)
60
- *
61
- * NOTE this predicate alone is NOT the auth boundary for routes: the transport preHandler is
62
- * DEFAULT-DENY — it only honors the navigation half of this when the request matched NO registered
63
- * route (fastify's `is404`), so a real handler can never hide behind an extensionless path. The
64
- * navigation half here also drives the SPA fallback in {@link registerStatic}'s notFound handler.
65
- *
66
- * INVARIANT: a built shell asset must NEVER live at a path starting `/sessions`, `/fs`, `/ws`,
67
- * `/health`, or `/push`. This holds for the Vite build (assets are emitted under `/assets/`, plus
68
- * root `/index.html`, `/icon-*.svg`, `/manifest.webmanifest`, `/sw.js`) — none collide with the
69
- * denylist. If a future asset were ever emitted under one of those prefixes, this default-deny would
70
- * wrongly 401 a real asset to an unauthenticated shell load (the shell would fail to boot). Keep the
71
- * Vite output prefixes clear of the denylist, or special-case the asset path here.
72
- */
73
- export declare function isPublicPath(path: string): boolean;
74
- /**
75
- * True if the path looks like a request for a static FILE — it lives under `/assets/` or its last
76
- * segment has an extension (e.g. `.js`/`.css`/`.png`/`.svg`). A file request that reaches the
77
- * notFound handler means the file does NOT exist on disk, so it must 404 — it must NOT fall back to
78
- * index.html. Serving the HTML shell for a missing `.js` makes the browser block the module script
79
- * (MIME mismatch → blank page) and can POISON a service-worker precache (HTML cached as the JS
80
- * entry). Only extensionless navigation paths (client routes like `/`, `/login`) get the SPA shell.
81
- *
82
- * Operational note: `@fastify/static` with `wildcard:false` globs the dist and registers one route
83
- * per file AT STARTUP, so after rebuilding the web bundle (new content-hashed filenames) the server
84
- * must be RESTARTED for the new assets to get routes — otherwise they reach this handler and (now)
85
- * correctly 404 instead of silently serving the shell.
86
- */
87
- export declare function looksLikeAssetRequest(path: string): boolean;
88
- export interface RegisterStaticOptions {
89
- /** Absolute path to the built PWA (packages/web/dist). */
90
- webDir: string;
91
- }
92
- /**
93
- * Serve the built PWA at `/` with an SPA fallback: a GET NAVIGATION (extensionless, public, non-API)
94
- * that no static file matched returns index.html, so client routes (e.g. /login) work on a hard
95
- * refresh. A request that looks like a missing FILE (`/assets/*`, or any `*.ext`) is served LIVE from
96
- * disk when the file exists under webDir but @fastify/static's startup glob missed it (a build that
97
- * landed after this server started — an OTA window / manual rebuild); otherwise it returns 404, never
98
- * the shell — so a missing asset fails loudly instead of poisoning a browser/SW cache with HTML. An
99
- * unknown /sessions/... still 404/401s from the real handlers / the gate.
100
- */
101
- export declare function registerStatic(app: FastifyInstance, opts: RegisterStaticOptions): void;
@@ -1,188 +0,0 @@
1
- import fastifyStatic from "@fastify/static";
2
- import { existsSync } from "node:fs";
3
- import { resolve, sep } from "node:path";
4
- /**
5
- * Server-side mirror of the web SW's `apiNavigationDenylist` (packages/web/src/pwa/sw-exclusions.ts),
6
- * EXTENDED with /health, /push and the /ws suffix. A request whose path matches one of these is a
7
- * live API/WS/health/push route — the SPA navigation fallback must NOT serve index.html for it (it
8
- * must hit the real handler), and the auth gate must NOT treat it as a public static asset.
9
- *
10
- * SYNC INVARIANT: this denylist (and `isPublicForRequest` below) decides which requests skip the
11
- * token gate, while Fastify's find-my-way router decides which handler a request reaches. The two
12
- * MUST agree on path normalization, or a request can look public to the gate yet reach a protected
13
- * handler (an auth bypass). We gate on the DECODED path (`pathForGate`) to match the router's
14
- * percent-decoding, and we reject encoded separators (`hasEncodedSep`). If Fastify is ever
15
- * configured with `caseSensitive:false` or `ignoreDuplicateSlashes:true`, this gate must apply the
16
- * SAME case/slash normalization first, or those options will silently reopen the bypass.
17
- */
18
- export const API_PATH_DENYLIST = [
19
- /^\/sessions/,
20
- /^\/resumable/,
21
- /^\/fs/,
22
- /^\/images/,
23
- /^\/health/,
24
- /^\/push/,
25
- /^\/version/,
26
- /^\/update/,
27
- /^\/usage/,
28
- /^\/diag/,
29
- /^\/token\//,
30
- /^\/auth\//,
31
- /^\/claude\//,
32
- /^\/ws-ticket/,
33
- /\/ws$/,
34
- ];
35
- /**
36
- * The EXPLICIT public-shell allowlist — the only paths the auth gate lets a REGISTERED route serve
37
- * without a token. It must cover exactly what an unauthenticated PWA boot needs (the login screen has to
38
- * render before a token exists): the root shell, the Vite asset dir, and the top-level bundle files
39
- * (index.html, sw.js, manifest.webmanifest, icons — matched by extension so a new icon the web build adds
40
- * doesn't need a server change). Everything else — including any FUTURE route nobody remembered to gate —
41
- * is token-gated by default (see the transport preHandler).
42
- *
43
- * SAFE because only static handlers live at these shapes: every API route is extensionless and no API
44
- * route sits at `/` or under `/assets/`. Keep it that way — an API route named like a top-level static
45
- * file (e.g. `/report.json`) would be silently public.
46
- */
47
- export const SHELL_PATH_ALLOWLIST = [
48
- /^\/$/,
49
- /^\/assets\//,
50
- /^\/[^/]+\.(?:html|js|css|map|json|webmanifest|txt|ico|png|svg|jpg|jpeg|gif|webp|woff2?)$/,
51
- ];
52
- /** True for the static PWA shell paths above (the auth gate's explicit allowlist). */
53
- export function isShellPath(path) {
54
- return SHELL_PATH_ALLOWLIST.some((re) => re.test(path));
55
- }
56
- /**
57
- * Normalize a raw request URL to the path the FASTIFY ROUTER will route, for the auth gate.
58
- * find-my-way routes the percent-DECODED path, so we must gate on the decoded path too — otherwise
59
- * `GET /%73essions` (`%73`=`s`) looks public to a raw-path gate but routes to `/sessions`.
60
- * A malformed escape (decodeURIComponent throws) falls back to the raw path (still gated by the
61
- * caller's encoded-separator check), never crashing the gate.
62
- */
63
- export function pathForGate(rawUrl) {
64
- const raw = rawUrl.split("?")[0] ?? "/";
65
- try {
66
- return decodeURIComponent(raw);
67
- }
68
- catch {
69
- return raw;
70
- }
71
- }
72
- /**
73
- * True if the RAW (pre-decode) path carries an encoded slash or backslash (`%2f`/`%2F`/`%5c`/`%5C`).
74
- * find-my-way's handling of encoded slashes can desync from a single `decodeURIComponent`, and any
75
- * request that needs an encoded separator to look public is inherently suspicious — so we treat such
76
- * requests as NON-public (gated) regardless of what they decode to.
77
- */
78
- export function hasEncodedSep(rawUrl) {
79
- const raw = rawUrl.split("?")[0] ?? "/";
80
- return /%2f|%5c/i.test(raw);
81
- }
82
- /**
83
- * The auth-boundary decision for a real request: a request is public (skips the token gate / may get
84
- * the SPA shell) IFF it has no encoded separator AND its decoded path is a public static/shell path.
85
- * Both the preHandler bypass (transport.ts) and the SPA `setNotFoundHandler` MUST use this, so the
86
- * gate and the router agree on what is reachable.
87
- */
88
- export function isPublicForRequest(rawUrl) {
89
- return !hasEncodedSep(rawUrl) && isPublicPath(pathForGate(rawUrl));
90
- }
91
- /**
92
- * True for the PUBLIC static shell (HTML/JS/CSS/icons/manifest/sw + SPA navigations) — served WITHOUT
93
- * a token so the login screen can load and THEN authenticate. A path is public iff it is on the shell
94
- * allowlist, OR it is an extensionless NAVIGATION path (an SPA client route like `/login` gets
95
- * index.html on a hard refresh) outside the reserved API namespace. (The served bundle carries no
96
- * secret: the token lives only in the browser localStorage.)
97
- *
98
- * NOTE this predicate alone is NOT the auth boundary for routes: the transport preHandler is
99
- * DEFAULT-DENY — it only honors the navigation half of this when the request matched NO registered
100
- * route (fastify's `is404`), so a real handler can never hide behind an extensionless path. The
101
- * navigation half here also drives the SPA fallback in {@link registerStatic}'s notFound handler.
102
- *
103
- * INVARIANT: a built shell asset must NEVER live at a path starting `/sessions`, `/fs`, `/ws`,
104
- * `/health`, or `/push`. This holds for the Vite build (assets are emitted under `/assets/`, plus
105
- * root `/index.html`, `/icon-*.svg`, `/manifest.webmanifest`, `/sw.js`) — none collide with the
106
- * denylist. If a future asset were ever emitted under one of those prefixes, this default-deny would
107
- * wrongly 401 a real asset to an unauthenticated shell load (the shell would fail to boot). Keep the
108
- * Vite output prefixes clear of the denylist, or special-case the asset path here.
109
- */
110
- export function isPublicPath(path) {
111
- if (isShellPath(path))
112
- return true;
113
- return !looksLikeAssetRequest(path) && !API_PATH_DENYLIST.some((re) => re.test(path));
114
- }
115
- /**
116
- * True if the path looks like a request for a static FILE — it lives under `/assets/` or its last
117
- * segment has an extension (e.g. `.js`/`.css`/`.png`/`.svg`). A file request that reaches the
118
- * notFound handler means the file does NOT exist on disk, so it must 404 — it must NOT fall back to
119
- * index.html. Serving the HTML shell for a missing `.js` makes the browser block the module script
120
- * (MIME mismatch → blank page) and can POISON a service-worker precache (HTML cached as the JS
121
- * entry). Only extensionless navigation paths (client routes like `/`, `/login`) get the SPA shell.
122
- *
123
- * Operational note: `@fastify/static` with `wildcard:false` globs the dist and registers one route
124
- * per file AT STARTUP, so after rebuilding the web bundle (new content-hashed filenames) the server
125
- * must be RESTARTED for the new assets to get routes — otherwise they reach this handler and (now)
126
- * correctly 404 instead of silently serving the shell.
127
- */
128
- export function looksLikeAssetRequest(path) {
129
- return path.startsWith("/assets/") || /\/[^/]+\.[a-z0-9]+$/i.test(path);
130
- }
131
- /**
132
- * Serve the built PWA at `/` with an SPA fallback: a GET NAVIGATION (extensionless, public, non-API)
133
- * that no static file matched returns index.html, so client routes (e.g. /login) work on a hard
134
- * refresh. A request that looks like a missing FILE (`/assets/*`, or any `*.ext`) is served LIVE from
135
- * disk when the file exists under webDir but @fastify/static's startup glob missed it (a build that
136
- * landed after this server started — an OTA window / manual rebuild); otherwise it returns 404, never
137
- * the shell — so a missing asset fails loudly instead of poisoning a browser/SW cache with HTML. An
138
- * unknown /sessions/... still 404/401s from the real handlers / the gate.
139
- */
140
- export function registerStatic(app, opts) {
141
- app.register(fastifyStatic, { root: opts.webDir, wildcard: false });
142
- // The service worker is the OTA UPDATE TRIGGER: a client only swaps to a new bundle once the browser
143
- // sees a CHANGED sw.js. A cacheable sw.js therefore pins clients to a STALE bundle — behind Cloudflare a
144
- // cacheable sw.js inherits a multi-hour browser TTL, which silently blocked OTA updates from reaching
145
- // clients until that cache expired (and reopening the PWA kept serving the old bundle). Force sw.js
146
- // UNCACHED so every update check fetches it fresh and the OTA lands immediately. Cloudflare won't edge-
147
- // cache a no-store response, so origin headers pass straight through — no CDN config needed. Done in
148
- // onSend (not @fastify/static's setHeaders, which the plugin's own default cache-control overrides).
149
- // Content-hashed `/assets/*` are immutable, so their default caching is left untouched.
150
- // ALSO force the HTML SHELL (index.html — served at `/`, `/index.html`, and every SPA-fallback route)
151
- // uncached: the shell embeds the content-hashed asset filenames, so a cached shell keeps pointing the
152
- // browser at OLD assets — a stale bundle that survives an OTA even though the assets themselves rotated.
153
- // Detected by content-type (text/html) so the SPA fallback routes are covered too; hashed `/assets/*`
154
- // (JS/CSS) are immutable and keep their default long-cache.
155
- app.addHook("onSend", async (request, reply, payload) => {
156
- const contentType = String(reply.getHeader("content-type") ?? "");
157
- if (pathForGate(request.url) === "/sw.js" || contentType.includes("text/html")) {
158
- reply.header("cache-control", "no-store, no-cache, must-revalidate");
159
- }
160
- return payload;
161
- });
162
- app.setNotFoundHandler((request, reply) => {
163
- const sendFile = reply.sendFile.bind(reply);
164
- const path = pathForGate(request.url);
165
- if (request.method === "GET" && isPublicForRequest(request.url)) {
166
- if (looksLikeAssetRequest(path)) {
167
- // An asset @fastify/static didn't serve. With `wildcard:false` it freezes the asset route list
168
- // at STARTUP, so a build that landed AFTER this server started — an OTA's build→restart window,
169
- // a manual rebuild, a parallel deploy — has new content-hashed `/assets/*` with NO route. The
170
- // freshly-served index.html points at exactly those, so without this they 404 and the app fails
171
- // to boot until a restart re-globs. Serve such a file LIVE from disk (constrained to webDir) so
172
- // the update window never 404s; a genuinely missing file still 404s (never the HTML shell, which
173
- // would poison a browser/SW cache).
174
- const rel = path.replace(/^\/+/, "");
175
- const abs = resolve(opts.webDir, rel);
176
- const root = resolve(opts.webDir);
177
- if ((abs === root || abs.startsWith(root + sep)) && existsSync(abs)) {
178
- return sendFile(rel);
179
- }
180
- reply.code(404).send({ error: "not found" });
181
- return;
182
- }
183
- // A GET NAVIGATION (extensionless, public, non-API) that matched no file → the SPA shell.
184
- return sendFile("index.html");
185
- }
186
- reply.code(404).send({ error: "not found" });
187
- });
188
- }
@@ -1,5 +0,0 @@
1
- /** Terminal mode needs BOTH a tmux binary and a loadable node-pty. Injectable for tests. */
2
- export declare function detectTerminalSupport(deps?: {
3
- hasTmux?: () => boolean;
4
- hasPty?: () => boolean;
5
- }): boolean;
@@ -1,27 +0,0 @@
1
- import { spawnSync } from "node:child_process";
2
- import { createRequire } from "node:module";
3
- const require = createRequire(import.meta.url);
4
- function tmuxOnPath() {
5
- // spawnSync is FINE here: this runs exactly once, at BOOT (createServer's terminalAvailable probe),
6
- // before any client is connected — there is no event loop latency to protect yet. Hot-path tmux calls
7
- // (kill-session, capture-pane) are async elsewhere.
8
- try {
9
- return spawnSync("tmux", ["-V"], { stdio: "ignore" }).status === 0;
10
- }
11
- catch {
12
- return false;
13
- }
14
- }
15
- function ptyLoads() {
16
- try {
17
- require.resolve("node-pty");
18
- return true;
19
- }
20
- catch {
21
- return false;
22
- }
23
- }
24
- /** Terminal mode needs BOTH a tmux binary and a loadable node-pty. Injectable for tests. */
25
- export function detectTerminalSupport(deps = {}) {
26
- return (deps.hasTmux ?? tmuxOnPath)() && (deps.hasPty ?? ptyLoads)();
27
- }
@@ -1,224 +0,0 @@
1
- import { type PtySpawn } from "./terminal-process.js";
2
- import { type PaneStatus } from "./pane-status.js";
3
- import { type AttachSpawnOptions } from "./config.js";
4
- import type { SessionStore } from "./session-store.js";
5
- import { ProviderRegistry } from "./providers/registry.js";
6
- import { type ClaudeSessionOptions, type CodexSessionOptions, type ProviderId } from "./providers/types.js";
7
- import type { CodexThreadResolver } from "./providers/codex-thread-resolver.js";
8
- export interface TerminalMeta {
9
- id: string;
10
- provider: ProviderId;
11
- cwd: string;
12
- mode: "terminal";
13
- status: "running" | "ended";
14
- createdAt: number;
15
- lastActivityAt: number;
16
- /**
17
- * The session's LIVE activity, derived every ~2.5s from its rendered tmux pane by the capture-pane monitor
18
- * (TerminalManager.refreshActivity → pane-status.classifyPaneStatus). UNIVERSAL (no hooks) + works while
19
- * detached. Surfaced in GET /sessions to drive the rail's per-session status:
20
- * - "working" — actively generating (main spinner OR background agents still developing);
21
- * - "blocked" — claude is WAITING ON YOUR DECISION (a permission or plan prompt) → the loud
22
- * "needs you". This is the ONLY state that alerts;
23
- * - "idle" — a finished turn sitting at an empty prompt, nothing running, nothing to decide (calm).
24
- */
25
- activity: PaneStatus;
26
- /**
27
- * Back-compat boolean = `activity === "blocked"`. Drives the loud "needs you" badge/chip, the away push, and
28
- * the badge count. Kept as a distinct field so existing consumers (awaitingCount, the push gate) don't all
29
- * have to learn the 3-state enum. Set together with `activity` by the monitor.
30
- */
31
- awaiting: boolean;
32
- /**
33
- * Whether this session's claude runs with `--dangerously-skip-permissions` (RCE-by-design: claude can run
34
- * any tool without asking). Derived at create() from the spawn args, persisted, and surfaced in GET /sessions
35
- * so the web rail can badge a session as running in skip-permissions mode.
36
- */
37
- dangerouslySkip: boolean;
38
- /** The effective model: initialized from launch options, then refreshed from live provider chrome when the
39
- * TUI exposes it. Surfaced in GET /sessions so the chat header/rail follow in-session changes. */
40
- model?: string;
41
- /** Effective effort/reasoning: initialized from launch options, then refreshed from live provider chrome.
42
- * Absent means the provider controls its default. */
43
- effort?: string;
44
- /** Provider-native safety controls captured at launch for exact UI display. */
45
- permissionMode?: ClaudeSessionOptions["permissionMode"];
46
- sandbox?: CodexSessionOptions["sandbox"];
47
- approvalPolicy?: CodexSessionOptions["approvalPolicy"];
48
- /** User-set display name (PATCH /sessions/:id). SERVER-side so a rename shows on every device, not just
49
- * the one that typed it. Persisted; absent = unnamed (the UI falls back to the cwd). */
50
- name?: string;
51
- /** Codex exact-thread capture state. Claude sessions do not need a provider resume identity. */
52
- identityState?: "pending" | "exact" | "ambiguous";
53
- providerSessionId?: string;
54
- }
55
- export interface TerminalSub {
56
- unsubscribe(): void;
57
- }
58
- export interface TerminalManagerDeps {
59
- store: SessionStore;
60
- providers: ProviderRegistry;
61
- now: () => number;
62
- ptySpawn?: PtySpawn;
63
- runTmux?: (args: string[]) => void;
64
- /** Dedicated tmux server socket. Defaults to the unchanged production socket; integration tests inject a
65
- * unique socket so spawn, capture, resume, and cleanup cannot touch a live RoamCode instance. */
66
- tmuxSocket?: string;
67
- /**
68
- * Best-effort notifier fired on a false→true `awaiting` transition WHEN NO CLIENT IS ATTACHED (i.e. the
69
- * user is away from the desk). Wired by the transport to the push dispatcher; omitted in tests. Called in
70
- * a try/catch — a throw here can NEVER break the terminal.
71
- */
72
- onAwaiting?: (id: string) => void;
73
- /**
74
- * Best-effort notifier fired when a session's claude exits (the "done" ping). `wasAttached` reports whether
75
- * a client was still attached at the moment of exit — captured BEFORE the subs are torn down — so the
76
- * transport can gate the away-from-desk push on "nobody was watching" (you already see the WS close when you
77
- * are). Same never-throw contract.
78
- */
79
- onFinished?: (id: string, wasAttached: boolean) => void;
80
- /**
81
- * Capture a tmux session's CURRENT rendered pane as plain text (READ-ONLY). Injected for tests; in
82
- * production it defaults to a real `capture-pane -p` on {@link TMUX_SOCKET}. Drives {@link refreshActivity},
83
- * the universal (hook-free) working-vs-awaiting classifier.
84
- */
85
- capturePane?: (sessionName: string) => Promise<string>;
86
- /** Builds the cwd-scoped exact-thread resolver used around a fresh Codex TUI spawn. */
87
- codexThreadResolver?: (cwd: string) => CodexThreadResolver;
88
- }
89
- type CreateTerminalOptions = {
90
- id: string;
91
- cwd: string;
92
- provider: "claude";
93
- options: ClaudeSessionOptions;
94
- cols?: number;
95
- rows?: number;
96
- } | {
97
- id: string;
98
- cwd: string;
99
- provider: "codex";
100
- options: CodexSessionOptions;
101
- cols?: number;
102
- rows?: number;
103
- };
104
- type LegacyCreateTerminalOptions = {
105
- id: string;
106
- cwd: string;
107
- claudeArgs?: string[];
108
- cols?: number;
109
- rows?: number;
110
- };
111
- export declare class TerminalManager {
112
- private readonly deps;
113
- private readonly records;
114
- private readonly providers;
115
- private attachConfig?;
116
- constructor(deps: TerminalManagerDeps);
117
- /** Late-bound (after listen(), which resolves the loopback port) — same config the chat SessionManager
118
- * gets. When set, each terminal's claude is spawned with `--mcp-config` so send_image/send_file work. */
119
- setAttachConfig(attach: AttachSpawnOptions | undefined): void;
120
- create(explicit: CreateTerminalOptions): TerminalMeta;
121
- /** Temporary explicit-Claude seam for the pre-provider transport. */
122
- createLegacyClaude(opts: LegacyCreateTerminalOptions): TerminalMeta;
123
- /**
124
- * Delete stale per-session 0600 files that hold the access token — `mcp-config-<id>.json`, `hooks-<id>.json`,
125
- * `hook-auth-<id>`, and `codex-mcp-token-<id>`. A file is stale when no live session owns its id: leaked by a
126
- * crash, an orphan-reap, a rehydrated record (which carries no such paths, so stop() never unlinks its files),
127
- * or a token rotation. Call at boot AFTER rehydrate + setAttachConfig so `records` reflects the surviving
128
- * sessions. No-op without an attach config.
129
- */
130
- sweepStaleMcpConfigs(): number;
131
- /**
132
- * Kill running terminal sessions with NO attached client that have been idle longer than `ttlMs`. OFF by
133
- * default (ttlMs <= 0) and opt-in via env (SESSION_IDLE_TTL_MS): sessions intentionally survive a
134
- * disconnect for later reattach, so reaping them must never be the default — it's only for hosts that
135
- * choose to bound the accumulation of detached claude+tmux processes. Returns the number reaped.
136
- */
137
- reapIdle(ttlMs: number): number;
138
- /**
139
- * Push a JSON control message to every attached client of a terminal session. An `attach` (file/image)
140
- * frame is ALSO buffered (bounded, oldest-dropped) so a client that (re)connects later still sees files
141
- * that arrived while it was away — see {@link attach}'s replay. Other control frames (e.g. `ask`, which
142
- * has its own answer/replay flow in the transport) are delivered live only.
143
- */
144
- pushControl(id: string, msg: unknown): boolean;
145
- /**
146
- * Set a session's `awaiting` flag ("claude is blocked on YOU"). The capture-pane monitor
147
- * ({@link refreshActivity}) is the authority for this flag in production; this explicit setter exists for
148
- * direct overrides and is exercised by the manager's tests. Deterministic — no timers, no terminal scraping.
149
- * A missing session is a no-op. Does NOT fire the away-from-desk push — the monitor owns that so it can gate
150
- * on {@link isAttached}.
151
- */
152
- setAwaiting(id: string, value: boolean): void;
153
- /** Clear the awaiting flag (user is active / session ended). */
154
- private clearAwaiting;
155
- /** Whether a session currently has ≥1 attached client (a live browser WS). The hook route uses this so the
156
- * away-from-desk push fires ONLY when nobody is watching. */
157
- isAttached(id: string): boolean;
158
- /** How many sessions are currently `awaiting` you. Threaded into each away-from-desk push as `badgeCount`
159
- * so the home-screen app badge tracks "how many sessions need you" (Android/desktop badge; iOS can't). */
160
- awaitingCount(): number;
161
- /**
162
- * Set/clear a session's display name (PATCH /sessions/:id). `undefined` clears back to unnamed. Written
163
- * through to the store so the name survives restarts + rehydrate. Returns false for an unknown id so the
164
- * route can 404 without a separate lookup.
165
- */
166
- setName(id: string, name: string | undefined): boolean;
167
- /**
168
- * Re-derive every RUNNING session's `awaiting` flag from its LIVE rendered tmux pane (`capture-pane`) — the
169
- * single, UNIVERSAL source of truth for working / blocked / idle. Works for EVERY session (no per-session
170
- * hooks needed → correct for sessions created before hooks existed) and works while the browser is DETACHED
171
- * (it reads tmux directly). Called on a ~2.5s timer from start.ts. Read-only: it can never disturb a live
172
- * session.
173
- *
174
- * {@link classifyPaneStatus} decides. A capture that returns "" (tmux hiccup) leaves the last value
175
- * untouched so the status never flaps on a transient miss.
176
- *
177
- * `awaiting` (the loud "needs you" flag) tracks activity==="blocked" ONLY — a finished-but-idle session, or
178
- * one whose background agents are still developing, is NOT "needs you". Fires
179
- * {@link TerminalManagerDeps.onAwaiting} (the away push) on a not-blocked→blocked transition when no client is
180
- * attached, so the nudge is universal + fires only for a genuine block.
181
- */
182
- refreshActivity(): Promise<void>;
183
- /**
184
- * Nudge tmux to repaint the WHOLE screen for a session whose pty is already running — used when a client
185
- * REATTACHES (a fresh xterm) to a still-live tmux client that drew its screen earlier and won't redraw on
186
- * its own, so the new client would otherwise show only a blinking cursor until something changes. A brief
187
- * pty size wiggle (+1 row, then back) sends SIGWINCH, which makes tmux redraw — exactly what the manual
188
- * window-resize the user found does, minus the manual part. Deferred so the new client's own initial resize
189
- * lands first (rec.cols/rec.rows are then the final size we wiggle around). Best-effort + unref'd timers.
190
- */
191
- private forceRedraw;
192
- /** Subscribe to provider output, spawning lazily through the owning provider on the first attach. */
193
- attach(id: string, handlers: {
194
- onData: (chunk: string) => void;
195
- onExit?: () => void;
196
- onControl?: (msg: string) => void;
197
- }, size?: {
198
- cols: number;
199
- rows: number;
200
- }, opts?: {
201
- respawn?: "continue" | "fresh";
202
- signal?: AbortSignal;
203
- }): Promise<TerminalSub | undefined>;
204
- private spawnForRecord;
205
- private cleanupProviderPaths;
206
- private cleanupRecordPaths;
207
- private applyRuntimeSignal;
208
- write(id: string, data: string): void;
209
- resize(id: string, cols: number, rows: number): void;
210
- stop(id: string): void;
211
- get(id: string): TerminalMeta | undefined;
212
- list(): TerminalMeta[];
213
- /**
214
- * After a server/OTA restart: adopt stored terminal sessions whose tmux session is still alive (so they
215
- * reappear, resumable), prune store rows whose tmux session is gone, AND kill ORPHAN tmux sessions — live
216
- * `rc-*` sessions with no store row (leaked by a crash or an interrupted cleanup) — so they don't pile up.
217
- */
218
- rehydrate(opts: {
219
- liveTmuxNames: string[];
220
- }): void;
221
- /** Kill a tmux session for an id without needing a live proc (reuses TerminalProcess's socketed kill). */
222
- private killTmux;
223
- }
224
- export {};