@roamcode.ai/server 1.0.10 → 1.0.12

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.10" ? "1.0.10".replace(/^v/, "") : packageVersion();
874
+ var RUNNING_VERSION = "1.0.12" ? "1.0.12".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,92 @@ 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
+ var FILE_HISTORY_BACKFILL_BUDGET_MS = 150;
4299
+ var FILE_HISTORY_AVAILABILITY_BUDGET_MS = 150;
4300
+ function completionWithin(task, timeoutMs) {
4301
+ return new Promise((resolve7) => {
4302
+ let settled = false;
4303
+ const finish = (completed) => {
4304
+ if (settled) return;
4305
+ settled = true;
4306
+ clearTimeout(timer);
4307
+ resolve7(completed);
4308
+ };
4309
+ const timer = setTimeout(() => finish(false), timeoutMs);
4310
+ void task.then(
4311
+ () => finish(true),
4312
+ () => finish(false)
4313
+ );
4314
+ });
4315
+ }
4316
+ function attachmentMedia(filename, declared = "application/octet-stream") {
4317
+ const ext = filename.includes(".") ? filename.slice(filename.lastIndexOf(".") + 1).toLowerCase() : "";
4318
+ if (declared.startsWith("image/") || IMAGE_FILE_EXTENSIONS.has(ext)) {
4319
+ const inferred = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : ext ? `image/${ext}` : declared;
4320
+ return { mimeType: declared.startsWith("image/") ? declared : inferred, kind: "image" };
4321
+ }
4322
+ if (declared === "application/pdf" || ext === "pdf") return { mimeType: "application/pdf", kind: "pdf" };
4323
+ if (declared.startsWith("text/") || TEXT_FILE_EXTENSIONS.has(ext)) {
4324
+ return { mimeType: "text/plain; charset=utf-8", kind: "text" };
4325
+ }
4326
+ return { mimeType: declared || "application/octet-stream", kind: "binary" };
4327
+ }
4328
+ function publicSessionFile(file, available = true) {
4329
+ return { ...file, isImage: file.kind === "image", available };
4330
+ }
4007
4331
  function isDisallowedPushHost(hostname) {
4008
4332
  const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
4009
4333
  if (host === "localhost" || host.endsWith(".localhost")) return true;
@@ -4059,8 +4383,69 @@ function createServer(config, deps = {}) {
4059
4383
  });
4060
4384
  const fsService = new FsService({ root: config.fsRoot });
4061
4385
  const terminalSharedRoot = terminalSharedBase({ dataDir: config.dataDir, fsRoot: config.fsRoot });
