@lelouchhe/webagent 0.7.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/bridge.js CHANGED
@@ -3,6 +3,7 @@ import { Writable, Readable } from "node:stream";
3
3
  import { EventEmitter } from "node:events";
4
4
  import * as acp from "@agentclientprotocol/sdk";
5
5
  import { interruptBashProc } from "./session-manager.js";
6
+ import { abbreviateHomePath } from "./home-path.js";
6
7
  import { log } from "./log.js";
7
8
  const blog = log.scope("bridge");
8
9
  export class AgentBridge extends EventEmitter {
@@ -12,15 +13,27 @@ export class AgentBridge extends EventEmitter {
12
13
  permissionRequestSessions = new Map();
13
14
  silentSessions = new Set(); // Sessions that don't emit events
14
15
  silentBuffers = new Map(); // Text buffers for silent sessions
16
+ pendingNewSessions = 0;
17
+ unboundNewSessionIds = new Set();
18
+ pendingSessionUpdates = new Map();
15
19
  pendingAborts = new Map();
16
20
  deadReason = null;
17
21
  stderrTail = "";
22
+ closedProcesses = new WeakSet();
18
23
  agentCmd;
24
+ sessionIds;
19
25
  reloading = false;
20
26
  attachmentDispatcher = null;
21
- constructor(agentCmd) {
27
+ constructor(agentCmd, sessionIds) {
22
28
  super();
23
29
  this.agentCmd = agentCmd;
30
+ this.sessionIds = sessionIds;
31
+ }
32
+ agentSessionId(webSessionId) {
33
+ const id = this.sessionIds.getAgentSessionId(webSessionId);
34
+ if (!id)
35
+ throw new Error(`Session is not available for the current agent: ${webSessionId}`);
36
+ return id;
24
37
  }
25
38
  /**
26
39
  * Inject the dispatcher used to translate client attachment refs into
@@ -60,6 +73,9 @@ export class AgentBridge extends EventEmitter {
60
73
  `\nCheck '${this.agentCmd}' is properly configured (e.g. authenticated).`;
61
74
  this.markAgentDead(reason);
62
75
  });
76
+ proc.once("close", () => {
77
+ this.closedProcesses.add(proc);
78
+ });
63
79
  proc.on("error", (err) => {
64
80
  if (this.reloading)
65
81
  return;
@@ -95,29 +111,57 @@ export class AgentBridge extends EventEmitter {
95
111
  async newSession(cwd, opts) {
96
112
  if (!this.conn)
97
113
  throw new Error("Not connected");
98
- const session = await this.conn.newSession({
99
- cwd,
100
- mcpServers: [],
101
- });
102
- const configOptions = (session.configOptions ??
103
- []);
104
- if (!opts?.silent) {
105
- this.emit("event", {
106
- type: "session_created",
107
- sessionId: session.sessionId,
114
+ this.pendingNewSessions++;
115
+ try {
116
+ const session = await this.conn.newSession({
108
117
  cwd,
109
- configOptions,
118
+ mcpServers: [],
110
119
  });
120
+ if (opts?.silent) {
121
+ this.pendingSessionUpdates.delete(session.sessionId);
122
+ this.silentSessions.add(session.sessionId);
123
+ }
124
+ else {
125
+ this.unboundNewSessionIds.add(session.sessionId);
126
+ }
127
+ const configOptions = (session.configOptions ??
128
+ []);
129
+ return { sessionId: session.sessionId, configOptions };
111
130
  }
112
- return { sessionId: session.sessionId, configOptions };
131
+ finally {
132
+ this.pendingNewSessions--;
133
+ if (this.pendingNewSessions === 0) {
134
+ for (const sessionId of this.pendingSessionUpdates.keys()) {
135
+ if (!this.unboundNewSessionIds.has(sessionId)) {
136
+ this.pendingSessionUpdates.delete(sessionId);
137
+ blog.warn("discarded update for unrelated unmapped ACP session", {
138
+ sessionId,
139
+ });
140
+ }
141
+ }
142
+ }
143
+ }
144
+ }
145
+ sessionMapped(agentSessionId) {
146
+ this.unboundNewSessionIds.delete(agentSessionId);
147
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
148
+ this.pendingSessionUpdates.delete(agentSessionId);
149
+ for (const update of updates) {
150
+ void this.handleSessionUpdate({ sessionId: agentSessionId, update });
151
+ }
152
+ }
153
+ discardUnboundSession(agentSessionId) {
154
+ this.unboundNewSessionIds.delete(agentSessionId);
155
+ this.pendingSessionUpdates.delete(agentSessionId);
113
156
  }
114
157
  async loadSession(sessionId, cwd) {
115
158
  if (!this.conn)
116
159
  throw new Error("Not connected");
160
+ const agentSessionId = this.agentSessionId(sessionId);
117
161
  let session;
118
162
  try {
119
163
  session = await this.conn.loadSession({
120
- sessionId,
164
+ sessionId: agentSessionId,
121
165
  cwd,
122
166
  mcpServers: [],
123
167
  });
@@ -140,6 +184,7 @@ export class AgentBridge extends EventEmitter {
140
184
  type: "session_created",
141
185
  sessionId,
142
186
  cwd,
187
+ cwdDisplay: abbreviateHomePath(cwd),
143
188
  configOptions,
144
189
  });
145
190
  return { sessionId, configOptions };
@@ -148,7 +193,19 @@ export class AgentBridge extends EventEmitter {
148
193
  if (!this.conn)
149
194
  throw new Error("Not connected");
150
195
  const result = await this.conn.setSessionConfigOption({
151
- sessionId,
196
+ sessionId: this.agentSessionId(sessionId),
197
+ configId,
198
+ ...(typeof value === "boolean"
199
+ ? { type: "boolean", value }
200
+ : { value }),
201
+ });
202
+ return result.configOptions;
203
+ }
204
+ async setAgentConfigOption(agentSessionId, configId, value) {
205
+ if (!this.conn)
206
+ throw new Error("Not connected");
207
+ const result = await this.conn.setSessionConfigOption({
208
+ sessionId: agentSessionId,
152
209
  configId,
153
210
  ...(typeof value === "boolean"
154
211
  ? { type: "boolean", value }
@@ -156,7 +213,10 @@ export class AgentBridge extends EventEmitter {
156
213
  });
157
214
  return result.configOptions;
158
215
  }
159
- async prompt(sessionId, text, attachments) {
216
+ async prompt(sessionId, text, attachments,
217
+ /** Turn identity echoed back on this prompt's terminal event, so a
218
+ * completion that outlives its turn can be told apart from the live one. */
219
+ promptId) {
160
220
  if (this.deadReason) {
161
221
  this.emit("event", {
162
222
  type: "error",
@@ -189,7 +249,7 @@ export class AgentBridge extends EventEmitter {
189
249
  promptParts.push({ type: "text", text });
190
250
  const result = (await Promise.race([
191
251
  this.conn.prompt({
192
- sessionId,
252
+ sessionId: this.agentSessionId(sessionId),
193
253
  prompt: promptParts,
194
254
  }),
195
255
  abortPromise,
@@ -198,6 +258,7 @@ export class AgentBridge extends EventEmitter {
198
258
  type: "prompt_done",
199
259
  sessionId,
200
260
  stopReason: result.stopReason ?? "end_turn",
261
+ ...(promptId ? { promptId } : {}),
201
262
  });
202
263
  }
203
264
  catch (err) {
@@ -211,6 +272,7 @@ export class AgentBridge extends EventEmitter {
211
272
  type: "prompt_done",
212
273
  sessionId,
213
274
  stopReason: "cancelled",
275
+ ...(promptId ? { promptId } : {}),
214
276
  });
215
277
  return;
216
278
  }
@@ -218,6 +280,7 @@ export class AgentBridge extends EventEmitter {
218
280
  type: "error",
219
281
  sessionId,
220
282
  message,
283
+ ...(promptId ? { promptId } : {}),
221
284
  });
222
285
  }
223
286
  finally {
@@ -231,7 +294,10 @@ export class AgentBridge extends EventEmitter {
231
294
  this.denyPermission(requestId);
232
295
  }
233
296
  }
234
- await this.conn?.cancel({ sessionId });
297
+ await this.conn?.cancel({ sessionId: this.agentSessionId(sessionId) });
298
+ }
299
+ async cancelAgentSession(agentSessionId) {
300
+ await this.conn?.cancel({ sessionId: agentSessionId });
235
301
  }
236
302
  /**
237
303
  * Mark the agent subprocess as dead. Rejects in-flight prompts and emits
@@ -324,6 +390,7 @@ export class AgentBridge extends EventEmitter {
324
390
  if (this.reloading)
325
391
  throw new Error("Already reloading");
326
392
  this.reloading = true;
393
+ const liveSessionIds = [...sessions.liveSessions];
327
394
  this.emit("event", { type: "agent_reloading" });
328
395
  blog.info("reloading agent...");
329
396
  try {
@@ -342,18 +409,29 @@ export class AgentBridge extends EventEmitter {
342
409
  }
343
410
  }
344
411
  // 2. Flush buffers to persist partial content
345
- for (const sessionId of sessions.liveSessions) {
412
+ for (const sessionId of liveSessionIds) {
346
413
  sessions.flushBuffers(sessionId);
347
414
  }
348
415
  // 3. Clean up SessionManager state
349
416
  sessions.pendingPermissions.clear();
350
- for (const id of sessions.activePrompts) {
417
+ sessions.state.clearPlans();
418
+ const busySessionIds = new Set([
419
+ ...sessions.activePrompts,
420
+ ...sessions.pendingPromptSubmissions.keys(),
421
+ ]);
422
+ for (const id of busySessionIds) {
351
423
  sessions.state.patch(id, { runtime: { busy: null } });
352
424
  }
425
+ for (const submissionId of sessions.pendingPromptSubmissions.values()) {
426
+ sessions.cancelledPromptSubmissions.add(submissionId);
427
+ }
353
428
  sessions.activePrompts.clear();
429
+ sessions.pendingPromptSubmissions.clear();
354
430
  // 4. Clean up bridge-side silent session state
355
431
  this.silentSessions.clear();
356
432
  this.silentBuffers.clear();
433
+ this.unboundNewSessionIds.clear();
434
+ this.pendingSessionUpdates.clear();
357
435
  // 5. Invalidate title service session
358
436
  titleService.invalidate();
359
437
  // 5. Clear liveSessions so ensureResumed() will re-register on next access
@@ -364,6 +442,13 @@ export class AgentBridge extends EventEmitter {
364
442
  sessions.cachedConfigOptions = [];
365
443
  // 6. Shutdown old process
366
444
  await this.shutdown();
445
+ // Cancellation is asynchronous: the old agent may emit final chunks
446
+ // before shutdown completes. Persist that tail and make the terminal
447
+ // stream state authoritative before starting the replacement process.
448
+ for (const sessionId of liveSessionIds) {
449
+ sessions.flushBuffers(sessionId);
450
+ }
451
+ sessions.state.clearStreaming();
367
452
  // 7. Start new process with retry (exponential backoff, max 3 attempts)
368
453
  let lastError;
369
454
  for (let i = 0; i < 3; i++) {
@@ -400,17 +485,28 @@ export class AgentBridge extends EventEmitter {
400
485
  }
401
486
  this.permissionResolvers.clear();
402
487
  this.permissionRequestSessions.clear();
403
- if (this.proc?.exitCode === null) {
404
- const proc = this.proc;
488
+ const proc = this.proc;
489
+ if (proc && !this.closedProcesses.has(proc)) {
405
490
  await new Promise((resolve) => {
406
- const timer = setTimeout(() => {
407
- proc.kill(process.platform === "win32" ? undefined : "SIGKILL");
491
+ let settled = false;
492
+ const finish = () => {
493
+ if (settled)
494
+ return;
495
+ settled = true;
496
+ this.closedProcesses.add(proc);
497
+ clearTimeout(killTimer);
498
+ clearTimeout(drainTimer);
499
+ proc.off("close", finish);
408
500
  resolve();
501
+ };
502
+ const killTimer = setTimeout(() => {
503
+ proc.kill(process.platform === "win32" ? undefined : "SIGKILL");
409
504
  }, 5000);
410
- proc.on("exit", () => {
411
- clearTimeout(timer);
412
- resolve();
413
- });
505
+ // `close` follows `exit` after stdio has drained. Keep a bounded
506
+ // fallback for pathological child-process implementations that never
507
+ // deliver close even after SIGKILL.
508
+ const drainTimer = setTimeout(finish, 6000);
509
+ proc.once("close", finish);
414
510
  proc.kill();
415
511
  });
416
512
  }
@@ -419,6 +515,10 @@ export class AgentBridge extends EventEmitter {
419
515
  }
420
516
  // --- ACP Client callbacks ---
421
517
  handlePermission(params) {
518
+ const webSessionId = this.sessionIds.getWebSessionId(params.sessionId);
519
+ if (!webSessionId) {
520
+ return Promise.resolve({ outcome: { outcome: "cancelled" } });
521
+ }
422
522
  const requestId = crypto.randomUUID();
423
523
  const toolCall = params.toolCall;
424
524
  // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- toolCall may be undefined in practice
@@ -429,11 +529,11 @@ export class AgentBridge extends EventEmitter {
429
529
  return new Promise((resolve) => {
430
530
  // Register resolver BEFORE emitting, so synchronous auto-approve can find it
431
531
  this.permissionResolvers.set(requestId, resolve);
432
- this.permissionRequestSessions.set(requestId, params.sessionId);
532
+ this.permissionRequestSessions.set(requestId, webSessionId);
433
533
  this.emit("event", {
434
534
  type: "permission_request",
435
535
  requestId,
436
- sessionId: params.sessionId,
536
+ sessionId: webSessionId,
437
537
  title,
438
538
  toolCallId,
439
539
  options: params.options,
@@ -446,9 +546,23 @@ export class AgentBridge extends EventEmitter {
446
546
  }
447
547
  handleSessionUpdate(params) {
448
548
  const update = params.update;
449
- const sessionId = params.sessionId;
450
- if (this.silentSessions.has(sessionId)) {
451
- this.captureSilentText(sessionId, update);
549
+ const agentSessionId = params.sessionId;
550
+ if (this.silentSessions.has(agentSessionId)) {
551
+ this.captureSilentText(agentSessionId, update);
552
+ return Promise.resolve();
553
+ }
554
+ const sessionId = this.sessionIds.getWebSessionId(agentSessionId);
555
+ if (!sessionId) {
556
+ if (this.pendingNewSessions > 0 ||
557
+ this.unboundNewSessionIds.has(agentSessionId)) {
558
+ const updates = this.pendingSessionUpdates.get(agentSessionId) ?? [];
559
+ updates.push(update);
560
+ this.pendingSessionUpdates.set(agentSessionId, updates);
561
+ return Promise.resolve();
562
+ }
563
+ blog.warn("ignored event for unmapped ACP session", {
564
+ sessionId: agentSessionId,
565
+ });
452
566
  return Promise.resolve();
453
567
  }
454
568
  const event = this.sessionUpdateToEvent(sessionId, update);
@@ -490,9 +604,26 @@ export class AgentBridge extends EventEmitter {
490
604
  id: update.toolCallId,
491
605
  status: update.status ?? "",
492
606
  content: (update.content ?? undefined),
607
+ ...(typeof update.title === "string" ? { title: update.title } : {}),
608
+ ...(typeof update.kind === "string" ? { kind: update.kind } : {}),
609
+ ...(update.rawInput ? { rawInput: update.rawInput } : {}),
610
+ ...(Object.hasOwn(update, "rawOutput")
611
+ ? { rawOutput: update.rawOutput }
612
+ : {}),
613
+ ...(Array.isArray(update.locations)
614
+ ? { locations: update.locations }
615
+ : {}),
493
616
  };
494
617
  case "plan":
495
618
  return { type: "plan", sessionId, entries: update.entries };
619
+ case "usage_update":
620
+ return {
621
+ type: "usage_update",
622
+ sessionId,
623
+ used: update.used,
624
+ size: update.size,
625
+ cost: update.cost,
626
+ };
496
627
  case "config_option_update":
497
628
  return {
498
629
  type: "config_option_update",
@@ -3,6 +3,7 @@ import { log } from "./log.js";
3
3
  import { isAutopilotMode } from "./mode-bucket.js";
4
4
  const ailog = log.scope("attachment-interceptor");
5
5
  const plog = log.scope("push");
6
+ const clog = log.scope("cancel");
6
7
  function handleConnected(event, sessions, config) {
7
8
  event.cancelTimeout = config.cancelTimeout;
8
9
  event.recentPathsLimit = config.recentPathsLimit;
@@ -10,14 +11,8 @@ function handleConnected(event, sessions, config) {
10
11
  if (event.agent)
11
12
  sessions.agentInfo = event.agent;
12
13
  }
13
- function handleConfigLikeEvent(event, sessions, store) {
14
- if (event.configOptions.length)
15
- sessions.cachedConfigOptions = event.configOptions;
16
- for (const opt of event.configOptions) {
17
- if (typeof opt.currentValue === "string") {
18
- store.updateSessionConfig(event.sessionId, opt.id, opt.currentValue);
19
- }
20
- }
14
+ function handleConfigLikeEvent(event, sessions) {
15
+ sessions.recordConfigOptions(event.sessionId, event.configOptions);
21
16
  }
22
17
  function handleMessageChunk(event, sessions) {
23
18
  sessions.flushThinkingBuffer(event.sessionId);
@@ -45,10 +40,27 @@ function handleToolCall(event, sessions, store) {
45
40
  rawInput: event.rawInput,
46
41
  }, { from_ref: "agent" });
47
42
  }
43
+ function handleUsageUpdate(event, sessions) {
44
+ sessions.state.patch(event.sessionId, {
45
+ runtime: {
46
+ contextUsage: {
47
+ used: event.used,
48
+ size: event.size,
49
+ ...(event.cost !== undefined ? { cost: event.cost } : {}),
50
+ },
51
+ },
52
+ });
53
+ }
48
54
  function handlePlan(event, sessions, store) {
49
55
  sessions.flushBuffers(event.sessionId);
50
56
  sessions.state.patch(event.sessionId, {
51
- runtime: { streaming: { assistant: false, thinking: false } },
57
+ runtime: {
58
+ streaming: { assistant: false, thinking: false },
59
+ plan: event.entries.length > 0 &&
60
+ !event.entries.every((entry) => entry.status === "completed")
61
+ ? event.entries
62
+ : null,
63
+ },
52
64
  });
53
65
  store.saveEvent(event.sessionId, event.type, { entries: event.entries }, { from_ref: "agent" });
54
66
  }
@@ -144,18 +156,61 @@ function handlePermissionRequest(event, sessions, store, bridge, sseManager, con
144
156
  return false;
145
157
  }
146
158
  function handlePromptDone(event, sessions, store) {
147
- sessions.activePrompts.delete(event.sessionId);
148
- sessions.syncBusy(event.sessionId);
159
+ const cancelStatus = sessions.state.getState(event.sessionId).runtime.busy?.cancelStatus ?? null;
160
+ if (cancelStatus !== null) {
161
+ clog.info("agent completed after request", {
162
+ sessionId: event.sessionId.slice(0, 8),
163
+ requestedStatus: cancelStatus,
164
+ stopReason: event.stopReason,
165
+ });
166
+ }
167
+ // The tail this turn buffered must always land, even when the turn has
168
+ // already been superseded — it is the only copy of that text.
169
+ const isCurrent = sessions.isCurrentPrompt(event.sessionId, event.promptId);
170
+ if (isCurrent) {
171
+ sessions.activePrompts.delete(event.sessionId);
172
+ sessions.syncBusy(event.sessionId);
173
+ }
174
+ else {
175
+ clog.info("completion from a superseded turn", {
176
+ sessionId: event.sessionId.slice(0, 8),
177
+ promptId: event.promptId,
178
+ stopReason: event.stopReason,
179
+ });
180
+ }
149
181
  sessions.flushBuffers(event.sessionId);
150
182
  sessions.state.patch(event.sessionId, {
151
183
  runtime: { streaming: { assistant: false, thinking: false } },
152
184
  });
153
- store.saveEvent(event.sessionId, event.type, { stopReason: event.stopReason }, { from_ref: "agent" });
185
+ store.saveEvent(event.sessionId, event.type, {
186
+ stopReason: event.stopReason,
187
+ // Replay must be able to make the same judgement the live path does.
188
+ ...(event.promptId ? { promptId: event.promptId } : {}),
189
+ }, { from_ref: "agent" });
154
190
  }
155
- function handleError(event, sessions) {
191
+ function handleError(event, sessions, store) {
156
192
  if (event.sessionId) {
157
- sessions.activePrompts.delete(event.sessionId);
158
- sessions.syncBusy(event.sessionId);
193
+ // Same attribution as a completion: a superseded turn failing late must
194
+ // not end the turn that replaced it. The buffered tail still flushes.
195
+ if (sessions.isCurrentPrompt(event.sessionId, event.promptId)) {
196
+ sessions.activePrompts.delete(event.sessionId);
197
+ sessions.syncBusy(event.sessionId);
198
+ }
199
+ else {
200
+ clog.info("failure from a superseded turn", {
201
+ sessionId: event.sessionId.slice(0, 8),
202
+ promptId: event.promptId,
203
+ message: event.message,
204
+ });
205
+ }
206
+ sessions.flushBuffers(event.sessionId);
207
+ sessions.state.patch(event.sessionId, {
208
+ runtime: { streaming: { assistant: false, thinking: false } },
209
+ });
210
+ store.saveEvent(event.sessionId, event.type, {
211
+ message: event.message,
212
+ ...(event.promptId ? { promptId: event.promptId } : {}),
213
+ }, { from_ref: "agent" });
159
214
  }
160
215
  }
161
216
  function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager) {
@@ -166,7 +221,7 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
166
221
  return false;
167
222
  case "session_created":
168
223
  case "config_option_update":
169
- handleConfigLikeEvent(event, sessions, store);
224
+ handleConfigLikeEvent(event, sessions);
170
225
  return false;
171
226
  case "message_chunk":
172
227
  handleMessageChunk(event, sessions);
@@ -178,7 +233,15 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
178
233
  handleToolCall(event, sessions, store);
179
234
  return false;
180
235
  case "tool_call_update":
181
- store.saveEvent(event.sessionId, event.type, { id: event.id, status: event.status, content: event.content }, { from_ref: "agent" });
236
+ store.saveEvent(event.sessionId, event.type, {
237
+ id: event.id,
238
+ status: event.status,
239
+ content: event.content,
240
+ title: event.title,
241
+ kind: event.kind,
242
+ rawInput: event.rawInput,
243
+ locations: event.locations,
244
+ }, { from_ref: "agent" });
182
245
  return false;
183
246
  case "plan":
184
247
  handlePlan(event, sessions, store);
@@ -189,7 +252,7 @@ function dispatchAgentEvent(event, sessions, store, bridge, config, sseManager)
189
252
  handlePromptDone(event, sessions, store);
190
253
  return false;
191
254
  case "error":
192
- handleError(event, sessions);
255
+ handleError(event, sessions, store);
193
256
  return false;
194
257
  }
195
258
  return false;
@@ -213,6 +276,10 @@ function maybePushNotify(event, pushService) {
213
276
  });
214
277
  }
215
278
  export function handleAgentEvent(event, sessions, store, bridge, config, sseManager, pushService, _clientRegistry) {
279
+ if (event.type === "usage_update") {
280
+ handleUsageUpdate(event, sessions);
281
+ return;
282
+ }
216
283
  if (event.type === "available_commands_update") {
217
284
  const snapshot = sessions.updateAgentCommands(event.sessionId, event.commands);
218
285
  if (sessions.restoringSessions.has(event.sessionId))
@@ -221,6 +288,12 @@ export function handleAgentEvent(event, sessions, store, bridge, config, sseMana
221
288
  return;
222
289
  }
223
290
  if (event.type === "agent_reloading" || event.type === "agent_disconnected") {
291
+ for (const sessionId of sessions.liveSessions) {
292
+ sessions.flushBuffers(sessionId);
293
+ }
294
+ sessions.state.clearStreaming();
295
+ sessions.state.clearPlans();
296
+ sessions.state.clearContextUsage();
224
297
  for (const snapshot of sessions.clearAgentCommands()) {
225
298
  sseManager.broadcast({
226
299
  type: "available_commands_update",
@@ -0,0 +1,15 @@
1
+ /**
2
+ * File viewer size limits.
3
+ *
4
+ * Kept as plain exported constants for now so the routes have a single,
5
+ * testable knob; wiring these into `[limits]` config is a later milestone.
6
+ * Values stay in the same order of magnitude as attachment limits and keep
7
+ * rendered previews bounded. Files outside preview limits stream as downloads
8
+ * instead of being buffered in server or browser memory.
9
+ */
10
+ /** Directory listings cap — beyond this the response is truncated + flagged. */
11
+ export const MAX_LIST_ITEMS = 2000;
12
+ /** Text/Markdown/code preview + highlight cap; larger files download. */
13
+ export const MAX_TEXT_PREVIEW_BYTES = 1024 * 1024; // 1 MiB
14
+ /** Image render cap — larger images stream as downloads. */
15
+ export const MAX_IMAGE_BYTES = 20 * 1024 * 1024; // 20 MB