@roamcode.ai/server 1.0.10 → 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/index.js CHANGED
@@ -287,9 +287,12 @@ var AuthGate = class {
287
287
  };
288
288
 
289
289
  // src/fs-service.ts
290
- import { readdir, readFile, stat, realpath, open, mkdir, unlink } from "fs/promises";
290
+ import { createWriteStream } from "fs";
291
+ import { readdir, readFile, stat, realpath, open, mkdir, unlink, rename, rmdir } from "fs/promises";
291
292
  import { constants } from "fs";
292
293
  import { resolve, join as join3, sep, basename } from "path";
294
+ import { randomUUID } from "crypto";
295
+ import { pipeline } from "stream/promises";
293
296
  var FsError = class extends Error {
294
297
  code;
295
298
  constructor(code, message) {
@@ -381,6 +384,14 @@ var FsService = class {
381
384
  const data = await readFile(real);
382
385
  return { filename: basename(file), data };
383
386
  }
387
+ /** Validate and describe a real in-root file without reading it into memory. */
388
+ async describeFile(target) {
389
+ const file = this.resolveWithinRoot(target);
390
+ const real = await this.realWithinRoot(file);
391
+ const info = await stat(real);
392
+ if (!info.isFile()) throw new FsError("not-found", `not a file: ${target}`);
393
+ return { path: real, filename: basename(file), size: info.size, mtimeMs: info.mtimeMs };
394
+ }
384
395
  /**
385
396
  * Validate that `target` is a real in-root file and describe it for an attachment frame WITHOUT
386
397
  * reading its bytes. Reuses the same resolveWithinRoot + realpath defense as readFileForDownload so
@@ -416,6 +427,95 @@ var FsService = class {
416
427
  }
417
428
  return { path: dest };
418
429
  }
430
+ /** Stream an upload to a same-directory partial and atomically publish it. The caller owns size limits;
431
+ * this method guarantees an interrupted upload never leaves a visible half-file. */
432
+ async writeUploadedStream(targetDir, filename, input, beforeCommit) {
433
+ if (!filename || filename.includes("/") || filename.includes("\\") || filename.includes(sep)) {
434
+ throw new Error(`invalid upload filename (no path separators allowed): ${filename}`);
435
+ }
436
+ const dir = this.resolveWithinRoot(targetDir);
437
+ await this.realWithinRoot(dir);
438
+ const dest = this.resolveWithinRoot(join3(dir, filename));
439
+ const partial = this.resolveWithinRoot(join3(dir, `.upload-${randomUUID()}.partial`));
440
+ try {
441
+ await pipeline(
442
+ input,
443
+ createWriteStream(partial, { flags: "wx", mode: 384 })
444
+ );
445
+ if (beforeCommit && !beforeCommit()) throw new Error("upload stream rejected before commit");
446
+ await rename(partial, dest);
447
+ const info = await stat(dest);
448
+ return { path: dest, size: info.size };
449
+ } catch (err) {
450
+ await unlink(partial).catch(() => void 0);
451
+ throw err;
452
+ }
453
+ }
454
+ /** Atomically replace one existing in-root managed file with streamed bytes. */
455
+ async replaceFileStream(target, input, beforeCommit) {
456
+ const file = this.resolveWithinRoot(target);
457
+ const real = await this.realWithinRoot(file);
458
+ const parent = resolve(real, "..");
459
+ await this.realWithinRoot(parent);
460
+ const partial = join3(parent, `.edit-${randomUUID()}.partial`);
461
+ try {
462
+ await pipeline(
463
+ input,
464
+ createWriteStream(partial, { flags: "wx", mode: 384 })
465
+ );
466
+ if (beforeCommit && !beforeCommit()) throw new Error("replacement stream rejected before commit");
467
+ await rename(partial, real);
468
+ const info = await stat(real);
469
+ return { path: real, size: info.size };
470
+ } catch (err) {
471
+ await unlink(partial).catch(() => void 0);
472
+ throw err;
473
+ }
474
+ }
475
+ /** Best-effort removal used only for server-owned managed attachment copies. */
476
+ async removeManagedPath(target) {
477
+ const file = this.resolveWithinRoot(target);
478
+ const real = await this.realWithinRoot(file);
479
+ await unlink(real);
480
+ await rmdir(resolve(real, "..")).catch(() => void 0);
481
+ }
482
+ /** Discover regular files in an app-owned directory for one-time legacy attachment backfill. Symlinks
483
+ * are ignored and traversal is deliberately shallow: old uploads are direct children, current uploads
484
+ * add exactly one id directory. */
485
+ async discoverManagedFiles(target, maxDepth = 1) {
486
+ let root;
487
+ try {
488
+ root = this.resolveWithinRoot(target);
489
+ await this.realWithinRoot(root);
490
+ } catch {
491
+ return [];
492
+ }
493
+ const found = [];
494
+ const walk = async (dir, depth) => {
495
+ let entries;
496
+ try {
497
+ entries = await readdir(dir, { withFileTypes: true });
498
+ } catch {
499
+ return;
500
+ }
501
+ for (const entry of entries) {
502
+ if (entry.isSymbolicLink() || entry.name.endsWith(".partial")) continue;
503
+ const path = join3(dir, entry.name);
504
+ if (entry.isDirectory()) {
505
+ if (depth < maxDepth) await walk(path, depth + 1);
506
+ continue;
507
+ }
508
+ if (!entry.isFile()) continue;
509
+ try {
510
+ const info = await stat(path);
511
+ found.push({ path, filename: entry.name, size: info.size, mtimeMs: info.mtimeMs });
512
+ } catch {
513
+ }
514
+ }
515
+ };
516
+ await walk(root, 0);
517
+ return found;
518
+ }
419
519
  /** Ensure a directory (confined to root) exists, creating parents as needed; returns its absolute path. */
420
520
  async ensureDirWithinRoot(target) {
421
521
  const dir = this.resolveWithinRoot(target);
@@ -505,30 +605,35 @@ var FsService = class {
505
605
  let removed = 0;
506
606
  for (const e of entries) {
507
607
  if (!e.isDirectory()) continue;
508
- removed += await this.pruneOlderThan(join3(dir, e.name), maxAgeMs, now);
608
+ removed += await this.pruneOlderThan(join3(dir, e.name), maxAgeMs, now, true);
509
609
  }
510
610
  return removed;
511
611
  }
512
612
  /** Delete top-level regular files in `target` (confined to root) whose mtime is older than `maxAgeMs`.
513
613
  * Best-effort — returns how many were removed; a missing dir / unreadable entry is skipped, not thrown.
514
614
  * Subdirectories are left untouched. Used to give terminal shared-files uploads a bounded lifetime. */
515
- async pruneOlderThan(target, maxAgeMs, now = Date.now()) {
615
+ async pruneOlderThan(target, maxAgeMs, now = Date.now(), recursive = false) {
516
616
  let dir;
517
617
  try {
518
618
  dir = this.resolveWithinRoot(target);
519
619
  } catch {
520
620
  return 0;
521
621
  }
522
- let names;
622
+ let entries;
523
623
  try {
524
- names = await readdir(dir);
624
+ entries = await readdir(dir, { withFileTypes: true });
525
625
  } catch {
526
626
  return 0;
527
627
  }
528
628
  let removed = 0;
529
- for (const name of names) {
530
- const p = join3(dir, name);
629
+ for (const entry of entries) {
630
+ const p = join3(dir, entry.name);
531
631
  try {
632
+ if (recursive && entry.isDirectory()) {
633
+ removed += await this.pruneOlderThan(p, maxAgeMs, now, true);
634
+ await rmdir(p).catch(() => void 0);
635
+ continue;
636
+ }
532
637
  const s = await stat(p);
533
638
  if (s.isFile() && now - s.mtimeMs > maxAgeMs) {
534
639
  await unlink(p);
@@ -745,6 +850,25 @@ function brandSessionStore(store) {
745
850
  concreteSessionStores.add(store);
746
851
  return store;
747
852
  }
853
+ function sessionFileRowToStored(row) {
854
+ return {
855
+ id: row.id,
856
+ sessionId: row.session_id,
857
+ direction: row.direction,
858
+ storage: row.storage,
859
+ name: row.name,
860
+ path: row.path,
861
+ mimeType: row.mime_type,
862
+ size: row.size,
863
+ kind: row.kind,
864
+ ...row.caption ? { caption: row.caption } : {},
865
+ createdAt: row.created_at,
866
+ updatedAt: row.updated_at,
867
+ expiresAt: row.expires_at,
868
+ ...row.derived_from_id ? { derivedFromId: row.derived_from_id } : {},
869
+ ...row.hidden_at !== null ? { hiddenAt: row.hidden_at } : {}
870
+ };
871
+ }
748
872
  function parseSpawnArgs(raw) {
749
873
  if (typeof raw !== "string" || raw.length === 0) return void 0;
750
874
  try {
@@ -853,6 +977,9 @@ function cloneStoredSessionDefaults(value) {
853
977
  updatedAt: value.updatedAt
854
978
  };
855
979
  }
980
+ function cloneStoredSessionFile(value) {
981
+ return { ...value };
982
+ }
856
983
  function appSettingRowToSessionDefaults(row) {
857
984
  if (!row) return void 0;
858
985
  try {
@@ -867,6 +994,7 @@ function appSettingRowToSessionDefaults(row) {
867
994
  }
868
995
  function inMemoryStore() {
869
996
  const map = /* @__PURE__ */ new Map();
997
+ const files = /* @__PURE__ */ new Map();
870
998
  const provisionalProviderSessionIds = /* @__PURE__ */ new Map();
871
999
  let sessionDefaults;
872
1000
  const write = (s) => {
@@ -965,13 +1093,38 @@ function inMemoryStore() {
965
1093
  };
966
1094
  return cloneStoredSessionDefaults(sessionDefaults);
967
1095
  },
1096
+ putFile: (file) => files.set(`${file.sessionId}:${file.id}`, cloneStoredSessionFile(file)),
1097
+ getFile: (sessionId, id) => {
1098
+ const value = files.get(`${sessionId}:${id}`);
1099
+ return value ? cloneStoredSessionFile(value) : void 0;
1100
+ },
1101
+ 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),
1102
+ setFileHidden: (sessionId, id, hiddenAt) => {
1103
+ const file = files.get(`${sessionId}:${id}`);
1104
+ if (!file) return;
1105
+ if (hiddenAt === void 0) delete file.hiddenAt;
1106
+ else file.hiddenAt = hiddenAt;
1107
+ file.updatedAt = Date.now();
1108
+ },
1109
+ deleteFile: (sessionId, id) => void files.delete(`${sessionId}:${id}`),
1110
+ pruneFiles: (expiredBefore) => {
1111
+ const removed = [];
1112
+ for (const [key, file] of files) {
1113
+ if (file.expiresAt > expiredBefore) continue;
1114
+ removed.push(cloneStoredSessionFile(file));
1115
+ files.delete(key);
1116
+ }
1117
+ return removed;
1118
+ },
968
1119
  delete: (id) => {
969
1120
  provisionalProviderSessionIds.delete(id);
970
1121
  map.delete(id);
1122
+ for (const [key, file] of files) if (file.sessionId === id) files.delete(key);
971
1123
  },
972
1124
  close: () => {
973
1125
  provisionalProviderSessionIds.clear();
974
1126
  map.clear();
1127
+ files.clear();
975
1128
  sessionDefaults = void 0;
976
1129
  },
977
1130
  mode: "memory-fallback"
@@ -1049,6 +1202,28 @@ function openSessionStore(opts) {
1049
1202
  revision INTEGER NOT NULL CHECK (revision > 0),
1050
1203
  updated_at INTEGER NOT NULL
1051
1204
  );
1205
+
1206
+ CREATE TABLE IF NOT EXISTS session_files (
1207
+ id TEXT NOT NULL,
1208
+ session_id TEXT NOT NULL,
1209
+ direction TEXT NOT NULL CHECK (direction IN ('sent', 'received')),
1210
+ storage TEXT NOT NULL CHECK (storage IN ('managed', 'workspace')),
1211
+ name TEXT NOT NULL,
1212
+ path TEXT NOT NULL,
1213
+ mime_type TEXT NOT NULL,
1214
+ size INTEGER NOT NULL,
1215
+ kind TEXT NOT NULL CHECK (kind IN ('image', 'pdf', 'text', 'binary')),
1216
+ caption TEXT,
1217
+ created_at INTEGER NOT NULL,
1218
+ updated_at INTEGER NOT NULL,
1219
+ expires_at INTEGER NOT NULL,
1220
+ derived_from_id TEXT,
1221
+ hidden_at INTEGER,
1222
+ PRIMARY KEY (session_id, id)
1223
+ );
1224
+ CREATE INDEX IF NOT EXISTS session_files_session_created_idx
1225
+ ON session_files(session_id, created_at DESC);
1226
+ CREATE INDEX IF NOT EXISTS session_files_expiry_idx ON session_files(expires_at);
1052
1227
  `);
1053
1228
  try {
1054
1229
  db.exec("ALTER TABLE provider_sessions ADD COLUMN provisional_provider_session_id TEXT");
@@ -1128,6 +1303,33 @@ function openSessionStore(opts) {
1128
1303
  ON CONFLICT(key) DO UPDATE SET
1129
1304
  value_json=excluded.value_json, revision=excluded.revision, updated_at=excluded.updated_at
1130
1305
  `);
1306
+ const filePutStmt = db.prepare(`
1307
+ INSERT INTO session_files (
1308
+ id, session_id, direction, storage, name, path, mime_type, size, kind, caption,
1309
+ created_at, updated_at, expires_at, derived_from_id, hidden_at
1310
+ ) VALUES (
1311
+ @id, @session_id, @direction, @storage, @name, @path, @mime_type, @size, @kind, @caption,
1312
+ @created_at, @updated_at, @expires_at, @derived_from_id, @hidden_at
1313
+ ) ON CONFLICT(session_id, id) DO UPDATE SET
1314
+ direction=excluded.direction, storage=excluded.storage, name=excluded.name, path=excluded.path,
1315
+ mime_type=excluded.mime_type, size=excluded.size, kind=excluded.kind, caption=excluded.caption,
1316
+ updated_at=excluded.updated_at, expires_at=excluded.expires_at,
1317
+ derived_from_id=excluded.derived_from_id, hidden_at=excluded.hidden_at
1318
+ `);
1319
+ const fileGetStmt = db.prepare("SELECT * FROM session_files WHERE session_id = ? AND id = ?");
1320
+ const fileListStmt = db.prepare(
1321
+ "SELECT * FROM session_files WHERE session_id = ? AND hidden_at IS NULL ORDER BY created_at DESC, id DESC"
1322
+ );
1323
+ const fileListAllStmt = db.prepare(
1324
+ "SELECT * FROM session_files WHERE session_id = ? ORDER BY created_at DESC, id DESC"
1325
+ );
1326
+ const fileHideStmt = db.prepare(
1327
+ "UPDATE session_files SET hidden_at = ?, updated_at = ? WHERE session_id = ? AND id = ?"
1328
+ );
1329
+ const fileDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ? AND id = ?");
1330
+ const filesForSessionDeleteStmt = db.prepare("DELETE FROM session_files WHERE session_id = ?");
1331
+ const expiredFilesStmt = db.prepare("SELECT * FROM session_files WHERE expires_at <= ?");
1332
+ const pruneFilesStmt = db.prepare("DELETE FROM session_files WHERE expires_at <= ?");
1131
1333
  const ownerOf = (id) => {
1132
1334
  const hasLegacy = legacyGetStmt.get(id) !== void 0;
1133
1335
  const hasProvider = providerGetStmt.get(id) !== void 0;
@@ -1304,10 +1506,45 @@ function openSessionStore(opts) {
1304
1506
  },
1305
1507
  getSessionDefaults: () => appSettingRowToSessionDefaults(sessionDefaultsGetStmt.get()),
1306
1508
  putSessionDefaults: (defaults, expectedRevision, updatedAt) => putSessionDefaultsAtomically.immediate(normalizeSessionDefaults(defaults), expectedRevision, updatedAt),
1509
+ putFile: (file) => {
1510
+ filePutStmt.run({
1511
+ id: file.id,
1512
+ session_id: file.sessionId,
1513
+ direction: file.direction,
1514
+ storage: file.storage,
1515
+ name: file.name,
1516
+ path: file.path,
1517
+ mime_type: file.mimeType,
1518
+ size: file.size,
1519
+ kind: file.kind,
1520
+ caption: file.caption ?? null,
1521
+ created_at: file.createdAt,
1522
+ updated_at: file.updatedAt,
1523
+ expires_at: file.expiresAt,
1524
+ derived_from_id: file.derivedFromId ?? null,
1525
+ hidden_at: file.hiddenAt ?? null
1526
+ });
1527
+ },
1528
+ getFile: (sessionId, id) => {
1529
+ const row = fileGetStmt.get(sessionId, id);
1530
+ return row ? sessionFileRowToStored(row) : void 0;
1531
+ },
1532
+ listFiles: (sessionId, includeHidden = false) => {
1533
+ const rows = includeHidden ? fileListAllStmt.all(sessionId) : fileListStmt.all(sessionId);
1534
+ return rows.map(sessionFileRowToStored);
1535
+ },
1536
+ setFileHidden: (sessionId, id, hiddenAt) => fileHideStmt.run(hiddenAt ?? null, Date.now(), sessionId, id),
1537
+ deleteFile: (sessionId, id) => void fileDeleteStmt.run(sessionId, id),
1538
+ pruneFiles: (expiredBefore) => {
1539
+ const rows = expiredFilesStmt.all(expiredBefore);
1540
+ pruneFilesStmt.run(expiredBefore);
1541
+ return rows.map(sessionFileRowToStored);
1542
+ },
1307
1543
  delete: (id) => {
1308
1544
  const owner = ownerOf(id);
1309
1545
  if (owner === "claude") legacyDeleteStmt.run(id);
1310
1546
  else if (owner === "codex") providerDeleteStmt.run(id);
1547
+ filesForSessionDeleteStmt.run(id);
1311
1548
  },
1312
1549
  close: () => db.close(),
1313
1550
  mode: "sqlite"
@@ -1394,8 +1631,9 @@ function resolveVapidKeys(opts) {
1394
1631
  }
1395
1632
 
1396
1633
  // src/transport.ts
1397
- import { randomUUID as randomUUID3 } from "crypto";
1634
+ import { randomUUID as randomUUID4 } from "crypto";
1398
1635
  import { basename as pathBasename } from "path";
1636
+ import { createReadStream } from "fs";
1399
1637
  import Fastify from "fastify";
1400
1638
  import websocket from "@fastify/websocket";
1401
1639
  import multipart from "@fastify/multipart";
@@ -1548,7 +1786,7 @@ function terminalSharedDir(opts) {
1548
1786
 
1549
1787
  // src/updater.ts
1550
1788
  import { spawn as nodeSpawn } from "child_process";
1551
- import { randomUUID as randomUUID2 } from "crypto";
1789
+ import { randomUUID as randomUUID3 } from "crypto";
1552
1790
  import {
1553
1791
  chmodSync as nodeChmodSync,
1554
1792
  existsSync as nodeExistsSync,
@@ -1562,7 +1800,7 @@ import { fileURLToPath } from "url";
1562
1800
 
1563
1801
  // src/managed-runtime.ts
1564
1802
  import { spawn } from "child_process";
1565
- import { randomUUID } from "crypto";
1803
+ import { randomUUID as randomUUID2 } from "crypto";
1566
1804
  import {
1567
1805
  chmodSync as chmodSync4,
1568
1806
  existsSync as existsSync4,
@@ -1839,7 +2077,7 @@ function compareVersions(a, b) {
1839
2077
  }
1840
2078
  function atomicWrite(path, value, mode = 384) {
1841
2079
  mkdirSync3(dirname2(path), { recursive: true, mode: 448 });
1842
- const temp = `${path}.${process.pid}.${randomUUID()}.tmp`;
2080
+ const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
1843
2081
  writeFileSync4(temp, value, { mode });
1844
2082
  chmodSync4(temp, mode);
1845
2083
  renameSync(temp, path);
@@ -1983,7 +2221,7 @@ async function npmIntegrity(npmCommand, packageName, version, nodePath, log) {
1983
2221
  return parsed;
1984
2222
  }
1985
2223
  async function smokeServer(serverEntry, dataDir, nodePath, log) {
1986
- const smokeDir = join7(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID()}`);
2224
+ const smokeDir = join7(tmpdir(), `roamcode-smoke-${process.pid}-${randomUUID2()}`);
1987
2225
  mkdirSync3(smokeDir, { recursive: true, mode: 448 });
1988
2226
  let output = "";
1989
2227
  const child = spawn(nodePath, [serverEntry], {
@@ -1991,7 +2229,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
1991
2229
  ...process.env,
1992
2230
  PORT: "0",
1993
2231
  BIND_ADDRESS: "127.0.0.1",
1994
- ACCESS_TOKEN: `rc-smoke-${randomUUID()}`,
2232
+ ACCESS_TOKEN: `rc-smoke-${randomUUID2()}`,
1995
2233
  ROAMCODE_DATA_DIR: smokeDir,
1996
2234
  RC_TMUX_SOCKET: `rc-smoke-${process.pid}`,
1997
2235
  ROAMCODE_INSTALL_ROOT: ""
@@ -2031,7 +2269,7 @@ async function smokeServer(serverEntry, dataDir, nodePath, log) {
2031
2269
  }
2032
2270
  }
2033
2271
  function replaceSymlink(link, target) {
2034
- const temp = `${link}.${process.pid}.${randomUUID()}.new`;
2272
+ const temp = `${link}.${process.pid}.${randomUUID2()}.new`;
2035
2273
  try {
2036
2274
  unlinkSync(temp);
2037
2275
  } catch {
@@ -2047,7 +2285,7 @@ function trimLog(log) {
2047
2285
  async function installManagedRelease(opts) {
2048
2286
  if (!isStableVersion(opts.version)) throw new Error(`invalid release version: ${opts.version}`);
2049
2287
  const now = opts.now ?? Date.now;
2050
- const operationId = opts.operationId ?? randomUUID();
2288
+ const operationId = opts.operationId ?? randomUUID2();
2051
2289
  const nodePath = opts.nodePath ?? process.execPath;
2052
2290
  const npmCommand = opts.npmCommand ?? process.env.npm_execpath ?? "npm";
2053
2291
  const expectedIntegrities = opts.expectedIntegrities ?? (opts.expectedIntegrity ? { roamcode: opts.expectedIntegrity } : {});
@@ -2056,7 +2294,7 @@ async function installManagedRelease(opts) {
2056
2294
  mkdirSync3(paths.releases, { recursive: true, mode: 448 });
2057
2295
  mkdirSync3(paths.staging, { recursive: true, mode: 448 });
2058
2296
  const release = join7(paths.releases, opts.version);
2059
- const stage = join7(paths.staging, `${opts.version}-${process.pid}-${randomUUID()}`);
2297
+ const stage = join7(paths.staging, `${opts.version}-${process.pid}-${randomUUID2()}`);
2060
2298
  let logText = "";
2061
2299
  const log = (line) => {
2062
2300
  logText += line.endsWith("\n") ? line : `${line}
@@ -2191,7 +2429,7 @@ async function installManagedRelease(opts) {
2191
2429
  }
2192
2430
 
2193
2431
  // src/updater.ts
2194
- var RUNNING_VERSION = "1.0.10" ? "1.0.10".replace(/^v/, "") : packageVersion();
2432
+ var RUNNING_VERSION = "1.0.11" ? "1.0.11".replace(/^v/, "") : packageVersion();
2195
2433
  var RUNNING_BUILD = RUNNING_VERSION;
2196
2434
  var RELEASES_API = "https://api.github.com/repos/burakgon/roamcode/releases?per_page=100";
2197
2435
  var RELEASE_MANIFEST_ASSET = "roamcode-release.json";
@@ -2492,7 +2730,7 @@ var Updater = class {
2492
2730
  const path = join8(this.dataDir, "update-status.json");
2493
2731
  const value = JSON.stringify(status, null, 2) + "\n";
2494
2732
  if (this.fs.renameSync) {
2495
- const temp = `${path}.${process.pid}.${randomUUID2()}.tmp`;
2733
+ const temp = `${path}.${process.pid}.${randomUUID3()}.tmp`;
2496
2734
  this.fs.writeFileSync(temp, value, 384);
2497
2735
  this.fs.renameSync(temp, path);
2498
2736
  } else {
@@ -2555,7 +2793,7 @@ var Updater = class {
2555
2793
  return { started: false, reason: error instanceof Error ? error.message : String(error) };
2556
2794
  }
2557
2795
  }
2558
- const operationId = randomUUID2();
2796
+ const operationId = randomUUID3();
2559
2797
  const status = {
2560
2798
  operationId,
2561
2799
  state: "starting",
@@ -4617,6 +4855,74 @@ var MAX_PENDING_TERMINAL_INPUT_FRAMES = 64;
4617
4855
  var MAX_PENDING_TERMINAL_INPUT_BYTES = 1e6;
4618
4856
  var MAX_TERMINAL_WS_BUFFER = 16e6;
4619
4857
  var TERMINAL_WS_PING_MS = 25e3;
4858
+ var TEXT_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4859
+ "txt",
4860
+ "md",
4861
+ "markdown",
4862
+ "json",
4863
+ "jsonl",
4864
+ "yaml",
4865
+ "yml",
4866
+ "toml",
4867
+ "xml",
4868
+ "csv",
4869
+ "tsv",
4870
+ "log",
4871
+ "js",
4872
+ "jsx",
4873
+ "ts",
4874
+ "tsx",
4875
+ "css",
4876
+ "scss",
4877
+ "html",
4878
+ "htm",
4879
+ "sql",
4880
+ "sh",
4881
+ "bash",
4882
+ "zsh",
4883
+ "py",
4884
+ "rb",
4885
+ "go",
4886
+ "rs",
4887
+ "java",
4888
+ "kt",
4889
+ "swift",
4890
+ "c",
4891
+ "h",
4892
+ "cpp",
4893
+ "hpp",
4894
+ "env",
4895
+ "ini",
4896
+ "conf"
4897
+ ]);
4898
+ var IMAGE_FILE_EXTENSIONS = /* @__PURE__ */ new Set([
4899
+ "png",
4900
+ "jpg",
4901
+ "jpeg",
4902
+ "gif",
4903
+ "webp",
4904
+ "svg",
4905
+ "bmp",
4906
+ "avif",
4907
+ "apng",
4908
+ "heic",
4909
+ "heif"
4910
+ ]);
4911
+ function attachmentMedia(filename, declared = "application/octet-stream") {
4912
+ const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
4913
+ if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
4914
+ const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
4915
+ return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
4916
+ }
4917
+ if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
4918
+ if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
4919
+ return { mimeType: "text/plain; charset=utf-8", kind: "text" };
4920
+ }
4921
+ return { mimeType: declared || "application/octet-stream", kind: "binary" };
4922
+ }
4923
+ function publicSessionFile(file, available = true) {
4924
+ return { ...file, isImage: file.kind === "image", available };
4925
+ }
4620
4926
  function isDisallowedPushHost(hostname) {
4621
4927
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
4622
4928
  if (host === "localhost" || host.endsWith(".localhost")) return true;
@@ -4672,8 +4978,44 @@ function createServer(config, deps = {}) {
4672
4978
  });
4673
4979
  const fsService = new FsService({ root: config.fsRoot });
4674
4980
  const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
4981
+ const backfilledFileSessions = /* @__PURE__ */ new Set();
4982
+ const backfillManagedFiles = async (sessionId) => {
4983
+ if (backfilledFileSessions.has(sessionId)) return;
4984
+ backfilledFileSessions.add(sessionId);
4985
+ const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
4986
+ const discovered = await fsService.discoverManagedFiles(sessionDir);
4987
+ const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
4988
+ for (const file of discovered) {
4989
+ if (knownPaths.has(file.path)) continue;
4990
+ const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
4991
+ if (expiresAt <= Date.now()) continue;
4992
+ const now = Date.now();
4993
+ const media = attachmentMedia(file.filename);
4994
+ store.putFile({
4995
+ id: randomUUID4(),
4996
+ sessionId,
4997
+ direction: "sent",
4998
+ storage: "managed",
4999
+ name: file.filename,
5000
+ path: file.path,
5001
+ mimeType: media.mimeType,
5002
+ size: file.size,
5003
+ kind: media.kind,
5004
+ createdAt: file.mtimeMs,
5005
+ updatedAt: now,
5006
+ expiresAt
5007
+ });
5008
+ knownPaths.add(file.path);
5009
+ }
5010
+ };
4675
5011
  const sweepSharedFiles = () => {
4676
- void fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
5012
+ void (async () => {
5013
+ const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
5014
+ await Promise.all(
5015
+ expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
5016
+ );
5017
+ await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
5018
+ })();
4677
5019
  };
4678
5020
  sweepSharedFiles();
4679
5021
  const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
@@ -4918,7 +5260,7 @@ function createServer(config, deps = {}) {
4918
5260
  reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
4919
5261
  return;
4920
5262
  }
4921
- const id = randomUUID3();
5263
+ const id = randomUUID4();
4922
5264
  const rawOptions = body.options ?? (provider === "claude" ? {
4923
5265
  ...typeof body.model === "string" ? { model: body.model } : {},
4924
5266
  ...typeof body.effort === "string" ? { effort: body.effort } : {},
@@ -5142,8 +5484,10 @@ function createServer(config, deps = {}) {
5142
5484
  }
5143
5485
  const caption = typeof body.caption === "string" ? body.caption : void 0;
5144
5486
  let described;
5487
+ let fileInfo;
5145
5488
  try {
5146
5489
  described = await fsService.describeForAttachment(body.path);
5490
+ fileInfo = await fsService.describeFile(body.path);
5147
5491
  } catch (err) {
5148
5492
  if (err instanceof FsError) {
5149
5493
  reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
@@ -5153,14 +5497,28 @@ function createServer(config, deps = {}) {
5153
5497
  return;
5154
5498
  }
5155
5499
  const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
5156
- const id = randomUUID3();
5157
- terminalManager.pushControl(sessionId, {
5158
- t: "attach",
5500
+ const id = randomUUID4();
5501
+ const now = Date.now();
5502
+ const media = attachmentMedia(described.name);
5503
+ const stored = {
5159
5504
  id,
5160
- path: body.path,
5505
+ sessionId,
5506
+ direction: "received",
5507
+ storage: "workspace",
5161
5508
  name: described.name,
5162
- caption,
5163
- isImage
5509
+ path: body.path,
5510
+ mimeType: media.mimeType,
5511
+ size: fileInfo.size,
5512
+ kind: isImage ? "image" : media.kind,
5513
+ ...caption ? { caption } : {},
5514
+ createdAt: now,
5515
+ updatedAt: now,
5516
+ expiresAt: now + TERMINAL_FILE_TTL_MS
5517
+ };
5518
+ store.putFile(stored);
5519
+ terminalManager.pushControl(sessionId, {
5520
+ t: "attach",
5521
+ ...publicSessionFile(stored)
5164
5522
  });
5165
5523
  dispatchPush({ kind: "file", sessionId, detail: described.name });
5166
5524
  reply.code(200).send({ ok: true, id });
@@ -5691,23 +6049,126 @@ function createServer(config, deps = {}) {
5691
6049
  reply.code(400).send({ error: err.message });
5692
6050
  }
5693
6051
  });
6052
+ app.get("/sessions/:id/files", async (request, reply) => {
6053
+ if (!terminalManager.get(request.params.id)) {
6054
+ reply.code(404).send({ error: "terminal session not found" });
6055
+ return;
6056
+ }
6057
+ await backfillManagedFiles(request.params.id);
6058
+ const files = await Promise.all(
6059
+ store.listFiles(request.params.id).map(async (file) => {
6060
+ const available = file.expiresAt > Date.now() && await fsService.describeFile(file.path).then(() => true).catch(() => false);
6061
+ return publicSessionFile(file, available);
6062
+ })
6063
+ );
6064
+ return {
6065
+ files,
6066
+ policy: {
6067
+ maxUploadBytes: config.maxUploadBytes,
6068
+ retentionMs: TERMINAL_FILE_TTL_MS,
6069
+ durable: store.mode === "sqlite"
6070
+ }
6071
+ };
6072
+ });
6073
+ app.get(
6074
+ "/sessions/:id/files/:fileId/content",
6075
+ async (request, reply) => {
6076
+ const file = store.getFile(request.params.id, request.params.fileId);
6077
+ if (!file || file.hiddenAt !== void 0) {
6078
+ reply.code(404).send({ error: "file not found" });
6079
+ return;
6080
+ }
6081
+ if (file.expiresAt <= Date.now()) {
6082
+ reply.code(410).send({ error: "file has expired" });
6083
+ return;
6084
+ }
6085
+ try {
6086
+ const info = await fsService.describeFile(file.path);
6087
+ const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
6088
+ reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
6089
+ "content-security-policy",
6090
+ "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
6091
+ ).header("content-disposition", contentDisposition(info.filename, disposition));
6092
+ const range = request.headers.range;
6093
+ if (range) {
6094
+ const match = /^bytes=(\d+)-(\d*)$/.exec(range);
6095
+ if (!match) {
6096
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
6097
+ return;
6098
+ }
6099
+ const start = Number(match[1]);
6100
+ const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
6101
+ if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
6102
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
6103
+ return;
6104
+ }
6105
+ 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 }));
6106
+ }
6107
+ return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
6108
+ } catch (err) {
6109
+ const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
6110
+ reply.code(code).send({ error: err.message });
6111
+ }
6112
+ }
6113
+ );
5694
6114
  app.post("/sessions/:id/upload", async (request, reply) => {
5695
- const meta = terminalManager.get(request.params.id);
5696
- if (!meta) {
6115
+ const sessionId = request.params.id;
6116
+ if (!terminalManager.get(sessionId)) {
5697
6117
  reply.code(404).send({ error: "terminal session not found" });
5698
6118
  return;
5699
6119
  }
5700
- let dir;
6120
+ let data;
6121
+ try {
6122
+ data = await request.file();
6123
+ } catch (err) {
6124
+ reply.code(400).send({ error: err.message });
6125
+ return;
6126
+ }
6127
+ if (!data) {
6128
+ reply.code(400).send({ error: "no file field in the upload" });
6129
+ return;
6130
+ }
6131
+ const id = randomUUID4();
5701
6132
  try {
5702
- dir = await fsService.ensureDirWithinRoot(
5703
- terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: meta.id })
6133
+ const dir = await fsService.ensureDirWithinRoot(
6134
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
5704
6135
  );
6136
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
6137
+ const now = Date.now();
6138
+ const media = attachmentMedia(data.filename, data.mimetype);
6139
+ const stored = {
6140
+ id,
6141
+ sessionId,
6142
+ direction: "sent",
6143
+ storage: "managed",
6144
+ name: data.filename,
6145
+ path: written.path,
6146
+ mimeType: media.mimeType,
6147
+ size: written.size,
6148
+ kind: media.kind,
6149
+ createdAt: now,
6150
+ updatedAt: now,
6151
+ expiresAt: now + TERMINAL_FILE_TTL_MS
6152
+ };
6153
+ store.putFile(stored);
6154
+ terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
6155
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5705
6156
  } catch (err) {
5706
- const code = err instanceof FsError && err.code === "forbidden" ? 403 : 400;
5707
- reply.code(code).send({ error: err.message });
6157
+ reply.code(data.file.truncated ? 413 : 400).send({
6158
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
6159
+ });
6160
+ }
6161
+ });
6162
+ app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
6163
+ const source = store.getFile(request.params.id, request.params.fileId);
6164
+ if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
6165
+ reply.code(404).send({ error: "source image not found" });
6166
+ return;
6167
+ }
6168
+ if (source.expiresAt <= Date.now()) {
6169
+ reply.code(410).send({ error: "source image has expired" });
5708
6170
  return;
5709
6171
  }
5710
- await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
5711
6172
  let data;
5712
6173
  try {
5713
6174
  data = await request.file();
@@ -5715,28 +6176,134 @@ function createServer(config, deps = {}) {
5715
6176
  reply.code(400).send({ error: err.message });
5716
6177
  return;
5717
6178
  }
5718
- if (!data) {
5719
- reply.code(400).send({ error: "no file field in the upload" });
6179
+ if (!data || !data.mimetype.startsWith("image/")) {
6180
+ reply.code(400).send({ error: "an edited image is required" });
5720
6181
  return;
5721
6182
  }
5722
- let buffer;
6183
+ const id = randomUUID4();
5723
6184
  try {
5724
- buffer = await data.toBuffer();
6185
+ const dir = await fsService.ensureDirWithinRoot(
6186
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
6187
+ );
6188
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
6189
+ const now = Date.now();
6190
+ const media = attachmentMedia(data.filename, data.mimetype);
6191
+ const stored = {
6192
+ id,
6193
+ sessionId: source.sessionId,
6194
+ direction: "sent",
6195
+ storage: "managed",
6196
+ name: data.filename,
6197
+ path: written.path,
6198
+ mimeType: media.mimeType,
6199
+ size: written.size,
6200
+ kind: "image",
6201
+ createdAt: now,
6202
+ updatedAt: now,
6203
+ expiresAt: now + TERMINAL_FILE_TTL_MS,
6204
+ derivedFromId: source.id
6205
+ };
6206
+ store.putFile(stored);
6207
+ terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
6208
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5725
6209
  } catch (err) {
5726
- reply.code(413).send({ error: err.message });
6210
+ reply.code(data.file.truncated ? 413 : 400).send({
6211
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
6212
+ });
6213
+ }
6214
+ });
6215
+ app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
6216
+ const file = store.getFile(request.params.id, request.params.fileId);
6217
+ if (!file || file.hiddenAt !== void 0) {
6218
+ reply.code(404).send({ error: "file not found" });
5727
6219
  return;
5728
6220
  }
5729
- if (data.file.truncated) {
5730
- reply.code(413).send({ error: "file exceeds the upload size limit" });
6221
+ if (file.expiresAt <= Date.now()) {
6222
+ reply.code(410).send({ error: "file has expired" });
6223
+ return;
6224
+ }
6225
+ if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
6226
+ reply.code(409).send({ error: "only managed sent images can be replaced" });
5731
6227
  return;
5732
6228
  }
6229
+ let data;
5733
6230
  try {
5734
- const written = await fsService.writeUploadedFile(dir, data.filename, buffer);
5735
- reply.code(201).send({ path: written.path });
6231
+ data = await request.file();
5736
6232
  } catch (err) {
5737
6233
  reply.code(400).send({ error: err.message });
6234
+ return;
6235
+ }
6236
+ if (!data || !data.mimetype.startsWith("image/")) {
6237
+ reply.code(400).send({ error: "an image file is required" });
6238
+ return;
6239
+ }
6240
+ try {
6241
+ const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
6242
+ const now = Date.now();
6243
+ const updated = {
6244
+ ...file,
6245
+ mimeType: data.mimetype,
6246
+ size: written.size,
6247
+ updatedAt: now,
6248
+ expiresAt: now + TERMINAL_FILE_TTL_MS
6249
+ };
6250
+ store.putFile(updated);
6251
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
6252
+ reply.send({ file: publicSessionFile(updated) });
6253
+ } catch (err) {
6254
+ reply.code(data.file.truncated ? 413 : 400).send({
6255
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
6256
+ });
5738
6257
  }
5739
6258
  });
6259
+ app.patch(
6260
+ "/sessions/:id/files/:fileId",
6261
+ async (request, reply) => {
6262
+ const file = store.getFile(request.params.id, request.params.fileId);
6263
+ if (!file) {
6264
+ reply.code(404).send({ error: "file not found" });
6265
+ return;
6266
+ }
6267
+ store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
6268
+ terminalManager.pushControl(file.sessionId, {
6269
+ t: "file",
6270
+ op: request.body?.hidden === false ? "restored" : "hidden",
6271
+ id: file.id
6272
+ });
6273
+ reply.code(204).send();
6274
+ }
6275
+ );
6276
+ app.delete(
6277
+ "/sessions/:id/files/:fileId",
6278
+ async (request, reply) => {
6279
+ const file = store.getFile(request.params.id, request.params.fileId);
6280
+ if (!file) {
6281
+ reply.code(204).send();
6282
+ return;
6283
+ }
6284
+ if (request.query.content === "true") {
6285
+ if (file.direction !== "sent" || file.storage !== "managed") {
6286
+ reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
6287
+ return;
6288
+ }
6289
+ try {
6290
+ await fsService.removeManagedPath(file.path);
6291
+ } catch (err) {
6292
+ if (!(err instanceof FsError && err.code === "not-found")) {
6293
+ reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
6294
+ error: "managed file could not be deleted"
6295
+ });
6296
+ return;
6297
+ }
6298
+ }
6299
+ store.deleteFile(file.sessionId, file.id);
6300
+ } else {
6301
+ store.setFileHidden(file.sessionId, file.id, Date.now());
6302
+ }
6303
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
6304
+ reply.code(204).send();
6305
+ }
6306
+ );
5740
6307
  if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
5741
6308
  app.addHook("onClose", async () => {
5742
6309
  clearInterval(sharedSweepTimer);
@@ -5767,10 +6334,10 @@ function createServer(config, deps = {}) {
5767
6334
  });
5768
6335
  return { app, authGate, terminalManager, terminalAvailable };
5769
6336
  }
5770
- function contentDisposition(filename) {
6337
+ function contentDisposition(filename, disposition = "attachment") {
5771
6338
  const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
5772
6339
  const encoded = encodeURIComponent(filename);
5773
- return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
6340
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
5774
6341
  }
5775
6342
 
5776
6343
  // src/start.ts
@@ -6079,7 +6646,7 @@ function createUsageService(opts) {
6079
6646
 
6080
6647
  // src/claude-auth-service.ts
6081
6648
  import { spawn as nodeSpawn2 } from "child_process";
6082
- import { randomUUID as randomUUID4 } from "crypto";
6649
+ import { randomUUID as randomUUID5 } from "crypto";
6083
6650
  function parseAuthStatus(stdout) {
6084
6651
  try {
6085
6652
  const o = JSON.parse(stdout);
@@ -6156,7 +6723,7 @@ var ClaudeAuthService = class {
6156
6723
  reject(err instanceof Error ? err : new Error("failed to spawn claude"));
6157
6724
  return;
6158
6725
  }
6159
- const id = randomUUID4();
6726
+ const id = randomUUID5();
6160
6727
  let settled = false;
6161
6728
  let buf = "";
6162
6729
  const onData = (d) => {
@@ -7518,7 +8085,7 @@ var CodexMetadataService = class {
7518
8085
 
7519
8086
  // src/providers/claude-metadata-service.ts
7520
8087
  import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
7521
- import { randomUUID as randomUUID5 } from "crypto";
8088
+ import { randomUUID as randomUUID6 } from "crypto";
7522
8089
  import { StringDecoder } from "string_decoder";
7523
8090
  var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
7524
8091
  var MAX_MODELS = 64;
@@ -7726,7 +8293,7 @@ function createClaudeMetadataRunner(options) {
7726
8293
  reject(metadataUnavailable2());
7727
8294
  return;
7728
8295
  }
7729
- const requestId = `roamcode-models-${randomUUID5()}`;
8296
+ const requestId = `roamcode-models-${randomUUID6()}`;
7730
8297
  const decoder = new StringDecoder("utf8");
7731
8298
  let stdoutBuffer = "";
7732
8299
  let outputBytes = 0;