4386
+ const backfilledFileSessions = /* @__PURE__ */ new Set();
4387
+ const fileBackfillsInFlight = /* @__PURE__ */ new Map();
4388
+ const backfillManagedFiles = (sessionId) => {
4389
+ if (backfilledFileSessions.has(sessionId)) return Promise.resolve();
4390
+ const existing = fileBackfillsInFlight.get(sessionId);
4391
+ if (existing) return existing;
4392
+ const task = (async () => {
4393
+ const sessionDir = terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId });
4394
+ const discovered = await fsService.discoverManagedFiles(sessionDir);
4395
+ const knownPaths = new Set(store.listFiles(sessionId, true).map((file) => file.path));
4396
+ for (const file of discovered) {
4397
+ if (knownPaths.has(file.path)) continue;
4398
+ const expiresAt = file.mtimeMs + TERMINAL_FILE_TTL_MS;
4399
+ if (expiresAt <= Date.now()) continue;
4400
+ const now = Date.now();
4401
+ const media = attachmentMedia(file.filename);
4402
+ store.putFile({
4403
+ id: randomUUID4(),
4404
+ sessionId,
4405
+ direction: "sent",
4406
+ storage: "managed",
4407
+ name: file.filename,
4408
+ path: file.path,
4409
+ mimeType: media.mimeType,
4410
+ size: file.size,
4411
+ kind: media.kind,
4412
+ createdAt: file.mtimeMs,
4413
+ updatedAt: now,
4414
+ expiresAt
4415
+ });
4416
+ knownPaths.add(file.path);
4417
+ }
4418
+ backfilledFileSessions.add(sessionId);
4419
+ })();
4420
+ fileBackfillsInFlight.set(sessionId, task);
4421
+ void task.catch(() => void 0).finally(() => fileBackfillsInFlight.delete(sessionId));
4422
+ return task;
4423
+ };
4424
+ const fileAvailableWithinBudget = async (file) => {
4425
+ if (file.expiresAt <= Date.now()) return false;
4426
+ return new Promise((resolve7) => {
4427
+ let settled = false;
4428
+ const finish = (available) => {
4429
+ if (settled) return;
4430
+ settled = true;
4431
+ clearTimeout(timer);
4432
+ resolve7(available);
4433
+ };
4434
+ const timer = setTimeout(() => finish(true), FILE_HISTORY_AVAILABILITY_BUDGET_MS);
4435
+ void fsService.describeFile(file.path).then(
4436
+ () => finish(true),
4437
+ () => finish(false)
4438
+ );
4439
+ });
4440
+ };
4062
4441
  const sweepSharedFiles = () => {
4063
- void fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
4442
+ void (async () => {
4443
+ const expired = typeof store.pruneFiles === "function" ? store.pruneFiles(Date.now()) : [];
4444
+ await Promise.all(
4445
+ expired.filter((file) => file.storage === "managed").map((file) => fsService.removeManagedPath(file.path).catch(() => void 0))
4446
+ );
4447
+ await fsService.pruneChildDirsOlderThan(terminalSharedRoot, TERMINAL_FILE_TTL_MS).catch(() => 0);
4448
+ })();
4064
4449
  };
4065
4450
  sweepSharedFiles();
4066
4451
  const sharedSweepTimer = setInterval(sweepSharedFiles, TERMINAL_SWEEP_INTERVAL_MS);
@@ -4305,7 +4690,7 @@ function createServer(config, deps = {}) {
4305
4690
  reply.code(400).send({ error: `cwd does not exist: ${body.cwd}` });
4306
4691
  return;
4307
4692
  }
