@roamcode.ai/server 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/dist/auth.d.ts +63 -0
  3. package/dist/auth.js +133 -0
  4. package/dist/claude-auth-service.d.ts +76 -0
  5. package/dist/claude-auth-service.js +217 -0
  6. package/dist/claude-latest-service.d.ts +34 -0
  7. package/dist/claude-latest-service.js +61 -0
  8. package/dist/config.d.ts +78 -0
  9. package/dist/config.js +59 -0
  10. package/dist/data-dir.d.ts +42 -0
  11. package/dist/data-dir.js +70 -0
  12. package/dist/diag.d.ts +43 -0
  13. package/dist/diag.js +83 -0
  14. package/dist/fs-service.d.ts +90 -0
  15. package/dist/fs-service.js +290 -0
  16. package/dist/index.d.ts +82 -0
  17. package/dist/index.js +44 -0
  18. package/dist/managed-runtime.d.ts +51 -0
  19. package/dist/managed-runtime.js +411 -0
  20. package/dist/managed-update-helper.d.ts +2 -0
  21. package/dist/managed-update-helper.js +34 -0
  22. package/dist/mcp-send.d.ts +33 -0
  23. package/dist/mcp-send.js +107 -0
  24. package/dist/origin-check.d.ts +37 -0
  25. package/dist/origin-check.js +101 -0
  26. package/dist/pane-status.d.ts +61 -0
  27. package/dist/pane-status.js +145 -0
  28. package/dist/providers/claude-metadata-service.d.ts +58 -0
  29. package/dist/providers/claude-metadata-service.js +352 -0
  30. package/dist/providers/claude-provider.d.ts +11 -0
  31. package/dist/providers/claude-provider.js +166 -0
  32. package/dist/providers/codex-activity.d.ts +21 -0
  33. package/dist/providers/codex-activity.js +122 -0
  34. package/dist/providers/codex-app-server-client.d.ts +90 -0
  35. package/dist/providers/codex-app-server-client.js +485 -0
  36. package/dist/providers/codex-latest-service.d.ts +50 -0
  37. package/dist/providers/codex-latest-service.js +174 -0
  38. package/dist/providers/codex-metadata-service.d.ts +161 -0
  39. package/dist/providers/codex-metadata-service.js +686 -0
  40. package/dist/providers/codex-profile-client.d.ts +16 -0
  41. package/dist/providers/codex-profile-client.js +52 -0
  42. package/dist/providers/codex-profile-security.d.ts +23 -0
  43. package/dist/providers/codex-profile-security.js +161 -0
  44. package/dist/providers/codex-provider.d.ts +15 -0
  45. package/dist/providers/codex-provider.js +174 -0
  46. package/dist/providers/codex-thread-coordinator.d.ts +18 -0
  47. package/dist/providers/codex-thread-coordinator.js +93 -0
  48. package/dist/providers/codex-thread-persistence.d.ts +9 -0
  49. package/dist/providers/codex-thread-persistence.js +45 -0
  50. package/dist/providers/codex-thread-resolver.d.ts +59 -0
  51. package/dist/providers/codex-thread-resolver.js +322 -0
  52. package/dist/providers/options.d.ts +7 -0
  53. package/dist/providers/options.js +155 -0
  54. package/dist/providers/provider-artifacts.d.ts +3 -0
  55. package/dist/providers/provider-artifacts.js +30 -0
  56. package/dist/providers/registry.d.ts +7 -0
  57. package/dist/providers/registry.js +23 -0
  58. package/dist/providers/types.d.ts +95 -0
  59. package/dist/providers/types.js +8 -0
  60. package/dist/push-dispatch.d.ts +81 -0
  61. package/dist/push-dispatch.js +100 -0
  62. package/dist/push-store.d.ts +25 -0
  63. package/dist/push-store.js +79 -0
  64. package/dist/rate-limit.d.ts +52 -0
  65. package/dist/rate-limit.js +72 -0
  66. package/dist/server-config.d.ts +60 -0
  67. package/dist/server-config.js +77 -0
  68. package/dist/service-install.d.ts +60 -0
  69. package/dist/service-install.js +221 -0
  70. package/dist/session-defaults.d.ts +26 -0
  71. package/dist/session-defaults.js +60 -0
  72. package/dist/session-store.d.ts +81 -0
  73. package/dist/session-store.js +654 -0
  74. package/dist/start.d.ts +31 -0
  75. package/dist/start.js +372 -0
  76. package/dist/static-routes.d.ts +101 -0
  77. package/dist/static-routes.js +188 -0
  78. package/dist/terminal-capability.d.ts +5 -0
  79. package/dist/terminal-capability.js +27 -0
  80. package/dist/terminal-manager.d.ts +224 -0
  81. package/dist/terminal-manager.js +917 -0
  82. package/dist/terminal-process.d.ts +85 -0
  83. package/dist/terminal-process.js +238 -0
  84. package/dist/terminal-shared.d.ts +36 -0
  85. package/dist/terminal-shared.js +43 -0
  86. package/dist/tmux-list.d.ts +11 -0
  87. package/dist/tmux-list.js +39 -0
  88. package/dist/transport.d.ts +123 -0
  89. package/dist/transport.js +1559 -0
  90. package/dist/updater.d.ts +161 -0
  91. package/dist/updater.js +451 -0
  92. package/dist/usage-service.d.ts +118 -0
  93. package/dist/usage-service.js +173 -0
  94. package/dist/vapid.d.ts +17 -0
  95. package/dist/vapid.js +31 -0
  96. package/dist/web-push-send.d.ts +20 -0
  97. package/dist/web-push-send.js +21 -0
  98. package/dist/ws-ticket.d.ts +47 -0
  99. package/dist/ws-ticket.js +62 -0
  100. package/package.json +55 -0
