@roamcode.ai/server 1.0.9 → 1.0.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/start.js CHANGED
@@ -6,16 +6,20 @@ import { join as join12 } from "path";
6
6
  import { existsSync as existsSync6 } from "fs";
7
7
 
8
8
  // src/transport.ts
9
- import { randomUUID as randomUUID3 } from "crypto";
9
+ import { randomUUID as randomUUID4 } from "crypto";
10
10
  import { basename as pathBasename } from "path";
11
+ import { createReadStream } from "fs";
11
12
  import Fastify from "fastify";
12
13
  import websocket from "@fastify/websocket";
13
14
  import multipart from "@fastify/multipart";
14
15
 
15
16
  // src/fs-service.ts
16
- import { readdir, readFile, stat, realpath, open, mkdir, unlink } from "fs/promises";
17
+ import { createWriteStream } from "fs";
18
+ import { readdir, readFile, stat, realpath, open, mkdir, unlink, rename, rmdir } from "fs/promises";
17
19
  import { constants } from "fs";
18
20
  import { resolve, join, sep, basename } from "path";
21
+ import { randomUUID } from "crypto";
22
+ import { pipeline } from "stream/promises";
19
23
  var FsError = class extends Error {
20
24
  code;
21
25
  constructor(code, message) {
@@ -107,6 +111,14 @@ var FsService = class {
107
111
  const data = await readFile(real);
108
112
  return { filename: basename(file), data };
109
113
  }
114
+ /** Validate and describe a real in-root file without reading it into memory. */
115
+ async describeFile(target) {
116
+ const file = this.resolveWithinRoot(target);
117
+ const real = await this.realWithinRoot(file);
118
+ const info = await stat(real);
119
+ if (!info.isFile()) throw new FsError("not-found", `not a file: ${target}`);
120
+ return { path: real, filename: basename(file), size: info.size, mtimeMs: info.mtimeMs };
121
+ }
110
122
  /**
111
123
  * Validate that `target` is a real in-root file and describe it for an attachment frame WITHOUT
112
124
  * reading its bytes. Reuses the same resolveWithinRoot + realpath defense as readFileForDownload so
@@ -142,6 +154,95 @@ var FsService = class {
142
154
  }
143
155
  return { path: dest };
144
156
  }
157
+ /** Stream an upload to a same-directory partial and atomically publish it. The caller owns size limits;
158
+ * this method guarantees an interrupted upload never leaves a visible half-file. */
159
+ async writeUploadedStream(targetDir, filename, input, beforeCommit) {
160
+ if (!filename || filename.includes("/") || filename.includes("\\") || filename.includes(sep)) {
161
+ throw new Error(`invalid upload filename (no path separators allowed): ${filename}`);
162
+ }
163
+ const dir = this.resolveWithinRoot(targetDir);
164
+ await this.realWithinRoot(dir);
165
+ const dest = this.resolveWithinRoot(join(dir, filename));
166
+ const partial = this.resolveWithinRoot(join(dir, `.upload-${randomUUID()}.partial`));
167
+ try {
168
+ await pipeline(
169
+ input,
170
+ createWriteStream(partial, { flags: "wx", mode: 384 })
171
+ );
172
+ if (beforeCommit && !beforeCommit()) throw new Error("upload stream rejected before commit");
173
+ await rename(partial, dest);
174
+ const info = await stat(dest);
175
+ return { path: dest, size: info.size };
176
+ } catch (err) {
177
+ await unlink(partial).catch(() => void 0);
178
+ throw err;
179
+ }
180
+ }
181
+ /** Atomically replace one existing in-root managed file with streamed bytes. */
182
+ async replaceFileStream(target, input, beforeCommit) {
183
+ const file = this.resolveWithinRoot(target);
184
+ const real = await this.realWithinRoot(file);
185
+ const parent = resolve(real, "..");
186
+ await this.realWithinRoot(parent);
187
+ const partial = join(parent, `.edit-${randomUUID()}.partial`);
188
+ try {
189
+ await pipeline(
190
+ input,
191
+ createWriteStream(partial, { flags: "wx", mode: 384 })
192
+ );
193
+ if (beforeCommit && !beforeCommit()) throw new Error("replacement stream rejected before commit");
194
+ await rename(partial, real);
195
+ const info = await stat(real);
196
+ return { path: real, size: info.size };
197
+ } catch (err) {
198
+ await unlink(partial).catch(() => void 0);
199
+ throw err;
200
+ }
201
+ }
202
+ /** Best-effort removal used only for server-owned managed attachment copies. */
203
+ async removeManagedPath(target) {
204
+ const file = this.resolveWithinRoot(target);
205
+ const real = await this.realWithinRoot(file);
206
+ await unlink(real);
207
+ await rmdir(resolve(real, "..")).catch(() => void 0);
208
+ }
209
+ /** Discover regular files in an app-owned directory for one-time legacy attachment backfill. Symlinks
210
+ * are ignored and traversal is deliberately shallow: old uploads are direct children, current uploads
211
+ * add exactly one id directory. */
212
+ async discoverManagedFiles(target, maxDepth = 1) {
213
+ let root;
214
+ try {
215
+ root = this.resolveWithinRoot(target);
216
+ await this.realWithinRoot(root);
217
+ } catch {
218
+ return [];
219
+ }
220
+ const found = [];
221
+ const walk = async (dir, depth) => {
222
+ let entries;
223
+ try {
224
+ entries = await readdir(dir, { withFileTypes: true });
225
+ } catch {
226
+ return;
227
+ }
228
+ for (const entry of entries) {
229
+ if (entry.isSymbolicLink() || entry.name.endsWith(".partial")) continue;
230
+ const path = join(dir, entry.name);
231
+ if (entry.isDirectory()) {
232
+ if (depth < maxDepth) await walk(path, depth + 1);
233
+ continue;
234
+ }
235
+ if (!entry.isFile()) continue;
236
+ try {
237
+ const info = await stat(path);
238
+ found.push({ path, filename: entry.name, size: info.size, mtimeMs: info.mtimeMs });
239
+ } catch {
240
+ }
241
+ }
242
+ };
243
+ await walk(root, 0);
244
+ return found;
245
+ }
145
246
  /** Ensure a directory (confined to root) exists, creating parents as needed; returns its absolute path. */
146
247
  async ensureDirWithinRoot(target) {
147
248
  const dir = this.resolveWithinRoot(target);
@@ -231,30 +332,35 @@ var FsService = class {
231
332
  let removed = 0;
232
333
  for (const e of entries) {
233
334
  if (!e.isDirectory()) continue;
234
- removed += await this.pruneOlderThan(join(dir, e.name), maxAgeMs, now);
335
+ removed += await this.pruneOlderThan(join(dir, e.name), maxAgeMs, now, true);
235
336
  }
236
337
  return removed;
237
338
  }
238
339
  /** Delete top-level regular files in `target` (confined to root) whose mtime is older than `maxAgeMs`.
239
340
  * Best-effort — returns how many were removed; a missing dir / unreadable entry is skipped, not thrown.
240
341
  * Subdirectories are left untouched. Used to give terminal shared-files uploads a bounded lifetime. */
241
- async pruneOlderThan(target, maxAgeMs, now = Date.now()) {
342
+ async pruneOlderThan(target, maxAgeMs, now = Date.now(), recursive = false) {
242
343
  let dir;
243
344
  try {
244
345
  dir = this.resolveWithinRoot(target);
245
346
  } catch {
246
347
  return 0;
247
348
  }
248
- let names;
349
+ let entries;
249
350
  try {
250
- names = await readdir(dir);
351
+ entries = await readdir(dir, { withFileTypes: true });
251
352
  } catch {
252
353
  return 0;
253
354
  }
254
355
  let removed = 0;
255
- for (const name of names) {
256
- const p = join(dir, name);
356
+ for (const entry of entries) {
357
+ const p = join(dir, entry.name);
257
358
  try {
359
+ if (recursive && entry.isDirectory()) {
360
+ removed += await this.pruneOlderThan(p, maxAgeMs, now, true);
361
+ await rmdir(p).catch(() => void 0);
362
+ continue;
363
+ }
258
364
  const s = await stat(p);
259
365
  if (s.isFile() && now - s.mtimeMs > maxAgeMs) {
260
366
  await unlink(p);
@@ -653,7 +759,7 @@ function terminalSharedDir(opts) {
653
759
 
654
760
  // src/updater.ts
655
761
  import { spawn as nodeSpawn } from "child_process";
656
- import { randomUUID as randomUUID2 } from "crypto";
762
+ import { randomUUID as randomUUID3 } from "crypto";
657
763
  import {
658
764
  chmodSync as nodeChmodSync,
659
765
  existsSync as nodeExistsSync,
@@ -667,7 +773,7 @@ import { fileURLToPath } from "url";
667
773
 
668
774
  // src/managed-runtime.ts
669
775
  import { spawn } from "child_process";
670
- import { randomUUID } from "crypto";
776
+ import { randomUUID as randomUUID2 } from "crypto";
671
777
  import {
672
778
  chmodSync as chmodSync3,
673
779
  existsSync as existsSync4,
@@ -765,7 +871,7 @@ function readPreviousVersion(root) {
765
871
  }
766
872
 
767
873
  // src/updater.ts
768
- var RUNNING_VERSION = "1.0.9" ? "1.0.9".replace(/^v/, "") : packageVersion();
874
+ var RUNNING_VERSION = "1.0.11" ? "1.0.11".replace(/^v/, "") : packageVersion();
769
875
  var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
770
876
  var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
771
877
  var CHECK_CACHE_MS = 15 * 6e4;
@@ -1064,7 +1170,7 @@ var Updater = class {
1064
1170
  const path = join6(this.dataDir, "update-status.json");
1065
1171
  const value = JSON.stringify(status, null, 2) + "\n";
1066
1172
  if (this.fs.renameSync) {
1067
- const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
1173
+ const temp = `${path}.${process.pid}.${randomUUID3()}.tmp`;
1068
1174
  this.fs.writeFileSync(temp, value, 384);
1069
1175
  this.fs.renameSync(temp, path);
1070
1176
  } else {
@@ -1127,7 +1233,7 @@ var Updater = class {
1127
1233
  return { started: false, reason: error instanceof Error ? error.message : String(error) };
1128
1234
  }
1129
1235
  }
1130
- const operationId = randomUUID2();
1236
+ const operationId = randomUUID3();
1131
1237
  const status = {
1132
1238
  operationId,
1133
1239
  state: "starting",
@@ -1811,6 +1917,25 @@ function brandSessionStore(store) {
1811
1917
  concreteSessionStores.add(store);
1812
1918
  return store;
1813
1919
  }
1920
+ function sessionFileRowToStored(row) {
1921
+ return {
1922
+ id: row.id,
1923
+ sessionId: row.session_id,
1924
+ direction: row.direction,
1925
+ storage: row.storage,
1926
+ name: row.name,
1927
+ path: row.path,
1928
+ mimeType: row.mime_type,
1929
+ size: row.size,
1930
+ kind: row.kind,
1931
+ ...row.caption ? { caption: row.caption } : {},
1932
+ createdAt: row.created_at,
1933
+ updatedAt: row.updated_at,
1934
+ expiresAt: row.expires_at,
1935
+ ...row.derived_from_id ? { derivedFromId: row.derived_from_id } : {},
1936
+ ...row.hidden_at !== null ? { hiddenAt: row.hidden_at } : {}
1937
+ };
1938
+ }
1814
1939
  function parseSpawnArgs(raw) {
1815
1940
  if (typeof raw !== "string" || raw.length === 0) return void 0;
1816
1941
  try {
@@ -1919,6 +2044,9 @@ function cloneStoredSessionDefaults(value) {
1919
2044
  updatedAt: value.updatedAt
1920
2045
  };
1921
2046
  }
2047
+ function cloneStoredSessionFile(value) {
2048
+ return { ...value };
2049
+ }
1922
2050
  function appSettingRowToSessionDefaults(row) {
1923
2051
  if (!row) return void 0;
1924
2052
  try {
@@ -1933,6 +2061,7 @@ function appSettingRowToSessionDefaults(row) {
1933
2061
  }
1934
2062
  function inMemoryStore() {
1935
2063
  const map = /* @__PURE__ */ new Map();
2064
+ const files = /* @__PURE__ */ new Map();
1936
2065
  const provisionalProviderSessionIds = /* @__PURE__ */ new Map();
1937
2066
  let sessionDefaults;
1938
2067
  const write = (s) => {
@@ -2031,13 +2160,38 @@ function inMemoryStore() {
2031
2160
  };
2032
2161
  return cloneStoredSessionDefaults(sessionDefaults);
2033
2162
  },
2163
+ putFile: (file) => files.set(`${file.sessionId}:${file.id}`, cloneStoredSessionFile(file)),
2164
+ getFile: (sessionId, id) => {
2165
+ const value = files.get(`${sessionId}:${id}`);
2166
+ return value ? cloneStoredSessionFile(value) : void 0;
2167
+ },
2168
+ listFiles: (sessionId, includeHidden = false) => [...files.values()].filter((file) => file.sessionId === sessionId && (includeHidden || file.hiddenAt === void 0)).sort((a, b) => b.createdAt - a.createdAt || b.id.localeCompare(a.id)).map(cloneStoredSessionFile),
2169
+ setFileHidden: (sessionId, id, hiddenAt) => {
2170
+ const file = files.get(`${sessionId}:${id}`);
2171
+ if (!file) return;
2172
+ if (hiddenAt === void 0) delete file.hiddenAt;
2173
+ else file.hiddenAt = hiddenAt;
2174
+ file.updatedAt = Date.now();
2175
+ },
2176
+ deleteFile: (sessionId, id) => void files.delete(`${sessionId}:${id}`),
2177
+ pruneFiles: (expiredBefore) => {
2178
+ const removed = [];
2179
+ for (const [key, file] of files) {
2180
+ if (file.expiresAt > expiredBefore) continue;
2181
+ removed.push(cloneStoredSessionFile(file));
2182
+ files.delete(key);
2183
+ }
2184
+ return removed;
2185
+ },
2034
2186
  delete: (id) => {
2035
2187
  provisionalProviderSessionIds.delete(id);
2036
2188
  map.delete(id);
2189
+ for (const [key, file] of files) if (file.sessionId === id) files.delete(key);
2037
2190
  },
2038
2191
  close: () => {
2039
2192
  provisionalProviderSessionIds.clear();
2040
2193
  map.clear();
2194
+ files.clear();
2041
2195
  sessionDefaults = void 0;
2042
2196
  },
2043
2197
  mode: "memory-fallback"
@@ -2115,6 +2269,28 @@ function openSessionStore(opts) {
2115
2269
  revision INTEGER NOT NULL CHECK (revision > 0),
2116
2270
  updated_at INTEGER NOT NULL
2117
2271
  );
2272
+
2273
+ CREATE TABLE IF NOT EXISTS session_files (
2274
+ id TEXT NOT NULL,
2275
+ session_id TEXT NOT NULL,
2276
+ direction TEXT NOT NULL CHECK (direction IN ('sent', 'received')),
2277
+ storage TEXT NOT NULL CHECK (storage IN ('managed', 'workspace')),
2278
+ name TEXT NOT NULL,
2279
+ path TEXT NOT NULL,
2280
+ mime_type TEXT NOT NULL,
2281
+ size INTEGER NOT NULL,
2282
+ kind TEXT NOT NULL CHECK (kind IN ('image', 'pdf', 'text', 'binary')),
2283
+ caption TEXT,
2284
+ created_at INTEGER NOT NULL,
2285
+ updated_at INTEGER NOT NULL,
2286
+ expires_at INTEGER NOT NULL,
2287
+ derived_from_id TEXT,
2288
+ hidden_at INTEGER,
2289
+ PRIMARY KEY (session_id, id)
2290
+ );
2291
+ CREATE INDEX IF NOT EXISTS session_files_session_created_idx
2292
+ ON session_files(session_id, created_at DESC);
2293
+ CREATE INDEX IF NOT EXISTS session_files_expiry_idx ON session_files(expires_at);
2118
2294
  `);
2119
2295
  try {
2120
2296
  db.exec("ALTER TABLE provider_sessions ADD COLUMN provisional_provider_session_id TEXT");
@@ -2194,6 +2370,33 @@ function openSessionStore(opts) {
2194
2370
  ON CONFLICT(key) DO UPDATE SET
2195
2371
  value_json=excluded.value_json, revision=excluded.revision, updated_at=excluded.updated_at
2196
2372
  `);
2373
+ const filePutStmt = db.prepare(`
2374
+ INSERT INTO session_files (
2375
+ id, session_id, direction, storage, name, path, mime_type, size, kind, caption,
2376
+ created_at, updated_at, expires_at, derived_from_id, hidden_at
2377
+ ) VALUES (
2378
+ @id, @session_id, @direction, @storage, @name, @path, @mime_type, @size, @kind, @caption,
2379
+ @created_at, @updated_at, @expires_at, @derived_from_id, @hidden_at
2380
+ ) ON CONFLICT(session_id, id) DO UPDATE SET
2381
+ direction=excluded.direction, storage=excluded.storage, name=excluded.name, path=excluded.path,
2382
+ mime_type=excluded.mime_type, size=excluded.size, kind=excluded.kind, caption=excluded.caption,
2383
+ updated_at=excluded.updated_at, expires_at=excluded.expires_at,
2384
+ derived_from_id=excluded.derived_from_id, hidden_at=excluded.hidden_at
2385
+ `);
2386
+ const fileGetStmt = db.prepare("SELECT * FROM session_files WHERE session_id = ? AND id = ?");
2387
+ const fileListStmt = db.prepare(
2388
+ "SELECT * FROM session_files WHERE session_id = ? AND hidden_at IS NULL ORDER BY created_at DESC, id DESC"
2389
+ );
2390
+ const fileListAllStmt = db.prepare(
2391
+ "SELECT * FROM session_files WHERE session_id = ? ORDER BY created_at DESC, id DESC"
2392
+ );
2393
+ const fileHideStmt = db.prepare(
2394
+ "UPDATE session_files SET hidden_at = ?, updated_at = ? WHERE session_id = ? AND id = ?"
2395
+ );
2396
+ const fileDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ? AND id = ?");
2397
+ const filesForSessionDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ?");
2398
+ const expiredFilesStmt = db.prepare("SELECT * FROM session_files WHERE expires_at <= ?");
2399
+ const pruneFilesStmt = db.prepare("DELETE FROM session_files WHERE expires_at <= ?");
2197
2400
  const ownerOf = (id) => {
2198
2401
  const hasLegacy = legacyGetStmt.get(id) !== void 0;
2199
2402
  const hasProvider = providerGetStmt.get(id) !== void 0;
@@ -2370,10 +2573,45 @@ function openSessionStore(opts) {
2370
2573
  },
2371
2574
  getSessionDefaults: () => appSettingRowToSessionDefaults(sessionDefaultsGetStmt.get()),
2372
2575
  putSessionDefaults: (defaults, expectedRevision, updatedAt) => putSessionDefaultsAtomically.immediate(normalizeSessionDefaults(defaults), expectedRevision, updatedAt),
2576
+ putFile: (file) => {
2577
+ filePutStmt.run({
2578
+ id: file.id,
2579
+ session_id: file.sessionId,
2580
+ direction: file.direction,
2581
+ storage: file.storage,
2582
+ name: file.name,
2583
+ path: file.path,
2584
+ mime_type: file.mimeType,
2585
+ size: file.size,
2586
+ kind: file.kind,
2587
+ caption: file.caption ?? null,
2588
+ created_at: file.createdAt,
2589
+ updated_at: file.updatedAt,
2590
+ expires_at: file.expiresAt,
2591
+ derived_from_id: file.derivedFromId ?? null,
2592
+ hidden_at: file.hiddenAt ?? null
2593
+ });
2594
+ },
2595
+ getFile: (sessionId, id) => {
2596
+ const row = fileGetStmt.get(sessionId, id);
2597
+ return row ? sessionFileRowToStored(row) : void 0;
2598
+ },
2599
+ listFiles: (sessionId, includeHidden = false) => {
2600
+ const rows = includeHidden ? fileListAllStmt.all(sessionId) : fileListStmt.all(sessionId);
2601
+ return rows.map(sessionFileRowToStored);
2602
+ },
2603
+ setFileHidden: (sessionId, id, hiddenAt) => fileHideStmt.run(hiddenAt ?? null, Date.now(), sessionId, id),
2604
+ deleteFile: (sessionId, id) => void fileDeleteStmt.run(sessionId, id),
2605
+ pruneFiles: (expiredBefore) => {
2606
+ const rows = expiredFilesStmt.all(expiredBefore);
2607
+ pruneFilesStmt.run(expiredBefore);
2608
+ return rows.map(sessionFileRowToStored);
2609
+ },
2373
2610
  delete: (id) => {
2374
2611
  const owner = ownerOf(id);
2375
2612
  if (owner === "claude") legacyDeleteStmt.run(id);
2376
2613
  else if (owner === "codex") providerDeleteStmt.run(id);
2614
+ filesForSessionDeleteStmt.run(id);
2377
2615
  },
2378
2616
  close: () => db.close(),
2379
2617
  mode: "sqlite"
@@ -4004,6 +4242,74 @@ var MAX_PENDING_TERMINAL_INPUT_FRAMES = 64;
4004
4242
  var MAX_PENDING_TERMINAL_INPUT_BYTES = 1e6;
4005
4243
  var MAX_TERMINAL_WS_BUFFER = 16e6;
4006
4244
  var TERMINAL_WS_PING_MS = 25e3;
4245
+ var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4246
+ "txt",
4247
+ "md",
4248
+ "markdown",
4249
+ "json",
4250
+ "jsonl",
4251
+ "yaml",
4252
+ "yml",
4253
+ "toml",
4254
+ "xml",
4255
+ "csv",
4256
+ "tsv",
4257
+ "log",
4258
+ "js",
4259
+ "jsx",
4260
+ "ts",
4261
+ "tsx",
4262
+ "css",
4263
+ "scss",
4264
+ "html",
4265
+ "htm",
4266
+ "sql",
4267
+ "sh",
4268
+ "bash",
4269
+ "zsh",
4270
+ "py",
4271
+ "rb",
4272
+ "go",
4273
+ "rs",
4274
+ "java",
4275
+ "kt",
4276
+ "swift",
4277
+ "c",
4278
+ "h",
4279
+ "cpp",
4280
+ "hpp",
4281
+ "env",
4282
+ "ini",
4283
+ "conf"
4284
+ ]);
4285
+ var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4286
+ "png",
4287
+ "jpg",
4288
+ "jpeg",
4289
+ "gif",
4290
+ "webp",
4291
+ "svg",
4292
+ "bmp",
4293
+ "avif",
4294
+ "apng",
4295
+ "heic",
4296
+ "heif"
4297
+ ]);
4298
+ function attachmentMedia(filename, declared = "application/octet-stream") {
4299
+ const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
4300
+ if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
4301
+ const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
4302
+ return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
4303
+ }
4304
+ if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
4305
+ if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
4306
+ return { mimeType: "text/plain; charset=utf-8", kind: "text" };
4307
+ }
4308
+ return { mimeType: declared || "application/octet-stream", kind: "binary" };
4309
+ }
4310
+ function publicSessionFile(file, available = true) {
4311
+ return { ...file, isImage: file.kind === "image", available };
4312
+ }
4007
4313
  function isDisallowedPushHost(hostname) {
4008
4314
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
4009
4315
  if (host === "localhost" || host.endsWith(".localhost")) return true;
@@ -4059,8 +4365,44 @@ function createServer(config, deps = {}) {
4059
4365
  });
4060
4366
  const fsService = new FsService({ root: config.fsRoot });
4061
4367
  const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
4368
+ const backfilledFileSessions = /* @__PURE__ */ new Set();
4369
+ const backfillManagedFiles = async (sessionId) => {
4370
+ if (backfilledFileSessions.has(sessionId)) return;
4371
+ backfilledFileSessions.add(sessionId);
4372
+ const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
4373
+ const discovered = await fsService.discoverManagedFiles(sessionDir);
4374
+ const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
4375
+ for (const file of discovered) {
4376
+ if (knownPaths.has(file.path)) continue;
4377
+ const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
4378
+ if (expiresAt <= Date.now()) continue;
4379
+ const now = Date.now();
4380
+ const media = attachmentMedia(file.filename);
4381
+ store.putFile({
4382
+ id: randomUUID4(),
4383
+ sessionId,
4384
+ direction: "sent",
4385
+ storage: "managed",
4386
+ name: file.filename,
4387
+ path: file.path,
4388
+ mimeType: media.mimeType,
4389
+ size: file.size,
4390
+ kind: media.kind,
4391
+ createdAt: file.mtimeMs,
4392
+ updatedAt: now,
4393
+ expiresAt
4394
+ });
4395
+ knownPaths.add(file.path);
4396
+ }
4397
+ };
4062
4398
  const sweepSharedFiles = () => {
4063
- void fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
4399
+ void (async () => {
4400
+ const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
4401
+ await Promise.all(
4402
+ expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
4403
+ );
4404
+ await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
4405
+ })();
4064
4406
  };
4065
4407
  sweepSharedFiles();
4066
4408
  const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
@@ -4305,7 +4647,7 @@ function createServer(config, deps = {}) {
4305
4647
  reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
4306
4648
  return;
4307
4649
  }
4308
- const id = randomUUID3();
4650
+ const id = randomUUID4();
4309
4651
  const rawOptions = body.options ?? (provider === "claude" ? {
4310
4652
  ...typeof body.model === "string" ? { model: body.model } : {},
4311
4653
  ...typeof body.effort === "string" ? { effort: body.effort } : {},
@@ -4529,8 +4871,10 @@ function createServer(config, deps = {}) {
4529
4871
  }
4530
4872
  const caption = typeof body.caption === "string" ? body.caption : void 0;
4531
4873
  let described;
4874
+ let fileInfo;
4532
4875
  try {
4533
4876
  described = await fsService.describeForAttachment(body.path);
4877
+ fileInfo = await fsService.describeFile(body.path);
4534
4878
  } catch (err) {
4535
4879
  if (err instanceof FsError) {
4536
4880
  reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
@@ -4540,14 +4884,28 @@ function createServer(config, deps = {}) {
4540
4884
  return;
4541
4885
  }
4542
4886
  const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
4543
- const id = randomUUID3();
4544
- terminalManager.pushControl(sessionId, {
4545
- t: "attach",
4887
+ const id = randomUUID4();
4888
+ const now = Date.now();
4889
+ const media = attachmentMedia(described.name);
4890
+ const stored = {
4546
4891
  id,
4547
- path: body.path,
4892
+ sessionId,
4893
+ direction: "received",
4894
+ storage: "workspace",
4548
4895
  name: described.name,
4549
- caption,
4550
- isImage
4896
+ path: body.path,
4897
+ mimeType: media.mimeType,
4898
+ size: fileInfo.size,
4899
+ kind: isImage ? "image" : media.kind,
4900
+ ...caption ? { caption } : {},
4901
+ createdAt: now,
4902
+ updatedAt: now,
4903
+ expiresAt: now + TERMINAL_FILE_TTL_MS
4904
+ };
4905
+ store.putFile(stored);
4906
+ terminalManager.pushControl(sessionId, {
4907
+ t: "attach",
4908
+ ...publicSessionFile(stored)
4551
4909
  });
4552
4910
  dispatchPush({ kind: "file", sessionId, detail: described.name });
4553
4911
  reply.code(200).send({ ok: true, id });
@@ -5078,23 +5436,126 @@ function createServer(config, deps = {}) {
5078
5436
  reply.code(400).send({ error: err.message });
5079
5437
  }
5080
5438
  });
5439
+ app.get("/sessions/:id/files", async (request, reply) => {
5440
+ if (!terminalManager.get(request.params.id)) {
5441
+ reply.code(404).send({ error: "terminal session not found" });
5442
+ return;
5443
+ }
5444
+ await backfillManagedFiles(request.params.id);
5445
+ const files = await Promise.all(
5446
+ store.listFiles(request.params.id).map(async (file) => {
5447
+ const available = file.expiresAt > Date.now() && await fsService.describeFile(file.path).then(() => true).catch(() => false);
5448
+ return publicSessionFile(file, available);
5449
+ })
5450
+ );
5451
+ return {
5452
+ files,
5453
+ policy: {
5454
+ maxUploadBytes: config.maxUploadBytes,
5455
+ retentionMs: TERMINAL_FILE_TTL_MS,
5456
+ durable: store.mode === "sqlite"
5457
+ }
5458
+ };
5459
+ });
5460
+ app.get(
5461
+ "/sessions/:id/files/:fileId/content",
5462
+ async (request, reply) => {
5463
+ const file = store.getFile(request.params.id, request.params.fileId);
5464
+ if (!file || file.hiddenAt !== void 0) {
5465
+ reply.code(404).send({ error: "file not found" });
5466
+ return;
5467
+ }
5468
+ if (file.expiresAt <= Date.now()) {
5469
+ reply.code(410).send({ error: "file has expired" });
5470
+ return;
5471
+ }
5472
+ try {
5473
+ const info = await fsService.describeFile(file.path);
5474
+ const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
5475
+ reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
5476
+ "content-security-policy",
5477
+ "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
5478
+ ).header("content-disposition", contentDisposition(info.filename, disposition));
5479
+ const range = request.headers.range;
5480
+ if (range) {
5481
+ const match = /^bytes=(\d+)-(\d*)$/.exec(range);
5482
+ if (!match) {
5483
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
5484
+ return;
5485
+ }
5486
+ const start = Number(match[1]);
5487
+ const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
5488
+ if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
5489
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
5490
+ return;
5491
+ }
5492
+ return reply.code(206).header("content-range", `bytes ${start}-${end}/${info.size}`).header("content-length", String(end - start + 1)).send(createReadStream(info.path, { start, end }));
5493
+ }
5494
+ return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
5495
+ } catch (err) {
5496
+ const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
5497
+ reply.code(code).send({ error: err.message });
5498
+ }
5499
+ }
5500
+ );
5081
5501
  app.post("/sessions/:id/upload", async (request, reply) => {
5082
- const meta = terminalManager.get(request.params.id);
5083
- if (!meta) {
5502
+ const sessionId = request.params.id;
5503
+ if (!terminalManager.get(sessionId)) {
5084
5504
  reply.code(404).send({ error: "terminal session not found" });
5085
5505
  return;
5086
5506
  }
5087
- let dir;
5507
+ let data;
5508
+ try {
5509
+ data = await request.file();
5510
+ } catch (err) {
5511
+ reply.code(400).send({ error: err.message });
5512
+ return;
5513
+ }
5514
+ if (!data) {
5515
+ reply.code(400).send({ error: "no file field in the upload" });
5516
+ return;
5517
+ }
5518
+ const id = randomUUID4();
5088
5519
  try {
5089
- dir = await fsService.ensureDirWithinRoot(
5090
- terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: meta.id })
5520
+ const dir = await fsService.ensureDirWithinRoot(
5521
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
5091
5522
  );
5523
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
5524
+ const now = Date.now();
5525
+ const media = attachmentMedia(data.filename, data.mimetype);
5526
+ const stored = {
5527
+ id,
5528
+ sessionId,
5529
+ direction: "sent",
5530
+ storage: "managed",
5531
+ name: data.filename,
5532
+ path: written.path,
5533
+ mimeType: media.mimeType,
5534
+ size: written.size,
5535
+ kind: media.kind,
5536
+ createdAt: now,
5537
+ updatedAt: now,
5538
+ expiresAt: now + TERMINAL_FILE_TTL_MS
5539
+ };
5540
+ store.putFile(stored);
5541
+ terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
5542
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5092
5543
  } catch (err) {
5093
- const code = err instanceof FsError && err.code === "forbidden" ? 403 : 400;
5094
- reply.code(code).send({ error: err.message });
5544
+ reply.code(data.file.truncated ? 413 : 400).send({
5545
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5546
+ });
5547
+ }
5548
+ });
5549
+ app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
5550
+ const source = store.getFile(request.params.id, request.params.fileId);
5551
+ if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
5552
+ reply.code(404).send({ error: "source image not found" });
5553
+ return;
5554
+ }
5555
+ if (source.expiresAt <= Date.now()) {
5556
+ reply.code(410).send({ error: "source image has expired" });
5095
5557
  return;
5096
5558
  }
5097
- await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
5098
5559
  let data;
5099
5560
  try {
5100
5561
  data = await request.file();
@@ -5102,28 +5563,134 @@ function createServer(config, deps = {}) {
5102
5563
  reply.code(400).send({ error: err.message });
5103
5564
  return;
5104
5565
  }
5105
- if (!data) {
5106
- reply.code(400).send({ error: "no file field in the upload" });
5566
+ if (!data || !data.mimetype.startsWith("image/")) {
5567
+ reply.code(400).send({ error: "an edited image is required" });
5107
5568
  return;
5108
5569
  }
5109
- let buffer;
5570
+ const id = randomUUID4();
5110
5571
  try {
5111
- buffer = await data.toBuffer();
5572
+ const dir = await fsService.ensureDirWithinRoot(
5573
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
5574
+ );
5575
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
5576
+ const now = Date.now();
5577
+ const media = attachmentMedia(data.filename, data.mimetype);
5578
+ const stored = {
5579
+ id,
5580
+ sessionId: source.sessionId,
5581
+ direction: "sent",
5582
+ storage: "managed",
5583
+ name: data.filename,
5584
+ path: written.path,
5585
+ mimeType: media.mimeType,
5586
+ size: written.size,
5587
+ kind: "image",
5588
+ createdAt: now,
5589
+ updatedAt: now,
5590
+ expiresAt: now + TERMINAL_FILE_TTL_MS,
5591
+ derivedFromId: source.id
5592
+ };
5593
+ store.putFile(stored);
5594
+ terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
5595
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5112
5596
  } catch (err) {
5113
- reply.code(413).send({ error: err.message });
5597
+ reply.code(data.file.truncated ? 413 : 400).send({
5598
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5599
+ });
5600
+ }
5601
+ });
5602
+ app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
5603
+ const file = store.getFile(request.params.id, request.params.fileId);
5604
+ if (!file || file.hiddenAt !== void 0) {
5605
+ reply.code(404).send({ error: "file not found" });
5114
5606
  return;
5115
5607
  }
5116
- if (data.file.truncated) {
5117
- reply.code(413).send({ error: "file exceeds the upload size limit" });
5608
+ if (file.expiresAt <= Date.now()) {
5609
+ reply.code(410).send({ error: "file has expired" });
5610
+ return;
5611
+ }
5612
+ if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
5613
+ reply.code(409).send({ error: "only managed sent images can be replaced" });
5118
5614
  return;
5119
5615
  }
5616
+ let data;
5120
5617
  try {
5121
- const written = await fsService.writeUploadedFile(dir, data.filename, buffer);
5122
- reply.code(201).send({ path: written.path });
5618
+ data = await request.file();
5123
5619
  } catch (err) {
5124
5620
  reply.code(400).send({ error: err.message });
5621
+ return;
5622
+ }
5623
+ if (!data || !data.mimetype.startsWith("image/")) {
5624
+ reply.code(400).send({ error: "an image file is required" });
5625
+ return;
5626
+ }
5627
+ try {
5628
+ const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
5629
+ const now = Date.now();
5630
+ const updated = {
5631
+ ...file,
5632
+ mimeType: data.mimetype,
5633
+ size: written.size,
5634
+ updatedAt: now,
5635
+ expiresAt: now + TERMINAL_FILE_TTL_MS
5636
+ };
5637
+ store.putFile(updated);
5638
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
5639
+ reply.send({ file: publicSessionFile(updated) });
5640
+ } catch (err) {
5641
+ reply.code(data.file.truncated ? 413 : 400).send({
5642
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5643
+ });
5125
5644
  }
5126
5645
  });
5646
+ app.patch(
5647
+ "/sessions/:id/files/:fileId",
5648
+ async (request, reply) => {
5649
+ const file = store.getFile(request.params.id, request.params.fileId);
5650
+ if (!file) {
5651
+ reply.code(404).send({ error: "file not found" });
5652
+ return;
5653
+ }
5654
+ store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
5655
+ terminalManager.pushControl(file.sessionId, {
5656
+ t: "file",
5657
+ op: request.body?.hidden === false ? "restored" : "hidden",
5658
+ id: file.id
5659
+ });
5660
+ reply.code(204).send();
5661
+ }
5662
+ );
5663
+ app.delete(
5664
+ "/sessions/:id/files/:fileId",
5665
+ async (request, reply) => {
5666
+ const file = store.getFile(request.params.id, request.params.fileId);
5667
+ if (!file) {
5668
+ reply.code(204).send();
5669
+ return;
5670
+ }
5671
+ if (request.query.content === "true") {
5672
+ if (file.direction !== "sent" || file.storage !== "managed") {
5673
+ reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
5674
+ return;
5675
+ }
5676
+ try {
5677
+ await fsService.removeManagedPath(file.path);
5678
+ } catch (err) {
5679
+ if (!(err instanceof FsError && err.code === "not-found")) {
5680
+ reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
5681
+ error: "managed file could not be deleted"
5682
+ });
5683
+ return;
5684
+ }
5685
+ }
5686
+ store.deleteFile(file.sessionId, file.id);
5687
+ } else {
5688
+ store.setFileHidden(file.sessionId, file.id, Date.now());
5689
+ }
5690
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
5691
+ reply.code(204).send();
5692
+ }
5693
+ );
5127
5694
  if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
5128
5695
  app.addHook("onClose", async () => {
5129
5696
  clearInterval(sharedSweepTimer);
@@ -5154,10 +5721,10 @@ function createServer(config, deps = {}) {
5154
5721
  });
5155
5722
  return { app, authGate, terminalManager, terminalAvailable };
5156
5723
  }
5157
- function contentDisposition(filename) {
5724
+ function contentDisposition(filename, disposition = "attachment") {
5158
5725
  const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
5159
5726
  const encoded = encodeURIComponent(filename);
5160
- return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
5727
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
5161
5728
  }
5162
5729
 
5163
5730
  // src/server-config.ts
@@ -5540,7 +6107,7 @@ function createUsageService(opts) {
5540
6107
 
5541
6108
  // src/claude-auth-service.ts
5542
6109
  import { spawn as nodeSpawn2 } from "child_process";
5543
- import { randomUUID as randomUUID4 } from "crypto";
6110
+ import { randomUUID as randomUUID5 } from "crypto";
5544
6111
  function parseAuthStatus(stdout) {
5545
6112
  try {
5546
6113
  const o = JSON.parse(stdout);
@@ -5617,7 +6184,7 @@ var ClaudeAuthService = class {
5617
6184
  reject(err instanceof Error ? err : new Error("failed to spawn claude"));
5618
6185
  return;
5619
6186
  }
5620
- const id = randomUUID4();
6187
+ const id = randomUUID5();
5621
6188
  let settled = false;
5622
6189
  let buf = "";
5623
6190
  const onData = (d) => {
@@ -6979,7 +7546,7 @@ var CodexMetadataService = class {
6979
7546
 
6980
7547
  // src/providers/claude-metadata-service.ts
6981
7548
  import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
6982
- import { randomUUID as randomUUID5 } from "crypto";
7549
+ import { randomUUID as randomUUID6 } from "crypto";
6983
7550
  import { StringDecoder } from "string_decoder";
6984
7551
  var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
6985
7552
  var MAX_MODELS = 64;
@@ -7187,7 +7754,7 @@ function createClaudeMetadataRunner(options) {
7187
7754
  reject(metadataUnavailable2());
7188
7755
  return;
7189
7756
  }
7190
- const requestId = `roamcode-models-${randomUUID5()}`;
7757
+ const requestId = `roamcode-models-${randomUUID6()}`;
7191
7758
  const decoder = new StringDecoder("utf8");
7192
7759
  let stdoutBuffer = "";
7193
7760
  let outputBytes = 0;