4308
- const id = randomUUID3();
4693
+ const id = randomUUID4();
4309
4694
  const rawOptions = body.options ?? (provider === "claude" ? {
4310
4695
  ...typeof body.model === "string" ? { model: body.model } : {},
4311
4696
  ...typeof body.effort === "string" ? { effort: body.effort } : {},
@@ -4529,8 +4914,10 @@ function createServer(config, deps = {}) {
4529
4914
  }
4530
4915
  const caption = typeof body.caption === "string" ? body.caption : void 0;
4531
4916
  let described;
4917
+ let fileInfo;
4532
4918
  try {
4533
4919
  described = await fsService.describeForAttachment(body.path);
4920
+ fileInfo = await fsService.describeFile(body.path);
4534
4921
  } catch (err) {
4535
4922
  if (err instanceof FsError) {
4536
4923
  reply.code(err.code === "forbidden" ? 403 : 404).send({ error: err.message });
@@ -4540,14 +4927,28 @@ function createServer(config, deps = {}) {
4540
4927
  return;
4541
4928
  }
4542
4929
  const isImage = body.kind === "image" ? true : body.kind === "file" ? false : described.isImage;
4543
- const id = randomUUID3();
4544
- terminalManager.pushControl(sessionId, {
4545
- t: "attach",
4930
+ const id = randomUUID4();
4931
+ const now = Date.now();
4932
+ const media = attachmentMedia(described.name);
4933
+ const stored = {
4546
4934
  id,
4547
- path: body.path,
4935
+ sessionId,
4936
+ direction: "received",
4937
+ storage: "workspace",
4548
4938
  name: described.name,
4549
- caption,
4550
- isImage
4939
+ path: body.path,
4940
+ mimeType: media.mimeType,
4941
+ size: fileInfo.size,
4942
+ kind: isImage ? "image" : media.kind,
4943
+ ...caption ? { caption } : {},
4944
+ createdAt: now,
4945
+ updatedAt: now,
4946
+ expiresAt: now + TERMINAL_FILE_TTL_MS
4947
+ };
4948
+ store.putFile(stored);
4949
+ terminalManager.pushControl(sessionId, {
4950
+ t: "attach",
4951
+ ...publicSessionFile(stored)
4551
4952
  });
4552
4953
  dispatchPush({ kind: "file", sessionId, detail: described.name });
4553
4954
  reply.code(200).send({ ok: true, id });
@@ -5078,23 +5479,126 @@ function createServer(config, deps = {}) {
5078
5479
  reply.code(400).send({ error: err.message });
5079
5480
  }
5080
5481
  });
5482
+ app.get("/sessions/:id/files", async (request, reply) => {
5483
+ if (!terminalManager.get(request.params.id)) {
5484
+ reply.code(404).send({ error: "terminal session not found" });
5485
+ return;
5486
+ }
5487
+ await completionWithin(backfillManagedFiles(request.params.id), FILE_HISTORY_BACKFILL_BUDGET_MS);
5488
+ const files = await Promise.all(
5489
+ store.listFiles(request.params.id).map(async (file) => {
5490
+ const available = await fileAvailableWithinBudget(file);
5491
+ return publicSessionFile(file, available);
5492
+ })
5493
+ );
5494
+ return {
5495
+ files,
5496
+ policy: {
5497
+ maxUploadBytes: config.maxUploadBytes,
5498
+ retentionMs: TERMINAL_FILE_TTL_MS,
5499
+ durable: store.mode === "sqlite"
5500
+ }
5501
+ };
5502
+ });
5503
+ app.get(
5504
+ "/sessions/:id/files/:fileId/content",
5505
+ async (request, reply) => {
5506
+ const file = store.getFile(request.params.id, request.params.fileId);
5507
+ if (!file || file.hiddenAt !== void 0) {
5508
+ reply.code(404).send({ error: "file not found" });
5509
+ return;
5510
+ }
5511
+ if (file.expiresAt <= Date.now()) {
5512
+ reply.code(410).send({ error: "file has expired" });
5513
+ return;
5514
+ }
5515
+ try {
5516
+ const info = await fsService.describeFile(file.path);
5517
+ const disposition = request.query.disposition === "inline" ? "inline" : "attachment";
5518
+ reply.header("accept-ranges", "bytes").header("content-type", file.mimeType).header("x-content-type-options", "nosniff").header(
5519
+ "content-security-policy",
5520
+ "sandbox; default-src 'none'; img-src 'self' data: blob:; style-src 'unsafe-inline'"
5521
+ ).header("content-disposition", contentDisposition(info.filename, disposition));
5522
+ const range = request.headers.range;
5523
+ if (range) {
5524
+ const match = /^bytes=(\d+)-(\d*)$/.exec(range);
5525
+ if (!match) {
5526
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
5527
+ return;
5528
+ }
5529
+ const start = Number(match[1]);
5530
+ const end = match[2] ? Math.min(Number(match[2]), info.size - 1) : info.size - 1;
5531
+ if (!Number.isSafeInteger(start) || start < 0 || start > end || start >= info.size) {
5532
+ reply.code(416).header("content-range", `bytes */${info.size}`).send();
5533
+ return;
5534
+ }
5535
+ 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 }));
5536
+ }
5537
+ return reply.header("content-length", String(info.size)).send(createReadStream(info.path));
5538
+ } catch (err) {
5539
+ const code = err instanceof FsError && err.code === "forbidden" ? 403 : 404;
5540
+ reply.code(code).send({ error: err.message });
5541
+ }
5542
+ }
5543
+ );
5081
5544
  app.post("/sessions/:id/upload", async (request, reply) => {
5082
- const meta = terminalManager.get(request.params.id);
5083
- if (!meta) {
5545
+ const sessionId = request.params.id;
5546
+ if (!terminalManager.get(sessionId)) {
5084
5547
  reply.code(404).send({ error: "terminal session not found" });
5085
5548
  return;
5086
5549
  }
5087
- let dir;
5550
+ let data;
5551
+ try {
5552
+ data = await request.file();
5553
+ } catch (err) {
5554
+ reply.code(400).send({ error: err.message });
5555
+ return;
5556
+ }
5557
+ if (!data) {
5558
+ reply.code(400).send({ error: "no file field in the upload" });
5559
+ return;
5560
+ }
5561
+ const id = randomUUID4();
5088
5562
  try {
5089
- dir = await fsService.ensureDirWithinRoot(
5090
- terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: meta.id })
5563
+ const dir = await fsService.ensureDirWithinRoot(
5564
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId })}/${id}`
5091
5565
  );
5566
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
5567
+ const now = Date.now();
5568
+ const media = attachmentMedia(data.filename, data.mimetype);
5569
+ const stored = {
5570
+ id,
5571
+ sessionId,
5572
+ direction: "sent",
5573
+ storage: "managed",
5574
+ name: data.filename,
5575
+ path: written.path,
5576
+ mimeType: media.mimeType,
5577
+ size: written.size,
5578
+ kind: media.kind,
5579
+ createdAt: now,
5580
+ updatedAt: now,
5581
+ expiresAt: now + TERMINAL_FILE_TTL_MS
5582
+ };
5583
+ store.putFile(stored);
5584
+ terminalManager.pushControl(sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
5585
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5092
5586
  } catch (err) {
5093
- const code = err instanceof FsError && err.code === "forbidden" ? 403 : 400;
5094
- reply.code(code).send({ error: err.message });
5587
+ reply.code(data.file.truncated ? 413 : 400).send({
5588
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5589
+ });
5590
+ }
5591
+ });
5592
+ app.post("/sessions/:id/files/:fileId/derive", async (request, reply) => {
5593
+ const source = store.getFile(request.params.id, request.params.fileId);
5594
+ if (!source || source.hiddenAt !== void 0 || source.kind !== "image") {
5595
+ reply.code(404).send({ error: "source image not found" });
5596
+ return;
5597
+ }
5598
+ if (source.expiresAt <= Date.now()) {
5599
+ reply.code(410).send({ error: "source image has expired" });
5095
5600
  return;
5096
5601
  }
5097
- await fsService.pruneOlderThan(dir, TERMINAL_FILE_TTL_MS).catch(() => 0);
5098
5602
  let data;
5099
5603
  try {
5100
5604
  data = await request.file();
@@ -5102,28 +5606,134 @@ function createServer(config, deps = {}) {
5102
5606
  reply.code(400).send({ error: err.message });
5103
5607
  return;
5104
5608
  }
5105
- if (!data) {
5106
- reply.code(400).send({ error: "no file field in the upload" });
5609
+ if (!data || !data.mimetype.startsWith("image/")) {
5610
+ reply.code(400).send({ error: "an edited image is required" });
5107
5611
  return;
5108
5612
  }
5109
- let buffer;
5613
+ const id = randomUUID4();
5110
5614
  try {
5111
- buffer = await data.toBuffer();
5615
+ const dir = await fsService.ensureDirWithinRoot(
5616
+ `${terminalSharedDir({ dataDir: config.dataDir, fsRoot: config.fsRoot, sessionId: source.sessionId })}/${id}`
5617
+ );
5618
+ const written = await fsService.writeUploadedStream(dir, data.filename, data.file, () => !data.file.truncated);
5619
+ const now = Date.now();
5620
+ const media = attachmentMedia(data.filename, data.mimetype);
5621
+ const stored = {
5622
+ id,
5623
+ sessionId: source.sessionId,
5624
+ direction: "sent",
5625
+ storage: "managed",
5626
+ name: data.filename,
5627
+ path: written.path,
5628
+ mimeType: media.mimeType,
5629
+ size: written.size,
5630
+ kind: "image",
5631
+ createdAt: now,
5632
+ updatedAt: now,
5633
+ expiresAt: now + TERMINAL_FILE_TTL_MS,
5634
+ derivedFromId: source.id
5635
+ };
5636
+ store.putFile(stored);
5637
+ terminalManager.pushControl(source.sessionId, { t: "file", op: "added", file: publicSessionFile(stored) });
5638
+ reply.code(201).send({ path: stored.path, file: publicSessionFile(stored) });
5112
5639
  } catch (err) {
5113
- reply.code(413).send({ error: err.message });
5640
+ reply.code(data.file.truncated ? 413 : 400).send({
5641
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5642
+ });
5643
+ }
5644
+ });
5645
+ app.put("/sessions/:id/files/:fileId/content", async (request, reply) => {
5646
+ const file = store.getFile(request.params.id, request.params.fileId);
5647
+ if (!file || file.hiddenAt !== void 0) {
5648
+ reply.code(404).send({ error: "file not found" });
5114
5649
  return;
5115
5650
  }
5116
- if (data.file.truncated) {
5117
- reply.code(413).send({ error: "file exceeds the upload size limit" });
5651
+ if (file.expiresAt <= Date.now()) {
5652
+ reply.code(410).send({ error: "file has expired" });
5653
+ return;
5654
+ }
5655
+ if (file.direction !== "sent" || file.storage !== "managed" || file.kind !== "image") {
5656
+ reply.code(409).send({ error: "only managed sent images can be replaced" });
5118
5657
  return;
5119
5658
  }
5659
+ let data;
5120
5660
  try {
5121
- const written = await fsService.writeUploadedFile(dir, data.filename, buffer);
5122
- reply.code(201).send({ path: written.path });
5661
+ data = await request.file();
5123
5662
  } catch (err) {
5124
5663
  reply.code(400).send({ error: err.message });
5664
+ return;
5665
+ }
5666
+ if (!data || !data.mimetype.startsWith("image/")) {
5667
+ reply.code(400).send({ error: "an image file is required" });
5668
+ return;
5669
+ }
5670
+ try {
5671
+ const written = await fsService.replaceFileStream(file.path, data.file, () => !data.file.truncated);
5672
+ const now = Date.now();
5673
+ const updated = {
5674
+ ...file,
5675
+ mimeType: data.mimetype,
5676
+ size: written.size,
5677
+ updatedAt: now,
5678
+ expiresAt: now + TERMINAL_FILE_TTL_MS
5679
+ };
5680
+ store.putFile(updated);
5681
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "updated", file: publicSessionFile(updated) });
5682
+ reply.send({ file: publicSessionFile(updated) });
5683
+ } catch (err) {
5684
+ reply.code(data.file.truncated ? 413 : 400).send({
5685
+ error: data.file.truncated ? "file exceeds the upload size limit" : err.message
5686
+ });
5125
5687
  }
5126
5688
  });
5689
+ app.patch(
5690
+ "/sessions/:id/files/:fileId",
5691
+ async (request, reply) => {
5692
+ const file = store.getFile(request.params.id, request.params.fileId);
5693
+ if (!file) {
5694
+ reply.code(404).send({ error: "file not found" });
5695
+ return;
5696
+ }
5697
+ store.setFileHidden(file.sessionId, file.id, request.body?.hidden === false ? void 0 : Date.now());
5698
+ terminalManager.pushControl(file.sessionId, {
5699
+ t: "file",
5700
+ op: request.body?.hidden === false ? "restored" : "hidden",
5701
+ id: file.id
5702
+ });
5703
+ reply.code(204).send();
5704
+ }
5705
+ );
5706
+ app.delete(
5707
+ "/sessions/:id/files/:fileId",
5708
+ async (request, reply) => {
5709
+ const file = store.getFile(request.params.id, request.params.fileId);
5710
+ if (!file) {
5711
+ reply.code(204).send();
5712
+ return;
5713
+ }
5714
+ if (request.query.content === "true") {
5715
+ if (file.direction !== "sent" || file.storage !== "managed") {
5716
+ reply.code(409).send({ error: "workspace files cannot be deleted by RoamCode" });
5717
+ return;
5718
+ }
5719
+ try {
5720
+ await fsService.removeManagedPath(file.path);
5721
+ } catch (err) {
5722
+ if (!(err instanceof FsError && err.code === "not-found")) {
5723
+ reply.code(err instanceof FsError && err.code === "forbidden" ? 403 : 500).send({
5724
+ error: "managed file could not be deleted"
5725
+ });
5726
+ return;
5727
+ }
5728
+ }
5729
+ store.deleteFile(file.sessionId, file.id);
5730
+ } else {
5731
+ store.setFileHidden(file.sessionId, file.id, Date.now());
5732
+ }
5733
+ terminalManager.pushControl(file.sessionId, { t: "file", op: "removed", id: file.id });
5734
+ reply.code(204).send();
5735
+ }
5736
+ );
5127
5737
  if (deps.webDir) registerStatic(app, { webDir: deps.webDir });
5128
5738
  app.addHook("onClose", async () => {
5129
5739
  clearInterval(sharedSweepTimer);
@@ -5154,10 +5764,10 @@ function createServer(config, deps = {}) {
5154
5764
  });
5155
5765
  return { app, authGate, terminalManager, terminalAvailable };
5156
5766
  }
5157
- function contentDisposition(filename) {
5767
+ function contentDisposition(filename, disposition = "attachment") {
5158
5768
  const ascii = filename.replace(/[\x00-\x1f\x7f"\\]/g, "_");
5159
5769
  const encoded = encodeURIComponent(filename);
5160
- return `attachment; filename="${ascii}"; filename*=UTF-8''${encoded}`;
5770
+ return `${disposition}; filename="${ascii}"; filename*=UTF-8''${encoded}`;
5161
5771
  }
5162
5772
 
5163
5773
  // src/server-config.ts
@@ -5540,7 +6150,7 @@ function createUsageService(opts) {
5540
6150
 
5541
6151
  // src/claude-auth-service.ts
5542
6152
  import { spawn as nodeSpawn2 } from "child_process";
5543
- import { randomUUID as randomUUID4 } from "crypto";
6153
+ import { randomUUID as randomUUID5 } from "crypto";
5544
6154
  function parseAuthStatus(stdout) {
5545
6155
  try {
5546
6156
  const o = JSON.parse(stdout);
@@ -5617,7 +6227,7 @@ var ClaudeAuthService = class {
5617
6227
  reject(err instanceof Error ? err : new Error("failed to spawn claude"));
5618
6228
  return;
5619
6229
  }
5620
- const id = randomUUID4();
6230
+ const id = randomUUID5();
5621
6231
  let settled = false;
5622
6232
  let buf = "";
5623
6233
  const onData = (d) => {
@@ -6979,7 +7589,7 @@ var CodexMetadataService = class {
6979
7589
 
6980
7590
  // src/providers/claude-metadata-service.ts
6981
7591
  import { execFile as nodeExecFile2, spawn as nodeSpawn3 } from "child_process";
6982
- import { randomUUID as randomUUID5 } from "crypto";
7592
+ import { randomUUID as randomUUID6 } from "crypto";
6983
7593
  import { StringDecoder } from "string_decoder";
6984
7594
  var SAFE_VALUE = /^[A-Za-z0-9][A-Za-z0-9._:/\u005b\u005d-]*$/;
6985
7595
  var MAX_MODELS = 64;
@@ -7187,7 +7797,7 @@ function createClaudeMetadataRunner(options) {
7187
7797
  reject(metadataUnavailable2());
7188
7798
  return;
7189
7799
  }
7190
- const requestId = `roamcode-models-${randomUUID5()}`;
7800
+ const requestId = `roamcode-models-${randomUUID6()}`;
7191
7801
  const decoder = new StringDecoder("utf8");
7192
7802
  let stdoutBuffer = "";
7193
7803
  let outputBytes = 0;