@@ -0,0 +1,85 @@
1
+ import { EventEmitter } from "node:events";
2
+ export interface IPty {
3
+ onData(cb: (d: string) => void): void;
4
+ onExit(cb: (e: {
5
+ exitCode: number;
6
+ }) => void): void;
7
+ write(d: string): void;
8
+ resize(c: number, r: number): void;
9
+ kill(sig?: string): void;
10
+ }
11
+ export type PtySpawn = (file: string, args: string[], opts: {
12
+ name: string;
13
+ cols: number;
14
+ rows: number;
15
+ cwd: string;
16
+ env: NodeJS.ProcessEnv;
17
+ }) => IPty;
18
+ export interface TerminalProcessOptions {
19
+ sessionId: string;
20
+ cwd: string;
21
+ executable: string;
22
+ args?: string[];
23
+ /** Attach to an already-proven live tmux session without supplying a provider command. If the session
24
+ * disappeared, tmux fails closed instead of silently creating a fresh, identity-ambiguous conversation. */
25
+ attachOnly?: boolean;
26
+ /** Enable tmux mouse history for this session only. Codex uses inline mode; Claude keeps its existing
27
+ * alternate-screen behavior and browser-owned selection. */
28
+ enableMouseHistory?: boolean;
29
+ tmuxBin?: string;
30
+ cols?: number;
31
+ rows?: number;
32
+ env?: NodeJS.ProcessEnv;
33
+ /** Injectable PTY spawner (default loads node-pty). Tests pass a fake. */
34
+ ptySpawn?: PtySpawn;
35
+ /** Injectable one-shot tmux command runner (kill-session). Default: async fire-and-forget spawn. */
36
+ runTmux?: (args: string[]) => void;
37
+ /** Read the current global tmux update-environment array. The default reader invokes tmux with an argv
38
+ * array and returns variable NAMES only; injection keeps version-specific behavior deterministic in tests. */
39
+ readTmuxUpdateEnvironment?: () => readonly string[] | undefined;
40
+ /** Dedicated tmux server socket (`-L <socket>`). Defaults to {@link TMUX_SOCKET}. Injected by the
41
+ * real-tmux integration test so it runs on a UNIQUE socket and can NEVER touch the live "roamcode"
42
+ * server (a shared socket is how the full suite used to kill a running session). */
43
+ tmuxSocket?: string;
44
+ }
45
+ /** Dedicated tmux server socket — ISOLATES roamcode's sessions from the host user's own tmux (their
46
+ * `tmux ls` never shows `rc-*`, a stray `kill-server` can't nuke ours, and our global options never touch
47
+ * theirs). Every tmux invocation must pass `-L <SOCKET>`. Overridable via RC_TMUX_SOCKET so a SECOND
48
+ * instance (a test/verification server) gets its own socket and never reaps the primary server's
49
+ * sessions on boot (rehydrate treats unknown `rc-*` as orphans). Default is unchanged in production.
50
+ *
51
+ * The default keeps the PRE-RENAME name "remote-coder" ON PURPOSE: live terminal sessions exist on this
52
+ * socket, and an OTA update restarts the server in place — a renamed socket would boot into an empty tmux
53
+ * server and strand every running session (still-running claudes, invisible to the UI). RC = RoamCode. */
54
+ export declare const TMUX_SOCKET: string;
55
+ /** The tmux session name for a roamcode session id. Stable so attach/kill always target the same one. */
56
+ export declare function tmuxSessionName(id: string): string;
57
+ export declare class TerminalProcess extends EventEmitter {
58
+ readonly tmuxName: string;
59
+ private readonly opts;
60
+ private pty?;
61
+ private started;
62
+ private readonly tmuxBin;
63
+ private readonly runTmux;
64
+ private readonly ptySpawn;
65
+ private readonly tmuxSocket;
66
+ private readonly readTmuxUpdateEnvironment;
67
+ constructor(opts: TerminalProcessOptions);
68
+ start(): void;
69
+ write(d: string): void;
70
+ resize(c: number, r: number): void;
71
+ /** Detach (kill the pty client; tmux + claude keep running). `kill:true` also kills the tmux session. */
72
+ stop(opts?: {
73
+ kill?: boolean;
74
+ }): void;
75
+ }
76
+ export interface TerminalProcess {
77
+ on(event: "data", listener: (chunk: string) => void): this;
78
+ on(event: "exit", listener: (info: {
79
+ exitCode: number;
80
+ }) => void): this;
81
+ emit(event: "data", chunk: string): boolean;
82
+ emit(event: "exit", info: {
83
+ exitCode: number;
84
+ }): boolean;
85
+ }
@@ -0,0 +1,238 @@
1
+ // packages/server/src/terminal-process.ts
2
+ import { spawn, spawnSync } from "node:child_process";
3
+ import { EventEmitter } from "node:events";
4
+ import { createRequire } from "node:module";
5
+ const require = createRequire(import.meta.url);
6
+ /** Dedicated tmux server socket — ISOLATES roamcode's sessions from the host user's own tmux (their
7
+ * `tmux ls` never shows `rc-*`, a stray `kill-server` can't nuke ours, and our global options never touch
8
+ * theirs). Every tmux invocation must pass `-L <SOCKET>`. Overridable via RC_TMUX_SOCKET so a SECOND
9
+ * instance (a test/verification server) gets its own socket and never reaps the primary server's
10
+ * sessions on boot (rehydrate treats unknown `rc-*` as orphans). Default is unchanged in production.
11
+ *
12
+ * The default keeps the PRE-RENAME name "remote-coder" ON PURPOSE: live terminal sessions exist on this
13
+ * socket, and an OTA update restarts the server in place — a renamed socket would boot into an empty tmux
14
+ * server and strand every running session (still-running claudes, invisible to the UI). RC = RoamCode. */
15
+ export const TMUX_SOCKET = process.env.RC_TMUX_SOCKET || "remote-coder";
16
+ /** The tmux session name for a roamcode session id. Stable so attach/kill always target the same one. */
17
+ export function tmuxSessionName(id) {
18
+ return `rc-${id}`;
19
+ }
20
+ const ROAMCODE_TMUX_ENVIRONMENT = ["RC_BASE_URL", "RC_SESSION_ID", "RC_TOKEN", "RC_TOKEN_FILE"];
21
+ // tmux's compiled default. Used only when no dedicated server exists yet (or the read fails), so the first
22
+ // RoamCode session retains tmux's normal display/auth forwarding instead of starting from an empty list.
23
+ const DEFAULT_TMUX_UPDATE_ENVIRONMENT = [
24
+ "DISPLAY",
25
+ "KRB5CCNAME",
26
+ "MSYSTEM",
27
+ "SSH_ASKPASS",
28
+ "SSH_AUTH_SOCK",
29
+ "SSH_AGENT_PID",
30
+ "SSH_CONNECTION",
31
+ "WINDOWID",
32
+ "XAUTHORITY",
33
+ ];
34
+ function readTmuxUpdateEnvironment(tmuxBin, tmuxSocket) {
35
+ try {
36
+ const result = spawnSync(tmuxBin, ["-L", tmuxSocket, "show-options", "-gv", "update-environment"], {
37
+ encoding: "utf8",
38
+ timeout: 1_000,
39
+ });
40
+ if (result.status !== 0 || typeof result.stdout !== "string")
41
+ return undefined;
42
+ return result.stdout
43
+ .split("\n")
44
+ .map((name) => name.trim())
45
+ .filter(Boolean);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
50
+ }
51
+ function normalizeTmuxUpdateEnvironment(current) {
52
+ const required = new Set(ROAMCODE_TMUX_ENVIRONMENT);
53
+ return [...(current ?? DEFAULT_TMUX_UPDATE_ENVIRONMENT).filter((name) => !required.has(name)), ...required];
54
+ }
55
+ /** Server-wide tmux options that make the embedded session behave like a plain, transparent terminal rather
56
+ * than a visible tmux: NO status bar (it stole a row and made the TUI look shifted), instant escape-time (the
57
+ * 500ms default mangled Esc-prefixed sequences = arrow/alt keys), mouse + focus + clipboard passthrough, and
58
+ * a 256-color terminfo. Set as ONE chained command BEFORE `new-session` so claude renders full-height from
59
+ * its first frame (no status-bar reflow). Applied on our dedicated socket, so they never affect the user's tmux. */
60
+ function tmuxConfigChain(updateEnvironment) {
61
+ const sets = [
62
+ ["-g", "status", "off"],
63
+ ["-s", "escape-time", "0"],
64
+ // Keep the server default OFF; Codex enables mouse history on its own session immediately before attach.
65
+ // This preserves Claude's current wheel and plain drag-to-select behavior exactly as-is.
66
+ ["-g", "mouse", "off"],
67
+ ["-g", "focus-events", "on"],
68
+ ["-g", "set-clipboard", "on"],
69
+ // Codex wraps OSC 9 notifications in tmux passthrough frames. `-q` keeps older tmux versions compatible
70
+ // if the option is unknown; supported versions forward the bounded frames to the runtime parser.
71
+ ["-gq", "allow-passthrough", "on"],
72
+ ["-g", "default-terminal", "tmux-256color"],
73
+ // remain-on-exit OFF: if claude exits, END the tmux session instead of leaving a frozen, untypeable
74
+ // [exited] pane that nothing respawns. The server forwards the exit to the client (which shows a
75
+ // Restart/Close overlay); a Restart re-attaches and `new-session -A` then spawns a FRESH claude.
76
+ ["-g", "remain-on-exit", "off"],
77
+ ];
78
+ return [
79
+ ...sets.flatMap(([scope, name, value]) => ["set-option", scope, name, value, ";"]),
80
+ // tmux is a long-lived server: without this allow-list, a later session inherits the FIRST tmux client's
81
+ // RC_* environment and Codex MCP can target the wrong RoamCode session. The list was read and normalized
82
+ // in Node because tmux 3.4 cannot expand this array through #{update-environment}. Only variable NAMES
83
+ // enter argv; token values remain solely in the PTY client's environment.
84
+ "set-option",
85
+ "-g",
86
+ "update-environment",
87
+ updateEnvironment.join(" "),
88
+ ";",
89
+ // tmux's stock first WheelUp only enters copy mode; it does not move. Enter AND scroll on that same
90
+ // gesture so Codex feels like a normal scrollable conversation from the first wheel/trackpad movement.
91
+ // Alternate-screen apps (Claude) and apps that request mouse input continue receiving the event.
92
+ "bind-key",
93
+ "-n",
94
+ "WheelUpPane",
95
+ "if-shell",
96
+ "-F",
97
+ "#{||:#{alternate_on},#{mouse_any_flag}}",
98
+ "send-keys -M",
99
+ "copy-mode -e; send-keys -X -N 5 scroll-up",
100
+ ";",
101
+ ];
102
+ }
103
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
104
+ export class TerminalProcess extends EventEmitter {
105
+ tmuxName;
106
+ opts;
107
+ pty;
108
+ started = false;
109
+ tmuxBin;
110
+ runTmux;
111
+ ptySpawn;
112
+ tmuxSocket;
113
+ readTmuxUpdateEnvironment;
114
+ constructor(opts) {
115
+ super();
116
+ this.opts = opts;
117
+ this.tmuxName = tmuxSessionName(opts.sessionId);
118
+ this.tmuxBin = opts.tmuxBin ?? "tmux";
119
+ // Default runner is an ASYNC fire-and-forget spawn (was spawnSync, which BLOCKED the event loop for
120
+ // the full tmux round-trip on every kill — stalling every other live session's WS/pty traffic while a
121
+ // session closed). Nothing consumes the result: the tmux session is either killed or was already gone,
122
+ // so errors are swallowed and the child is unref'd (it must never hold the server process open).
123
+ // The injectable signature is unchanged — tests/callers that inject their own runner are unaffected.
124
+ this.runTmux =
125
+ opts.runTmux ??
126
+ ((args) => {
127
+ try {
128
+ const child = spawn(this.tmuxBin, args, { stdio: "ignore" });
129
+ child.on("error", () => {
130
+ /* tmux missing / spawn failed — nothing to kill */
131
+ });
132
+ child.unref();
133
+ }
134
+ catch {
135
+ /* defensive: spawn with an args array shouldn't throw, but stop() must never crash a teardown */
136
+ }
137
+ });
138
+ this.ptySpawn = opts.ptySpawn ?? defaultPtySpawn;
139
+ this.tmuxSocket = opts.tmuxSocket ?? TMUX_SOCKET;
140
+ this.readTmuxUpdateEnvironment =
141
+ opts.readTmuxUpdateEnvironment ?? (() => readTmuxUpdateEnvironment(this.tmuxBin, this.tmuxSocket));
142
+ }
143
+ start() {
144
+ if (this.started)
145
+ return;
146
+ this.started = true;
147
+ const cols = Math.max(1, this.opts.cols ?? 80);
148
+ const rows = Math.max(1, this.opts.rows ?? 24);
149
+ const env = { ...(this.opts.env ?? process.env) };
150
+ // Strip TMUX/TMUX_PANE so a server itself running inside tmux can't make our `tmux` child think it's
151
+ // nesting (which makes it refuse / attach to the wrong server). Provider-specific env policy belongs to
152
+ // the provider that built this process spec.
153
+ delete env.TMUX;
154
+ delete env.TMUX_PANE;
155
+ // UTF-8 LOCALE: a server launched by launchd/systemd often has NO locale env, so tmux assumes a non-UTF-8
156
+ // terminal and DOWNGRADES wide/block-element glyphs to ASCII — that's what turned claude's logo (drawn
157
+ // with █▛▜▌▐) into coral dashes + black boxes in the browser. Guarantee a UTF-8 locale so tmux passes the
158
+ // glyphs through verbatim. (Belt-and-suspenders with tmux's `-u` flag below.)
159
+ if (!/utf-?8/i.test(env.LC_ALL ?? env.LC_CTYPE ?? env.LANG ?? "")) {
160
+ env.LANG = "en_US.UTF-8";
161
+ env.LC_CTYPE = "en_US.UTF-8";
162
+ }
163
+ // ONE command on our dedicated socket: configure the server, THEN attach-or-create the session running
164
+ // claude. `;` tokens are tmux command separators (no shell involved). `-A` = attach if it already exists.
165
+ // `-u` forces tmux to treat the (node-pty) client as UTF-8 capable regardless of the locale it detects.
166
+ const providerCommand = this.opts.attachOnly
167
+ ? ["attach-session", "-t", this.tmuxName]
168
+ : [
169
+ "new-session",
170
+ "-A",
171
+ "-s",
172
+ this.tmuxName,
173
+ "-x",
174
+ String(cols),
175
+ "-y",
176
+ String(rows),
177
+ "--",
178
+ this.opts.executable,
179
+ ...(this.opts.args ?? []),
180
+ ];
181
+ const terminalCommand = this.opts.enableMouseHistory
182
+ ? [
183
+ ...(this.opts.attachOnly ? [] : [...providerCommand.slice(0, 2), "-d", ...providerCommand.slice(2), ";"]),
184
+ "set-option",
185
+ "-t",
186
+ this.tmuxName,
187
+ "mouse",
188
+ "on",
189
+ ";",
190
+ "attach-session",
191
+ "-t",
192
+ this.tmuxName,
193
+ ]
194
+ : providerCommand;
195
+ const updateEnvironment = normalizeTmuxUpdateEnvironment(this.readTmuxUpdateEnvironment());
196
+ const args = ["-L", this.tmuxSocket, "-u", ...tmuxConfigChain(updateEnvironment), ...terminalCommand];
197
+ const pty = this.ptySpawn(this.tmuxBin, args, { name: "xterm-256color", cols, rows, cwd: this.opts.cwd, env });
198
+ this.pty = pty;
199
+ pty.onData((d) => this.emit("data", d));
200
+ pty.onExit((e) => this.emit("exit", e));
201
+ }
202
+ write(d) {
203
+ try {
204
+ this.pty?.write(d);
205
+ }
206
+ catch {
207
+ // pty gone (claude exited / detached) — drop the write rather than crash the connection.
208
+ }
209
+ }
210
+ resize(c, r) {
211
+ // Clamp BOTH ends: a transient 0/NaN from a pre-layout fit() or an absurd client value (e.g. 1e9) would
212
+ // otherwise hit ioctl(TIOCSWINSZ) and throw / allocate huge line buffers. 1000 is far beyond any viewport.
213
+ const clamp = (n) => Math.min(1000, Math.max(1, Math.trunc(n) || 1));
214
+ try {
215
+ this.pty?.resize(clamp(c), clamp(r));
216
+ }
217
+ catch {
218
+ // pty gone or rejected the dims — best-effort.
219
+ }
220
+ }
221
+ /** Detach (kill the pty client; tmux + claude keep running). `kill:true` also kills the tmux session. */
222
+ stop(opts = {}) {
223
+ if (opts.kill)
224
+ this.runTmux(["-L", this.tmuxSocket, "kill-session", "-t", this.tmuxName]);
225
+ try {
226
+ this.pty?.kill();
227
+ }
228
+ catch {
229
+ // pty already gone — best-effort
230
+ }
231
+ this.pty = undefined;
232
+ }
233
+ }
234
+ /** Default spawner: lazy-load node-pty so a missing native module never breaks module import. */
235
+ const defaultPtySpawn = (file, args, opts) => {
236
+ const pty = require("node-pty");
237
+ return pty.spawn(file, args, opts);
238
+ };
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Where terminal file-exchange uploads live on disk.
3
+ *
4
+ * They are SERVER-OWNED scratch files (the user uploads a file so the terminal's claude can read it), so
5
+ * they must NEVER land in the user's project tree — a stray file there dirties the git checkout and the
6
+ * OTA updater then refuses to run (its dirty-tree guard). The original design wrote them to
7
+ * `<session cwd>/shared_files/`, i.e. straight into whatever repo the terminal was opened in. This module
8
+ * relocates them to the app DATA dir instead, one folder per session, with a 7-day TTL.
9
+ */
10
+ /** Subdir (of the data dir / fsRoot fallback) that holds every terminal session's shared-files folder. */
11
+ export declare const TERMINAL_SHARED_DIRNAME = "terminal-shared";
12
+ /** Terminal uploads live 7 days — pruned on each upload and by a periodic sweep, so they never accumulate. */
13
+ export declare const TERMINAL_FILE_TTL_MS: number;
14
+ /** How often the periodic prune sweep runs. */
15
+ export declare const TERMINAL_SWEEP_INTERVAL_MS: number;
16
+ /**
17
+ * The BASE dir that holds every terminal session's shared-files folder.
18
+ *
19
+ * Prefers the app DATA dir (e.g. `~/.config/roamcode/terminal-shared`) — always outside the user's
20
+ * project tree, so uploads can never dirty a git checkout. The Files panel downloads these back through the
21
+ * fsRoot-confined `/fs/download`, so the base MUST also sit within fsRoot; the data dir does by default
22
+ * (`$HOME/.config/roamcode` ⊂ `$HOME`). If an operator set a NARROWER `FS_ROOT` that excludes the data
23
+ * dir — or the data dir is unset (some test configs) — fall back to a hidden dir INSIDE fsRoot so upload +
24
+ * download stay consistent (still outside any repo in the default `FS_ROOT=$HOME` case).
25
+ */
26
+ export declare function terminalSharedBase(opts: {
27
+ dataDir?: string;
28
+ fsRoot: string;
29
+ }): string;
30
+ /** The shared-files folder for ONE terminal session — a child of {@link terminalSharedBase} keyed by the
31
+ * session id, so different sessions never collide on a same-named upload and each ages out independently. */
32
+ export declare function terminalSharedDir(opts: {
33
+ dataDir?: string;
34
+ fsRoot: string;
35
+ sessionId: string;
36
+ }): string;
@@ -0,0 +1,43 @@
1
+ import { join, resolve, sep } from "node:path";
2
+ /**
3
+ * Where terminal file-exchange uploads live on disk.
4
+ *
5
+ * They are SERVER-OWNED scratch files (the user uploads a file so the terminal's claude can read it), so
6
+ * they must NEVER land in the user's project tree — a stray file there dirties the git checkout and the
7
+ * OTA updater then refuses to run (its dirty-tree guard). The original design wrote them to
8
+ * `<session cwd>/shared_files/`, i.e. straight into whatever repo the terminal was opened in. This module
9
+ * relocates them to the app DATA dir instead, one folder per session, with a 7-day TTL.
10
+ */
11
+ /** Subdir (of the data dir / fsRoot fallback) that holds every terminal session's shared-files folder. */
12
+ export const TERMINAL_SHARED_DIRNAME = "terminal-shared";
13
+ /** Terminal uploads live 7 days — pruned on each upload and by a periodic sweep, so they never accumulate. */
14
+ export const TERMINAL_FILE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
15
+ /** How often the periodic prune sweep runs. */
16
+ export const TERMINAL_SWEEP_INTERVAL_MS = 12 * 60 * 60 * 1000;
17
+ /** True when `path` is `root` itself or nested underneath it (same test FsService uses to confine paths). */
18
+ function isWithinRoot(path, root) {
19
+ const p = resolve(path);
20
+ const r = resolve(root);
21
+ return p === r || p.startsWith(r + sep);
22
+ }
23
+ /**
24
+ * The BASE dir that holds every terminal session's shared-files folder.
25
+ *
26
+ * Prefers the app DATA dir (e.g. `~/.config/roamcode/terminal-shared`) — always outside the user's
27
+ * project tree, so uploads can never dirty a git checkout. The Files panel downloads these back through the
28
+ * fsRoot-confined `/fs/download`, so the base MUST also sit within fsRoot; the data dir does by default
29
+ * (`$HOME/.config/roamcode` ⊂ `$HOME`). If an operator set a NARROWER `FS_ROOT` that excludes the data
30
+ * dir — or the data dir is unset (some test configs) — fall back to a hidden dir INSIDE fsRoot so upload +
31
+ * download stay consistent (still outside any repo in the default `FS_ROOT=$HOME` case).
32
+ */
33
+ export function terminalSharedBase(opts) {
34
+ const preferred = opts.dataDir ? join(opts.dataDir, TERMINAL_SHARED_DIRNAME) : undefined;
35
+ if (preferred && isWithinRoot(preferred, opts.fsRoot))
36
+ return preferred;
37
+ return join(opts.fsRoot, ".roamcode", TERMINAL_SHARED_DIRNAME);
38
+ }
39
+ /** The shared-files folder for ONE terminal session — a child of {@link terminalSharedBase} keyed by the
40
+ * session id, so different sessions never collide on a same-named upload and each ages out independently. */
41
+ export function terminalSharedDir(opts) {
42
+ return join(terminalSharedBase(opts), opts.sessionId);
43
+ }
@@ -0,0 +1,11 @@
1
+ /** Outcome of probing the tmux server. `ok:false` means the probe FAILED in a way we can't interpret —
2
+ * the caller MUST NOT treat that as "zero live sessions" (doing so would prune every stored terminal). */
3
+ export interface TmuxProbe {
4
+ ok: boolean;
5
+ out: string;
6
+ }
7
+ /**
8
+ * Live tmux session names, or `undefined` when the probe could not be determined (a transient tmux error).
9
+ * A definitive "no sessions" is `[]`; `undefined` signals the caller to NOT prune the store.
10
+ */
11
+ export declare function listTmuxSessions(run?: () => TmuxProbe): string[] | undefined;
@@ -0,0 +1,39 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { TMUX_SOCKET } from "./terminal-process.js";
3
+ function defaultRun() {
4
+ // spawnSync is FINE here: this probe runs only at BOOT (rehydrate, before listen()), where blocking a
5
+ // few ms is harmless and a definitive synchronous answer keeps the adopt-or-prune logic simple. Hot-path
6
+ // tmux calls (kill-session, capture-pane) are async elsewhere.
7
+ // MUST target the same dedicated `-L` socket the sessions were created on.
8
+ let r;
9
+ try {
10
+ r = spawnSync("tmux", ["-L", TMUX_SOCKET, "list-sessions", "-F", "#{session_name}"], { encoding: "utf8" });
11
+ }
12
+ catch {
13
+ return { ok: false, out: "" };
14
+ }
15
+ if (r.error)
16
+ return { ok: false, out: "" };
17
+ if (r.status === 0)
18
+ return { ok: true, out: typeof r.stdout === "string" ? r.stdout : "" };
19
+ // Exit nonzero: "no server running on <socket>" is the NORMAL empty case (no sessions exist yet) — that's a
20
+ // definitive "zero sessions". ANY other failure is ambiguous (transient tmux error) → report not-ok so the
21
+ // caller skips destructive pruning rather than deleting live, resumable sessions on a flaky probe.
22
+ const stderr = typeof r.stderr === "string" ? r.stderr : "";
23
+ if (/no server running/i.test(stderr))
24
+ return { ok: true, out: "" };
25
+ return { ok: false, out: "" };
26
+ }
27
+ /**
28
+ * Live tmux session names, or `undefined` when the probe could not be determined (a transient tmux error).
29
+ * A definitive "no sessions" is `[]`; `undefined` signals the caller to NOT prune the store.
30
+ */
31
+ export function listTmuxSessions(run = defaultRun) {
32
+ const { ok, out } = run();
33
+ if (!ok)
34
+ return undefined;
35
+ return out
36
+ .split("\n")
37
+ .map((l) => l.trim())
38
+ .filter((l) => l.length > 0);
39
+ }
@@ -0,0 +1,123 @@
1
+ import type { FastifyInstance } from "fastify";
2
+ import { AuthGate } from "./auth.js";
3
+ import { RateLimiter } from "./rate-limit.js";
4
+ import { WsTicketStore } from "./ws-ticket.js";
5
+ import type { ServerRuntimeConfig } from "./server-config.js";
6
+ import type { SessionStore, StoreMode } from "./session-store.js";
7
+ import type { PushStore } from "./push-store.js";
8
+ import type { PushDispatcher } from "./push-dispatch.js";
9
+ import type { Updater } from "./updater.js";
10
+ import type { ClaudeVersionProbe } from "./diag.js";
11
+ import type { UsageService } from "./usage-service.js";
12
+ import type { ClaudeAuthService } from "./claude-auth-service.js";
13
+ import type { ClaudeLatestService } from "./claude-latest-service.js";
14
+ import { TerminalManager } from "./terminal-manager.js";
15
+ import { ProviderRegistry } from "./providers/registry.js";
16
+ import type { CodexMetadataService } from "./providers/codex-metadata-service.js";
17
+ import type { ClaudeMetadataService } from "./providers/claude-metadata-service.js";
18
+ import type { CodexLatestService } from "./providers/codex-latest-service.js";
19
+ import type { CodexThreadResolver } from "./providers/codex-thread-resolver.js";
20
+ export interface CreateServerDeps {
21
+ store?: SessionStore;
22
+ /** Absolute path to the built PWA (packages/web/dist). When set, the server also serves the UI. */
23
+ webDir?: string;
24
+ pushStore?: PushStore;
25
+ /** VAPID public key exposed at GET /push/vapid for the browser subscription. */
26
+ vapidPublicKey?: string;
27
+ /**
28
+ * Away-from-desk Web Push dispatcher (fan-out for awaiting/finished/file events). Wired by start.ts
29
+ * from the push store + VAPID keys. When omitted (tests / push not configured) the "get pinged" side of
30
+ * the loop is simply a no-op — every route/heuristic still functions, it just sends no notifications.
31
+ */
32
+ pushDispatcher?: PushDispatcher;
33
+ /**
34
+ * In-app OTA self-update (GET /version, POST /update, GET /update/status). Injected so tests can use a
35
+ * fixture release feed without network access. When omitted, a real stable-release updater is built.
36
+ */
37
+ updater?: Updater;
38
+ /**
39
+ * Claude usage limits (GET /usage → the session + weekly bars). Injected here so tests can pass a
40
+ * fake (no real `claude` spawn). When omitted the route reports `usage:null` (the feature is off in
41
+ * the UI). A real UsageService is wired by start.ts from the configured claude bin + the server env.
42
+ */
43
+ usage?: UsageService;
44
+ /**
45
+ * In-app Claude re-authentication (GET /auth/status, POST /auth/login/start|code|cancel). Injected so
46
+ * tests can pass a fake (no real `claude auth` spawn). When omitted the auth routes report "unavailable"
47
+ * (the UI hides the sign-in). A real ClaudeAuthService is wired by start.ts from the claude bin + env.
48
+ */
49
+ claudeAuth?: ClaudeAuthService;
50
+ /**
51
+ * The latest published claude CLI version (GET /claude/version → {installed, latest}), for update
52
+ * awareness. Injected so tests don't hit the npm registry; absent → latest:null (the UI hides the hint).
53
+ */
54
+ claudeLatest?: ClaudeLatestService;
55
+ /**
56
+ * How the session store is actually backed — "sqlite" (durable) or "memory-fallback" (better-sqlite3
57
+ * failed to load; NOT durable across restarts). Surfaced by the authed GET /diag for fleet observability.
58
+ * Threaded from start.ts (it opens the store). Defaults to "sqlite" when omitted.
59
+ */
60
+ storeMode?: StoreMode;
61
+ /**
62
+ * Cached best-effort `claude --version` probe for the authed GET /diag. Injected so tests pass a fake
63
+ * (no real spawn). When omitted a real probe is built from the configured claude bin + server env.
64
+ */
65
+ claudeVersionProbe?: ClaudeVersionProbe;
66
+ /**
67
+ * Global per-client request rate limiter (token bucket). Injected so tests can drive an injectable clock
68
+ * / a tiny limit. When omitted one is built from `config.rateLimitRpm`/`config.rateLimitBurst` (a
69
+ * rpm of 0 disables it). Applied in the global preHandler AFTER the auth gate + origin check.
70
+ */
71
+ rateLimiter?: RateLimiter;
72
+ /**
73
+ * CSPRNG token generator for POST /token/rotate (reuses data-dir.ts's default). Injected so tests get a
74
+ * deterministic rotated token. When omitted, resolveAccessToken's default 32-byte base64url generator.
75
+ */
76
+ generateToken?: () => string;
77
+ /**
78
+ * The token gate. Injected so tests can control the rotation grace window / clock (e.g. graceMs:0 to
79
+ * assert the OLD token is rejected the instant after rotation). When omitted one is built from
80
+ * `config.accessToken` with the default 60s rotation grace.
81
+ */
82
+ authGate?: AuthGate;
83
+ /**
84
+ * Whether terminal mode (tmux + node-pty) is available on this host. Injected so tests can force it
85
+ * on/off without real tmux/pty. When omitted, detectTerminalSupport() is called at boot.
86
+ */
87
+ terminalAvailable?: boolean;
88
+ /**
89
+ * Terminal session manager (injectable for tests; a real one is constructed from deps.store +
90
+ * config.claude.claudeBin when omitted).
91
+ */
92
+ terminalManager?: TerminalManager;
93
+ /** Exact provider registry shared with the terminal manager and provider capability routes. */
94
+ providers?: ProviderRegistry;
95
+ /** Auxiliary Codex app-server metadata. Its failure never disables terminal sessions. */
96
+ codexMetadata?: CodexMetadataService;
97
+ /** Auxiliary Claude model metadata. Its failure never disables terminal sessions. */
98
+ claudeMetadata?: ClaudeMetadataService;
99
+ /** Cached aggregate of every stable Codex metadata method/schema used by this server. */
100
+ codexCapabilityProbe?: {
101
+ get(): Promise<boolean>;
102
+ };
103
+ /** Installation-aware Codex version/update service. */
104
+ codexLatest?: CodexLatestService;
105
+ codexThreadResolver?: (cwd: string) => CodexThreadResolver;
106
+ disposeProviders?: () => void | Promise<void>;
107
+ /**
108
+ * Single-use terminal-WS ticket store (POST /ws-ticket → `?ticket=` on the WS URL, so the long-lived
109
+ * token stays OUT of WS URLs / proxy logs). Injectable so tests drive the clock/TTL; a real 30s-TTL
110
+ * store is built when omitted.
111
+ */
112
+ wsTickets?: WsTicketStore;
113
+ }
114
+ export interface CreateServerResult {
115
+ app: FastifyInstance;
116
+ authGate: AuthGate;
117
+ /** Exposed so startServer can late-bind the MCP attach config (after listen() resolves the port) —
118
+ * this is what gives the terminal's claude send_image/send_file. */
119
+ terminalManager: TerminalManager;
120
+ /** False when tmux/node-pty is unavailable → terminal sessions are disabled (startServer warns loudly). */
121
+ terminalAvailable: boolean;
122
+ }
123
+ export declare function createServer(config: ServerRuntimeConfig, deps?: CreateServerDeps): CreateServerResult;