@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,78 @@
1
+ export interface ServerConfig {
2
+ claudeBin: string;
3
+ }
4
+ export declare function loadConfig(env: NodeJS.ProcessEnv): ServerConfig;
5
+ /**
6
+ * Wiring for the mcp-send server (Claude → user attachments). When present, the spawn layer writes a
7
+ * per-session 0600 MCP config FILE (carrying the loopback base URL, this session's id, and the access
8
+ * token via env) and passes its PATH to the terminal spawn as `--mcp-config <path>`. The token therefore
9
+ * NEVER lands in any process's argv (where `ps`/`/proc` would expose it to other local users) — it
10
+ * lives only in the mode-0600 file. ABSENT → spawn exactly as before (the feature is additive).
11
+ */
12
+ export interface AttachSpawnOptions {
13
+ /** Loopback base URL of roamcode (e.g. http://127.0.0.1:4280) the tool POSTs back to. */
14
+ baseUrl: string;
15
+ /** The access token the mcp-send tool sends as `Authorization: Bearer <token>`. */
16
+ token: string;
17
+ /** Absolute path to the built dist/mcp-send.js node script. */
18
+ mcpScriptPath: string;
19
+ /** Host data dir (mode 0700) the per-session 0600 mcp-config-<id>.json is written into. */
20
+ dataDir: string;
21
+ }
22
+ /** The `{ mcpServers: { ... } }` document written to the per-session 0600 config file. */
23
+ export interface McpConfigDocument {
24
+ mcpServers: {
25
+ roamcode: {
26
+ command: string;
27
+ args: string[];
28
+ env: {
29
+ RC_BASE_URL: string;
30
+ RC_SESSION_ID: string;
31
+ RC_TOKEN: string;
32
+ };
33
+ };
34
+ };
35
+ }
36
+ /**
37
+ * Build the MCP config document for a session. PURE: no fs, no token leakage — the caller writes the
38
+ * returned object to a 0600 file and passes its path to the terminal spawn as `--mcp-config`.
39
+ */
40
+ export declare function buildMcpConfigDocument(sessionId: string, attach: AttachSpawnOptions): McpConfigDocument;
41
+ /** Absolute path of the per-session MCP config file inside the data dir. */
42
+ export declare function mcpConfigPathFor(dataDir: string, sessionId: string): string;
43
+ /** Reserved filename prefix for per-session Codex MCP token artifacts. */
44
+ export declare const CODEX_MCP_TOKEN_PREFIX = "codex-mcp-token-";
45
+ /** Absolute path of the per-session 0600 bare token file forwarded only to Codex's MCP subprocess. */
46
+ export declare function codexMcpTokenPathFor(dataDir: string, sessionId: string): string;
47
+ /**
48
+ * Per-session Claude Code settings document (written 0600, passed as `--settings <path>`). Its HOOKS let
49
+ * claude signal turn boundaries DIRECTLY instead of us scraping the terminal (which can't tell "generating /
50
+ * running a background agent" from "waiting for you" — the source of the false "needs you"):
51
+ * - `Stop` → claude finished its turn and is now waiting on YOU (mark awaiting).
52
+ * - `UserPromptSubmit` → you submitted a prompt → clear it.
53
+ * Each hook curls a loopback endpoint, reading the access token from a 0600 file via `-H '@authFile'` so the
54
+ * token never lands in the hook's argv (ps/proc), and ends in `|| true` so a hook can NEVER block/fail claude.
55
+ * Hooks fire in an interactive tmux pane, including under `--dangerously-skip-permissions` (verified).
56
+ */
57
+ export interface HookCommand {
58
+ type: "command";
59
+ command: string;
60
+ }
61
+ export interface HooksSettingsDocument {
62
+ hooks: {
63
+ Stop: Array<{
64
+ hooks: HookCommand[];
65
+ }>;
66
+ UserPromptSubmit: Array<{
67
+ hooks: HookCommand[];
68
+ }>;
69
+ };
70
+ }
71
+ /** Build the per-session hooks settings. PURE: the caller writes it to a 0600 file + the 0600 `authFilePath`. */
72
+ export declare function buildHooksSettingsDocument(sessionId: string, attach: Pick<AttachSpawnOptions, "baseUrl">, authFilePath: string): HooksSettingsDocument;
73
+ /** Absolute path of the per-session hooks settings file inside the data dir. */
74
+ export declare function hooksSettingsPathFor(dataDir: string, sessionId: string): string;
75
+ /** Absolute path of the per-session 0600 auth-header file the hook curls read via `-H '@file'`. */
76
+ export declare function hookAuthPathFor(dataDir: string, sessionId: string): string;
77
+ /** The exact bytes of that auth-header file (a single curl `-H` line). Keeps the token out of hook argv. */
78
+ export declare function hookAuthFileContent(token: string): string;
package/dist/config.js ADDED
@@ -0,0 +1,59 @@
1
+ import { join } from "node:path";
2
+ export function loadConfig(env) {
3
+ return { claudeBin: env.CLAUDE_BIN ?? "claude" };
4
+ }
5
+ /**
6
+ * Build the MCP config document for a session. PURE: no fs, no token leakage — the caller writes the
7
+ * returned object to a 0600 file and passes its path to the terminal spawn as `--mcp-config`.
8
+ */
9
+ export function buildMcpConfigDocument(sessionId, attach) {
10
+ return {
11
+ mcpServers: {
12
+ roamcode: {
13
+ command: process.execPath,
14
+ args: [attach.mcpScriptPath],
15
+ env: {
16
+ RC_BASE_URL: attach.baseUrl,
17
+ RC_SESSION_ID: sessionId,
18
+ RC_TOKEN: attach.token,
19
+ },
20
+ },
21
+ },
22
+ };
23
+ }
24
+ /** Absolute path of the per-session MCP config file inside the data dir. */
25
+ export function mcpConfigPathFor(dataDir, sessionId) {
26
+ return join(dataDir, `mcp-config-${sessionId}.json`);
27
+ }
28
+ /** Reserved filename prefix for per-session Codex MCP token artifacts. */
29
+ export const CODEX_MCP_TOKEN_PREFIX = "codex-mcp-token-";
30
+ /** Absolute path of the per-session 0600 bare token file forwarded only to Codex's MCP subprocess. */
31
+ export function codexMcpTokenPathFor(dataDir, sessionId) {
32
+ return join(dataDir, `${CODEX_MCP_TOKEN_PREFIX}${sessionId}`);
33
+ }
34
+ /** Build the per-session hooks settings. PURE: the caller writes it to a 0600 file + the 0600 `authFilePath`. */
35
+ export function buildHooksSettingsDocument(sessionId, attach, authFilePath) {
36
+ const post = (event) => ({
37
+ type: "command",
38
+ command: `curl -sS -m 4 -X POST -H '@${authFilePath}' ` +
39
+ `'${attach.baseUrl}/sessions/${sessionId}/hook?event=${event}' >/dev/null 2>&1 || true`,
40
+ });
41
+ return {
42
+ hooks: {
43
+ Stop: [{ hooks: [post("stop")] }],
44
+ UserPromptSubmit: [{ hooks: [post("submit")] }],
45
+ },
46
+ };
47
+ }
48
+ /** Absolute path of the per-session hooks settings file inside the data dir. */
49
+ export function hooksSettingsPathFor(dataDir, sessionId) {
50
+ return join(dataDir, `hooks-${sessionId}.json`);
51
+ }
52
+ /** Absolute path of the per-session 0600 auth-header file the hook curls read via `-H '@file'`. */
53
+ export function hookAuthPathFor(dataDir, sessionId) {
54
+ return join(dataDir, `hook-auth-${sessionId}`);
55
+ }
56
+ /** The exact bytes of that auth-header file (a single curl `-H` line). Keeps the token out of hook argv. */
57
+ export function hookAuthFileContent(token) {
58
+ return `Authorization: Bearer ${token}\n`;
59
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Host data dir for the SQLite DB + access token. Never inside the project tree by default.
3
+ *
4
+ * Rename compat (Remote Coder → RoamCode): installs that predate the rename keep their data where it
5
+ * already lives — the legacy `REMOTE_CODER_DATA_DIR` env still wins over defaults, and an existing
6
+ * `…/remote-coder` dir is preferred over creating a fresh `…/roamcode` (their token / service.json /
7
+ * session index MUST survive an OTA update). Only brand-new installs get the new directory name.
8
+ */
9
+ export declare function resolveDataDir(env: NodeJS.ProcessEnv, exists?: (p: string) => boolean): string;
10
+ export declare function ensureDataDir(dir: string): void;
11
+ /**
12
+ * Strong default token: 32 bytes of CSPRNG entropy (>= spec §9 baseline),
13
+ * base64url-encoded (43 chars, no padding). Never Math.random / randomUUID.
14
+ */
15
+ export declare function generateAccessToken(): string;
16
+ /**
17
+ * Persist a token to `<dataDir>/token` with mode 0600 (overwriting any prior file). Used by both first-run
18
+ * generation and POST /token/rotate. `writeFileSync`'s `mode` is honored only when CREATING a file, so we
19
+ * chmod unconditionally afterwards — a rotated secret can never land in a too-permissive (e.g. world-
20
+ * readable) file left behind by an earlier write.
21
+ */
22
+ export declare function persistAccessToken(dataDir: string, token: string): void;
23
+ export interface ResolveAccessTokenOptions {
24
+ /** From ACCESS_TOKEN; when set it wins and is not persisted. */
25
+ configured?: string;
26
+ dataDir: string;
27
+ /** Injectable generator for tests. Defaults to a 32-byte base64url CSPRNG token. */
28
+ generate?: () => string;
29
+ }
30
+ /**
31
+ * Spec §9: a long random secret generated on first run (printed once, stored).
32
+ * Precedence: explicit ACCESS_TOKEN > persisted token file > freshly generated.
33
+ *
34
+ * A configured (env) token is used verbatim and never written to disk. A
35
+ * generated token is persisted to `<dataDir>/token` with mode 0600 so other
36
+ * users on the host cannot read it; `generated: true` lets the caller print it
37
+ * once with the access URL.
38
+ */
39
+ export declare function resolveAccessToken(opts: ResolveAccessTokenOptions): {
40
+ token: string;
41
+ generated: boolean;
42
+ };
@@ -0,0 +1,70 @@
1
+ import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { randomBytes } from "node:crypto";
3
+ import { join } from "node:path";
4
+ /**
5
+ * Host data dir for the SQLite DB + access token. Never inside the project tree by default.
6
+ *
7
+ * Rename compat (Remote Coder → RoamCode): installs that predate the rename keep their data where it
8
+ * already lives — the legacy `REMOTE_CODER_DATA_DIR` env still wins over defaults, and an existing
9
+ * `…/remote-coder` dir is preferred over creating a fresh `…/roamcode` (their token / service.json /
10
+ * session index MUST survive an OTA update). Only brand-new installs get the new directory name.
11
+ */
12
+ export function resolveDataDir(env, exists = existsSync) {
13
+ if (env.ROAMCODE_DATA_DIR)
14
+ return env.ROAMCODE_DATA_DIR;
15
+ if (env.REMOTE_CODER_DATA_DIR)
16
+ return env.REMOTE_CODER_DATA_DIR; // legacy (pre-rename services set this)
17
+ const pick = (next, legacy) => (!exists(next) && exists(legacy) ? legacy : next);
18
+ if (env.XDG_CONFIG_HOME)
19
+ return pick(join(env.XDG_CONFIG_HOME, "roamcode"), join(env.XDG_CONFIG_HOME, "remote-coder"));
20
+ if (env.HOME)
21
+ return pick(join(env.HOME, ".config", "roamcode"), join(env.HOME, ".config", "remote-coder"));
22
+ return pick(join(process.cwd(), ".roamcode"), join(process.cwd(), ".remote-coder"));
23
+ }
24
+ export function ensureDataDir(dir) {
25
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
26
+ }
27
+ /**
28
+ * Strong default token: 32 bytes of CSPRNG entropy (>= spec §9 baseline),
29
+ * base64url-encoded (43 chars, no padding). Never Math.random / randomUUID.
30
+ */
31
+ export function generateAccessToken() {
32
+ return randomBytes(32).toString("base64url");
33
+ }
34
+ /**
35
+ * Persist a token to `<dataDir>/token` with mode 0600 (overwriting any prior file). Used by both first-run
36
+ * generation and POST /token/rotate. `writeFileSync`'s `mode` is honored only when CREATING a file, so we
37
+ * chmod unconditionally afterwards — a rotated secret can never land in a too-permissive (e.g. world-
38
+ * readable) file left behind by an earlier write.
39
+ */
40
+ export function persistAccessToken(dataDir, token) {
41
+ ensureDataDir(dataDir);
42
+ const tokenPath = join(dataDir, "token");
43
+ writeFileSync(tokenPath, token + "\n", { mode: 0o600 });
44
+ chmodSync(tokenPath, 0o600);
45
+ }
46
+ /**
47
+ * Spec §9: a long random secret generated on first run (printed once, stored).
48
+ * Precedence: explicit ACCESS_TOKEN > persisted token file > freshly generated.
49
+ *
50
+ * A configured (env) token is used verbatim and never written to disk. A
51
+ * generated token is persisted to `<dataDir>/token` with mode 0600 so other
52
+ * users on the host cannot read it; `generated: true` lets the caller print it
53
+ * once with the access URL.
54
+ */
55
+ export function resolveAccessToken(opts) {
56
+ if (opts.configured)
57
+ return { token: opts.configured, generated: false };
58
+ const tokenPath = join(opts.dataDir, "token");
59
+ try {
60
+ const existing = readFileSync(tokenPath, "utf8").trim();
61
+ if (existing)
62
+ return { token: existing, generated: false };
63
+ }
64
+ catch {
65
+ // no token file yet — fall through to generation
66
+ }
67
+ const token = (opts.generate ?? generateAccessToken)();
68
+ persistAccessToken(opts.dataDir, token);
69
+ return { token, generated: true };
70
+ }
package/dist/diag.d.ts ADDED
@@ -0,0 +1,43 @@
1
+ import type { ProviderAvailability } from "./providers/types.js";
2
+ /** Merge host and provider capability probes while replacing all provider/child detail with stable text. */
3
+ export declare function normalizeProviderAvailability(hostTerminalAvailable: boolean, probed: ProviderAvailability, metadataAvailable?: boolean): ProviderAvailability;
4
+ /**
5
+ * Best-effort, CACHED `claude --version` probe for the authed GET /diag (fleet observability). It must
6
+ * NOT block the request long, so the spawn is timeout-guarded (short) and the result is cached for
7
+ * CLAUDE_VERSION_CACHE_MS — a /diag poll then never re-spawns within the window. A missing/erroring
8
+ * claude resolves to {available:false} (never throws), so /diag degrades instead of 500ing.
9
+ *
10
+ * The spawn is injectable (a function returning {stdout} or rejecting) so tests assert the caching +
11
+ * the available/unavailable shaping without spawning a real binary.
12
+ */
13
+ /** How long a probe result (success OR failure) is reused before re-spawning. Long enough that the
14
+ * /diag poll is cheap; short enough that a claude install/upgrade is reflected within a few minutes. */
15
+ export declare const CLAUDE_VERSION_CACHE_MS: number;
16
+ /** Short hard timeout on the spawn so a hung claude never makes /diag hang. */
17
+ export declare const CLAUDE_VERSION_TIMEOUT_MS = 3000;
18
+ export interface ClaudeAvailability {
19
+ available: boolean;
20
+ /** The parsed version string (e.g. "1.2.3") when available; absent otherwise. */
21
+ version?: string;
22
+ }
23
+ /** Run `<bin> --version` (timeout-guarded), resolving its stdout. Rejects on spawn error / non-zero. */
24
+ export type RunClaudeVersion = () => Promise<{
25
+ stdout: string;
26
+ }>;
27
+ export interface ClaudeVersionProbe {
28
+ /** Resolve the (cached) availability. Never rejects. */
29
+ get(): Promise<ClaudeAvailability>;
30
+ }
31
+ /** The real runner: `<claudeBin> --version` with the server env + a short timeout. */
32
+ export declare function defaultRunClaudeVersion(claudeBin: string, env: NodeJS.ProcessEnv): RunClaudeVersion;
33
+ /**
34
+ * Extract a version-looking token from `claude --version` output (e.g. "1.2.3 (Claude Code)" → "1.2.3").
35
+ * Returns the trimmed raw string when no dotted-number token is found (still useful), or undefined for
36
+ * empty output.
37
+ */
38
+ export declare function parseClaudeVersion(raw: string): string | undefined;
39
+ /** Build a cached probe over an injected runner + clock. */
40
+ export declare function createClaudeVersionProbe(deps: {
41
+ run: RunClaudeVersion;
42
+ now?: () => number;
43
+ }): ClaudeVersionProbe;
package/dist/diag.js ADDED
@@ -0,0 +1,83 @@
1
+ import { execFile as nodeExecFile } from "node:child_process";
2
+ /** Merge host and provider capability probes while replacing all provider/child detail with stable text. */
3
+ export function normalizeProviderAvailability(hostTerminalAvailable, probed, metadataAvailable = probed.metadataAvailable) {
4
+ const terminalAvailable = hostTerminalAvailable && probed.terminalAvailable;
5
+ return {
6
+ terminalAvailable,
7
+ metadataAvailable,
8
+ ...(probed.version ? { version: probed.version } : {}),
9
+ ...(!terminalAvailable
10
+ ? { detail: "Provider terminal unavailable" }
11
+ : !metadataAvailable
12
+ ? { detail: "Provider metadata protocol unavailable" }
13
+ : {}),
14
+ };
15
+ }
16
+ /**
17
+ * Best-effort, CACHED `claude --version` probe for the authed GET /diag (fleet observability). It must
18
+ * NOT block the request long, so the spawn is timeout-guarded (short) and the result is cached for
19
+ * CLAUDE_VERSION_CACHE_MS — a /diag poll then never re-spawns within the window. A missing/erroring
20
+ * claude resolves to {available:false} (never throws), so /diag degrades instead of 500ing.
21
+ *
22
+ * The spawn is injectable (a function returning {stdout} or rejecting) so tests assert the caching +
23
+ * the available/unavailable shaping without spawning a real binary.
24
+ */
25
+ /** How long a probe result (success OR failure) is reused before re-spawning. Long enough that the
26
+ * /diag poll is cheap; short enough that a claude install/upgrade is reflected within a few minutes. */
27
+ export const CLAUDE_VERSION_CACHE_MS = 5 * 60_000;
28
+ /** Short hard timeout on the spawn so a hung claude never makes /diag hang. */
29
+ export const CLAUDE_VERSION_TIMEOUT_MS = 3_000;
30
+ /** The real runner: `<claudeBin> --version` with the server env + a short timeout. */
31
+ export function defaultRunClaudeVersion(claudeBin, env) {
32
+ return () => new Promise((resolve, reject) => {
33
+ nodeExecFile(claudeBin, ["--version"], { env, timeout: CLAUDE_VERSION_TIMEOUT_MS, windowsHide: true }, (err, stdout) => {
34
+ if (err)
35
+ reject(err);
36
+ else
37
+ resolve({ stdout: String(stdout) });
38
+ });
39
+ });
40
+ }
41
+ /**
42
+ * Extract a version-looking token from `claude --version` output (e.g. "1.2.3 (Claude Code)" → "1.2.3").
43
+ * Returns the trimmed raw string when no dotted-number token is found (still useful), or undefined for
44
+ * empty output.
45
+ */
46
+ export function parseClaudeVersion(raw) {
47
+ const trimmed = raw.trim();
48
+ if (!trimmed)
49
+ return undefined;
50
+ const m = /\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.]+)?/.exec(trimmed);
51
+ return m ? m[0] : trimmed;
52
+ }
53
+ /** Build a cached probe over an injected runner + clock. */
54
+ export function createClaudeVersionProbe(deps) {
55
+ const now = deps.now ?? (() => Date.now());
56
+ let cache;
57
+ let inFlight;
58
+ return {
59
+ async get() {
60
+ const t = now();
61
+ if (cache && t - cache.at < CLAUDE_VERSION_CACHE_MS)
62
+ return cache.value;
63
+ // Collapse concurrent probes into one spawn.
64
+ if (inFlight)
65
+ return inFlight;
66
+ inFlight = (async () => {
67
+ let value;
68
+ try {
69
+ const { stdout } = await deps.run();
70
+ const version = parseClaudeVersion(stdout);
71
+ value = version ? { available: true, version } : { available: true };
72
+ }
73
+ catch {
74
+ value = { available: false };
75
+ }
76
+ cache = { at: now(), value };
77
+ inFlight = undefined;
78
+ return value;
79
+ })();
80
+ return inFlight;
81
+ },
82
+ };
83
+ }
@@ -0,0 +1,90 @@
1
+ export type FsErrorCode = "forbidden" | "not-found" | "exists";
2
+ export declare class FsError extends Error {
3
+ readonly code: FsErrorCode;
4
+ constructor(code: FsErrorCode, message: string);
5
+ }
6
+ export interface DirEntry {
7
+ name: string;
8
+ path: string;
9
+ isDirectory: boolean;
10
+ isGitRepo: boolean;
11
+ gitBranch?: string;
12
+ }
13
+ export interface DirListing {
14
+ path: string;
15
+ parent?: string;
16
+ entries: DirEntry[];
17
+ }
18
+ export interface FsServiceOptions {
19
+ root: string;
20
+ }
21
+ /** One GET /fs/search hit — a DIRECTORY whose name matched, shaped for the picker. */
22
+ export interface DirSearchResult {
23
+ path: string;
24
+ name: string;
25
+ isGitRepo: boolean;
26
+ }
27
+ /** GET /fs/search walk bounds. Depth 5 / 400 dirs keeps the worst case (a huge home dir) to a bounded,
28
+ * sub-second readdir sweep; 30 results is more than a picker list ever shows. */
29
+ export declare const SEARCH_MAX_DEPTH = 5;
30
+ export declare const SEARCH_MAX_DIRS = 400;
31
+ export declare const SEARCH_MAX_RESULTS = 30;
32
+ export declare class FsService {
33
+ private readonly root;
34
+ constructor(opts: FsServiceOptions);
35
+ /** Resolve a target (absolute or relative to root) and confine it to root. */
36
+ resolveWithinRoot(target: string): string;
37
+ /** Resolve real paths so a symlink inside root cannot point outside it. Missing -> not-found. */
38
+ private realWithinRoot;
39
+ listDirectory(target: string): Promise<DirListing>;
40
+ /** Read .git/HEAD cheaply; return the branch name or undefined if not a repo. */
41
+ private readGitBranch;
42
+ readFileForDownload(target: string): Promise<{
43
+ filename: string;
44
+ data: Buffer;
45
+ }>;
46
+ /**
47
+ * Validate that `target` is a real in-root file and describe it for an attachment frame WITHOUT
48
+ * reading its bytes. Reuses the same resolveWithinRoot + realpath defense as readFileForDownload so
49
+ * a traversal/symlink-escape path throws FsError("forbidden") and a missing path FsError("not-found").
50
+ */
51
+ describeForAttachment(target: string): Promise<{
52
+ name: string;
53
+ isImage: boolean;
54
+ }>;
55
+ writeUploadedFile(targetDir: string, filename: string, data: Buffer): Promise<{
56
+ path: string;
57
+ }>;
58
+ /** Ensure a directory (confined to root) exists, creating parents as needed; returns its absolute path. */
59
+ ensureDirWithinRoot(target: string): Promise<string>;
60
+ /**
61
+ * POST /fs/mkdir: create ONE new directory (recursive:false — the picker creates a single folder under
62
+ * an existing parent; a missing parent is a client bug, not something to silently paper over). Same
63
+ * confinement as listDirectory: resolveWithinRoot on the target + realpath on the PARENT (the target
64
+ * itself doesn't exist yet), so a symlinked parent can't smuggle the create outside root.
65
+ * Errors: "exists" (→409) when the path is already taken, "not-found" (→404) when the parent is
66
+ * missing, "forbidden" (→403) on any escape.
67
+ */
68
+ makeDirectory(target: string): Promise<{
69
+ path: string;
70
+ }>;
71
+ /**
72
+ * GET /fs/search: case-insensitive SUBSTRING match on DIRECTORY names under `base` (default root),
73
+ * for the project picker's "type to find your repo" flow. Breadth-first — so results come back
74
+ * shallowest-first without a sort — bounded by {@link SEARCH_MAX_DEPTH} / {@link SEARCH_MAX_DIRS} /
75
+ * {@link SEARCH_MAX_RESULTS} so a giant tree can never wedge the request. Dot-entries and node_modules
76
+ * are skipped (never a project root; node_modules alone would blow the dir budget). Unreadable dirs are
77
+ * skipped, not fatal. Same fsRoot confinement as every other fs route; entries are reported by their
78
+ * in-root path (symlinked children are NOT re-resolved — mirroring listDirectory).
79
+ */
80
+ searchDirectories(query: string, base?: string): Promise<DirSearchResult[]>;
81
+ /** Prune expired files inside EACH immediate subdirectory of `base` (confined to root). Used to age out
82
+ * the terminal shared-files folders (one per session) in a single sweep — including ORPHANED folders
83
+ * whose session no longer exists (the old per-live-session sweep leaked those forever). Best-effort:
84
+ * returns the total files removed; a missing base / unreadable entry is skipped, not thrown. */
85
+ pruneChildDirsOlderThan(base: string, maxAgeMs: number, now?: number): Promise<number>;
86
+ /** Delete top-level regular files in `target` (confined to root) whose mtime is older than `maxAgeMs`.
87
+ * Best-effort — returns how many were removed; a missing dir / unreadable entry is skipped, not thrown.
88
+ * Subdirectories are left untouched. Used to give terminal shared-files uploads a bounded lifetime. */
89
+ pruneOlderThan(target: string, maxAgeMs: number, now?: number): Promise<number>;
90
+ }