@lelouchhe/webagent 0.4.0 → 0.5.1

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/config.js CHANGED
@@ -3,6 +3,13 @@ import { parse as parseTOML } from "smol-toml";
3
3
  import { z } from "zod";
4
4
  export const ConfigSchema = z.object({
5
5
  port: z.number().int().positive().default(6800),
6
+ // Network interface to bind. Default "127.0.0.1" = loopback only
7
+ // (no LAN exposure). Set to "0.0.0.0" to listen on all IPv4
8
+ // interfaces, "::" for IPv6/dual-stack, or a specific NIC IP
9
+ // (e.g. "192.168.1.10") to bind one interface on a multi-homed
10
+ // host. Note: "localhost" works but resolves via DNS and may
11
+ // pick IPv6 (`::1`) over IPv4 — prefer the explicit IP form.
12
+ host: z.string().default("127.0.0.1"),
6
13
  data_dir: z.string().default("data"),
7
14
  default_cwd: z.string().default(process.cwd()),
8
15
  public_dir: z.string().default("dist"),
@@ -35,7 +42,7 @@ export const ConfigSchema = z.object({
35
42
  }),
36
43
  // [title] — title generation sub-session configuration.
37
44
  //
38
- // `model` is an array of case-insensitive substring patterns. When the
45
+ // `models` is an array of case-insensitive substring patterns. When the
39
46
  // title sub-session is created, we look at the model list the agent
40
47
  // reports (ACP `availableModels`) and pick the first model whose id
41
48
  // matches any pattern in order. Match → call `setConfigOption` with
@@ -50,17 +57,17 @@ export const ConfigSchema = z.object({
50
57
  // - "flash" → Google Gemini (gemini-*-flash)
51
58
  // - "lite" → Cohere, generic
52
59
  //
53
- // Set `model = []` to disable substring matching entirely and always
60
+ // Set `models = []` to disable substring matching entirely and always
54
61
  // inherit the agent's default model. To pin one specific model, pass a
55
- // single-element array: `model = ["claude-haiku-4.5"]`.
62
+ // single-element array: `models = ["claude-haiku-4.5"]`.
56
63
  title: z
57
64
  .object({
58
- model: z
65
+ models: z
59
66
  .array(z.string())
60
67
  .default(["haiku", "flash-lite", "nano", "mini", "flash", "lite"]),
61
68
  })
62
69
  .default({
63
- model: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
70
+ models: ["haiku", "flash-lite", "nano", "mini", "flash", "lite"],
64
71
  }),
65
72
  // [debug] — frontend log level.
66
73
  // level ∈ off | debug | info | warn | error. Default "off".
@@ -0,0 +1,64 @@
1
+ function valid(width, height) {
2
+ if (!Number.isInteger(width) || !Number.isInteger(height))
3
+ return null;
4
+ if (width <= 0 || height <= 0)
5
+ return null;
6
+ return { width, height };
7
+ }
8
+ export function readImageDimensions(buf) {
9
+ return (readPngDimensions(buf) ?? readGifDimensions(buf) ?? readJpegDimensions(buf));
10
+ }
11
+ function readPngDimensions(buf) {
12
+ // PNG: signature + IHDR width/height at fixed offsets.
13
+ if (buf.length >= 24 &&
14
+ buf[0] === 0x89 &&
15
+ buf[1] === 0x50 &&
16
+ buf[2] === 0x4e &&
17
+ buf[3] === 0x47 &&
18
+ buf.toString("ascii", 12, 16) === "IHDR") {
19
+ return valid(buf.readUInt32BE(16), buf.readUInt32BE(20));
20
+ }
21
+ return null;
22
+ }
23
+ function readGifDimensions(buf) {
24
+ // GIF87a/GIF89a: logical screen width/height, little-endian.
25
+ if (buf.length >= 10 &&
26
+ (buf.toString("ascii", 0, 6) === "GIF87a" ||
27
+ buf.toString("ascii", 0, 6) === "GIF89a")) {
28
+ return valid(buf.readUInt16LE(6), buf.readUInt16LE(8));
29
+ }
30
+ return null;
31
+ }
32
+ function isJpegSofMarker(marker) {
33
+ return ((marker >= 0xc0 && marker <= 0xc3) ||
34
+ (marker >= 0xc5 && marker <= 0xc7) ||
35
+ (marker >= 0xc9 && marker <= 0xcb) ||
36
+ (marker >= 0xcd && marker <= 0xcf));
37
+ }
38
+ function readJpegDimensions(buf) {
39
+ // JPEG: scan marker segments until a SOF marker carrying dimensions.
40
+ if (buf.length >= 4 && buf[0] === 0xff && buf[1] === 0xd8) {
41
+ let offset = 2;
42
+ while (offset + 3 < buf.length) {
43
+ if (buf[offset] !== 0xff) {
44
+ offset++;
45
+ continue;
46
+ }
47
+ while (offset < buf.length && buf[offset] === 0xff)
48
+ offset++;
49
+ const marker = buf[offset++];
50
+ if (marker === 0xd9 || marker === 0xda)
51
+ break;
52
+ if (offset + 1 >= buf.length)
53
+ break;
54
+ const length = buf.readUInt16BE(offset);
55
+ if (length < 2 || offset + length > buf.length)
56
+ break;
57
+ if (isJpegSofMarker(marker) && length >= 7) {
58
+ return valid(buf.readUInt16BE(offset + 5), buf.readUInt16BE(offset + 3));
59
+ }
60
+ offset += length;
61
+ }
62
+ }
63
+ return null;
64
+ }
@@ -0,0 +1,17 @@
1
+ /** Find the first available model whose id matches any pattern. */
2
+ export function pickModelByPatterns(configOptions, patterns) {
3
+ const normalized = patterns
4
+ .map((p) => p.trim().toLowerCase())
5
+ .filter((p) => p.length > 0);
6
+ if (normalized.length === 0)
7
+ return null;
8
+ const modelOpt = configOptions.find((c) => c.id === "model");
9
+ if (!modelOpt || modelOpt.options.length === 0)
10
+ return null;
11
+ for (const pattern of normalized) {
12
+ const hit = modelOpt.options.find((o) => o.value.toLowerCase().includes(pattern));
13
+ if (hit)
14
+ return hit.value;
15
+ }
16
+ return null;
17
+ }
package/lib/preflight.js CHANGED
@@ -120,52 +120,71 @@ function checkAgent(agentCmd) {
120
120
  };
121
121
  }
122
122
  /**
123
- * Probe whether `port` can be bound on 0.0.0.0 (matching what
124
- * `server.listen` actually uses). Listens, then closes immediately.
125
- * There's a tiny race window between close and the real
126
- * server.listen() — that's fine for diagnostics: the goal is a friendly
127
- * "port already in use" hint, not a hard guarantee.
123
+ * Probe whether `host:port` can be bound (matching what `server.listen`
124
+ * actually uses). Listens, then closes immediately. There's a tiny
125
+ * race window between close and the real server.listen() — that's
126
+ * fine for diagnostics: the goal is a friendly hint, not a hard
127
+ * guarantee.
128
128
  *
129
129
  * Port 0 means "let the OS pick"; we treat it as always-free.
130
+ *
131
+ * Probing the same host as the real server matters: a foreign
132
+ * listener on 0.0.0.0:PORT occupies 127.0.0.1:PORT too (more-specific
133
+ * bind fails when wildcard already bound), so binding the configured
134
+ * host catches conflicts and surfaces EADDRNOTAVAIL when the user
135
+ * typo'd an IP that isn't on any local interface.
130
136
  */
131
- async function checkPort(port) {
132
- if (port === 0) {
133
- return { ok: true, name: "port", detail: "0 (OS-assigned)" };
134
- }
135
- // Probe must bind to the same address family as the real server
136
- // (server.ts uses "0.0.0.0"). Probing 127.0.0.1 lets a foreign
137
- // listener on 0.0.0.0:PORT slip past preflight and only surface as
138
- // EADDRINUSE during the real server.listen() — exactly the case
139
- // we're trying to catch.
137
+ async function checkPort(port, host) {
138
+ const label = `${host}:${port}`;
139
+ // port=0 still gets probed: the OS picks any free port, but bind can
140
+ // still fail with EADDRNOTAVAIL if `host` isn't on any local
141
+ // interface that's exactly the typo we want to catch.
140
142
  const result = await new Promise((settle) => {
141
143
  const probe = createServer();
142
144
  probe.once("error", (err) => {
143
145
  settle({ code: err.code ?? "unknown" });
144
146
  });
145
- probe.listen(port, "0.0.0.0", () => {
147
+ probe.listen(port, host, () => {
148
+ const addr = probe.address();
149
+ const assigned = typeof addr === "object" && addr ? addr.port : port;
146
150
  probe.close(() => {
147
- settle({});
151
+ settle({ assigned });
148
152
  });
149
153
  });
150
154
  });
151
155
  if (!result.code) {
152
- return { ok: true, name: "port", detail: String(port) };
156
+ if (port === 0) {
157
+ return {
158
+ ok: true,
159
+ name: "port",
160
+ detail: `${host}:0 (OS-assigned → ${result.assigned})`,
161
+ };
162
+ }
163
+ return { ok: true, name: "port", detail: label };
153
164
  }
154
165
  if (result.code === "EADDRINUSE") {
155
166
  return {
156
167
  ok: false,
157
168
  name: "port",
158
- detail: `${port} (in use)`,
159
- hint: `port ${port} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
169
+ detail: `${label} (in use)`,
170
+ hint: `${label} is already in use (EADDRINUSE).\nfind the owner: ${process.platform === "win32"
160
171
  ? `netstat -ano | findstr :${port}`
161
172
  : `lsof -nP -iTCP:${port} -sTCP:LISTEN`}\nor change \`port\` in config.toml to a free port.`,
162
173
  };
163
174
  }
175
+ if (result.code === "EADDRNOTAVAIL") {
176
+ return {
177
+ ok: false,
178
+ name: "port",
179
+ detail: `${label} (${result.code})`,
180
+ hint: `cannot bind ${label}: ${result.code}\nthe host '${host}' is not assigned to any local interface.\ncheck \`host\` in config.toml — typical values are "127.0.0.1" (loopback) or "0.0.0.0" (all interfaces).`,
181
+ };
182
+ }
164
183
  return {
165
184
  ok: false,
166
185
  name: "port",
167
- detail: `${port} (${result.code})`,
168
- hint: `cannot bind port ${port}: ${result.code}\ncheck firewall / permissions, or change \`port\` in config.toml.`,
186
+ detail: `${label} (${result.code})`,
187
+ hint: `cannot bind ${label}: ${result.code}\ncheck firewall / permissions, or change \`host\`/\`port\` in config.toml.`,
169
188
  };
170
189
  }
171
190
  /**
@@ -179,7 +198,7 @@ export async function runPreflight(opts) {
179
198
  checks.push(checkDataDir(opts.data_dir));
180
199
  const agent = checkAgent(opts.agent_cmd);
181
200
  checks.push(agent);
182
- checks.push(await checkPort(opts.port));
201
+ checks.push(await checkPort(opts.port, opts.host));
183
202
  for (const c of checks) {
184
203
  if (c.ok)
185
204
  printOk(c);
@@ -46,18 +46,14 @@ export function isAppleEndpoint(endpoint) {
46
46
  return host === "web.push.apple.com" || host.endsWith(".push.apple.com");
47
47
  }
48
48
  function emptyClientState() {
49
- return { visible: false, sessionId: null, endpoint: null, visibleSince: 0 };
49
+ return { endpoint: null };
50
50
  }
51
51
  export class PushService {
52
52
  store;
53
53
  vapidKeys;
54
54
  /**
55
- * Consolidated per-client state. Previously 3 separate Maps
56
- * (clientVisibility/clientEndpoints/clientSessions) which drifted under
57
- * partial updates. v2 merges them; `visibleSince` stamps when the client
58
- * transitioned to visible so the server can TTL-expire "ghost" visibility
59
- * records left by iOS PWA process suspension (where the client never gets
60
- * to POST visible:false).
55
+ * Per-client transport state only the push endpoint. Visibility lives
56
+ * in ClientRegistry as of Plan C Step 4.
61
57
  */
62
58
  clients = new Map();
63
59
  /** endpoint → consecutive failure count (absent or 0 = healthy) */
@@ -65,7 +61,8 @@ export class PushService {
65
61
  globalVisibilitySuppression;
66
62
  visibilityTtlMs;
67
63
  now;
68
- constructor(store, dataDir, vapidSubject, options = {}) {
64
+ clientRegistry;
65
+ constructor(store, dataDir, vapidSubject, options) {
69
66
  this.store = store;
70
67
  this.vapidKeys = this.loadOrGenerateKeys(dataDir);
71
68
  webpush.setVapidDetails(vapidSubject, this.vapidKeys.publicKey, this.vapidKeys.privateKey);
@@ -73,6 +70,7 @@ export class PushService {
73
70
  options.globalVisibilitySuppression ?? true;
74
71
  this.visibilityTtlMs = options.visibilityTtlMs ?? 60_000;
75
72
  this.now = options.now ?? (() => Date.now());
73
+ this.clientRegistry = options.clientRegistry;
76
74
  }
77
75
  // ---------------------------------------------------------------------------
78
76
  // VAPID keys
@@ -123,43 +121,18 @@ export class PushService {
123
121
  return { kind: "notify", title, body, tag, data: { sessionId } };
124
122
  }
125
123
  // ---------------------------------------------------------------------------
126
- // Client visibility tracking
124
+ // Endpoint mapping (transport-only as of Plan C Step 4)
127
125
  // ---------------------------------------------------------------------------
128
126
  /**
129
- * Atomic consolidated setter. All visibility/session/endpoint updates
130
- * should route through here. Callers distinguish "preserve" from "clear"
131
- * by omitting the key vs passing `null`. Returns an edge flag so the
132
- * caller can fire edge-triggered side effects (e.g. sendClose) without
133
- * double-firing on every heartbeat refresh.
127
+ * Set the push endpoint for a client. Identity-layer state (visibility,
128
+ * active session) goes through ClientRegistry.setVisibility, not here.
134
129
  */
135
130
  updateClient(clientId, patch) {
136
131
  const prev = this.clients.get(clientId) ?? emptyClientState();
137
- const wasVisibleForSession = prev.visible && prev.sessionId != null ? prev.sessionId : null;
138
132
  const next = { ...prev };
139
- if (patch.visible !== undefined) {
140
- next.visible = patch.visible;
141
- next.visibleSince = patch.visible ? this.now() : 0;
142
- }
143
- if (patch.sessionId !== undefined)
144
- next.sessionId = patch.sessionId;
145
133
  if (patch.endpoint !== undefined)
146
134
  next.endpoint = patch.endpoint;
147
- const becameVisibleForSession = next.visible &&
148
- next.sessionId != null &&
149
- next.sessionId !== wasVisibleForSession
150
- ? next.sessionId
151
- : null;
152
- // Any transition into "visible + session X" restarts the TTL clock,
153
- // including a session-switch that arrives without an explicit
154
- // visible:true in the patch (e.g. a session_created POST that only
155
- // carries sessionId). Otherwise the TTL would keep counting from the
156
- // previous session's first-visible moment and could prematurely
157
- // declare the newly-focused session "stale".
158
- if (becameVisibleForSession) {
159
- next.visibleSince = this.now();
160
- }
161
135
  this.clients.set(clientId, next);
162
- return { becameVisibleForSession };
163
136
  }
164
137
  /**
165
138
  * Read-only snapshot for tests and diagnostics. Do NOT mutate the
@@ -168,40 +141,29 @@ export class PushService {
168
141
  getClientState(clientId) {
169
142
  return this.clients.get(clientId) ?? null;
170
143
  }
171
- /** @deprecated Shim: prefer `updateClient({ visible })`. */
172
- setClientVisibility(clientId, visible) {
173
- this.updateClient(clientId, { visible });
174
- }
175
- /** @deprecated Shim: prefer `updateClient({ sessionId })`. */
176
- setClientSession(clientId, sessionId) {
177
- this.updateClient(clientId, { sessionId });
178
- }
179
- /** @deprecated Shim: prefer `updateClient({ endpoint })`. */
180
144
  registerClient(clientId, endpoint) {
181
145
  this.updateClient(clientId, { endpoint });
182
146
  }
183
147
  removeClient(clientId) {
184
148
  this.clients.delete(clientId);
149
+ // Disconnect also wipes identity-layer state so visibility queries don't
150
+ // leak past the SSE lifetime. Production calls removeClient on SSE close
151
+ // (see sse-manager); tests expect the same.
152
+ this.clientRegistry.remove(clientId);
185
153
  }
186
154
  hasVisibleClient() {
187
- const now = this.now();
188
- for (const s of this.clients.values()) {
189
- if (s.visible && now - s.visibleSince <= this.visibilityTtlMs)
190
- return true;
191
- }
192
- return false;
155
+ return this.clientRegistry.hasAnyVisibleClient();
193
156
  }
194
157
  /** Check if a specific endpoint has at least one visible (non-stale) client. */
195
158
  isEndpointVisible(endpoint) {
196
- const now = this.now();
197
- for (const s of this.clients.values()) {
159
+ // Identity (visible) comes from registry, transport (endpoint↔clientId)
160
+ // stays with pushService.clients.
161
+ const reg = this.clientRegistry;
162
+ for (const [clientId, s] of this.clients) {
198
163
  if (s.endpoint !== endpoint)
199
164
  continue;
200
- if (!s.visible)
201
- continue;
202
- if (now - s.visibleSince > this.visibilityTtlMs)
203
- continue;
204
- return true;
165
+ if (reg.isClientVisible(clientId))
166
+ return true;
205
167
  }
206
168
  return false;
207
169
  }
@@ -219,17 +181,7 @@ export class PushService {
219
181
  isSessionVisibleToAnyClient(sessionId) {
220
182
  if (!this.globalVisibilitySuppression)
221
183
  return false;
222
- const now = this.now();
223
- for (const s of this.clients.values()) {
224
- if (!s.visible)
225
- continue;
226
- if (s.sessionId !== sessionId)
227
- continue;
228
- if (now - s.visibleSince > this.visibilityTtlMs)
229
- continue;
230
- return true;
231
- }
232
- return false;
184
+ return this.clientRegistry.isSessionVisibleToAnyClient(sessionId);
233
185
  }
234
186
  // ---------------------------------------------------------------------------
235
187
  // High-level: decide whether to push, and if so, send
package/lib/routes.js CHANGED
@@ -17,6 +17,7 @@ const slog = rlog.scope("session");
17
17
  const mlog = rlog.scope("msg");
18
18
  import { signAttachmentUrl, verifyAttachmentSig, reSignAttachmentUrlsInJson, } from "./auth.js";
19
19
  import { buildContentDisposition, classifyKind, isInlineMime, mimeToExt, normalizeDisplayName, sniffMime, } from "./attachments.js";
20
+ import { readImageDimensions } from "./image-dimensions.js";
20
21
  const IS_WIN = process.platform === "win32";
21
22
  const SAFE_ID = /^[a-zA-Z0-9_-]+$/;
22
23
  const MIME = {
@@ -30,6 +31,8 @@ const MIME = {
30
31
  ".jpeg": "image/jpeg",
31
32
  ".gif": "image/gif",
32
33
  ".webp": "image/webp",
34
+ ".woff2": "font/woff2",
35
+ ".wasm": "application/wasm",
33
36
  };
34
37
  /**
35
38
  * HTML entrypoints served by this app. Any new HTML page MUST be registered
@@ -51,7 +54,10 @@ export const HTML_ENTRYPOINTS = [
51
54
  *
52
55
  * - default-src 'self': everything same-origin only
53
56
  * - img-src adds data: + blob: for image-upload preview
54
- * - script-src 'self' (no inline; theme bootstrap is /theme-init.js)
57
+ * - script-src 'self' 'wasm-unsafe-eval' (no inline; theme bootstrap is
58
+ * /theme-init.js. 'wasm-unsafe-eval' is the narrow CSP3 token that
59
+ * permits WebAssembly.instantiate() WITHOUT re-enabling eval()/Function;
60
+ * needed by any wasm consumer.)
55
61
  * - style-src 'self' (no inline; login styles live in /styles.css)
56
62
  * - object-src 'none', frame-ancestors 'none', base-uri 'self', form-action 'self'
57
63
  * - connect-src 'self' for fetch + EventSource
@@ -59,7 +65,7 @@ export const HTML_ENTRYPOINTS = [
59
65
  export const CSP_POLICY = [
60
66
  "default-src 'self'",
61
67
  "img-src 'self' data: blob:",
62
- "script-src 'self'",
68
+ "script-src 'self' 'wasm-unsafe-eval'",
63
69
  "style-src 'self'",
64
70
  "connect-src 'self'",
65
71
  "object-src 'none'",
@@ -91,6 +97,20 @@ function getClientOpId(req) {
91
97
  return v;
92
98
  return null;
93
99
  }
100
+ function logPromptRejectBeforeSave(fields) {
101
+ plog.warn("rejected before save", {
102
+ sessionId: fields.sessionId.slice(0, 8),
103
+ status: fields.status,
104
+ reason: fields.reason,
105
+ ...(fields.opId ? { opId: fields.opId } : {}),
106
+ ...(fields.textLength != null ? { textLength: fields.textLength } : {}),
107
+ ...(fields.attachmentCount != null
108
+ ? { attachmentCount: fields.attachmentCount }
109
+ : {}),
110
+ ...(fields.busyKind ? { busyKind: fields.busyKind } : {}),
111
+ ...(fields.error ? { error: fields.error } : {}),
112
+ });
113
+ }
94
114
  function tryReplayClientOp(req, res, store, sessionId) {
95
115
  const opId = getClientOpId(req);
96
116
  if (!opId)
@@ -317,6 +337,7 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
317
337
  finalPath = join(dir, `${uploadId}.${fileExt}`);
318
338
  }
319
339
  }
340
+ const imageDimensions = kind === "image" ? readImageDimensions(head) : null;
320
341
  await rename(tmpPath, finalPath);
321
342
  const rp = await realpath(finalPath);
322
343
  const row = store.insertAttachment({
@@ -327,6 +348,8 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
327
348
  mime: fileMime,
328
349
  size: bytesWritten,
329
350
  realpath: rp,
351
+ width: imageDimensions?.width ?? null,
352
+ height: imageDimensions?.height ?? null,
330
353
  });
331
354
  // Invalidate the per-session attachment label cache so the
332
355
  // next egress (SSE broadcast or replay) sees this new row.
@@ -344,6 +367,8 @@ async function handleAttachmentUpload(req, res, sessionId, deps) {
344
367
  displayName: row.name,
345
368
  mimeType: row.mime,
346
369
  size: row.size,
370
+ width: row.width,
371
+ height: row.height,
347
372
  kind: row.kind,
348
373
  path: `sessions/${sessionId}/attachments/${fileName}`,
349
374
  url: fileUrl,
@@ -779,17 +804,36 @@ export function createRequestHandler(deps) {
779
804
  const promptMatch = url.match(/^\/api\/v1\/sessions\/([^/]+)\/prompt\/?(\?.*)?$/);
780
805
  if (promptMatch && req.method === "POST") {
781
806
  const sessionId = decodeURIComponent(promptMatch[1]);
807
+ const requestOpId = getClientOpId(req);
782
808
  const session = store.getSession(sessionId);
783
809
  if (!session) {
810
+ logPromptRejectBeforeSave({
811
+ sessionId,
812
+ status: 404,
813
+ reason: "session_not_found",
814
+ opId: requestOpId,
815
+ });
784
816
  json(res, 404, { error: "Session not found" });
785
817
  return;
786
818
  }
787
819
  const bridge = getBridge?.();
788
820
  if (!bridge) {
821
+ logPromptRejectBeforeSave({
822
+ sessionId,
823
+ status: 503,
824
+ reason: "agent_not_ready",
825
+ opId: requestOpId,
826
+ });
789
827
  json(res, 503, { error: "Agent not ready yet" });
790
828
  return;
791
829
  }
792
830
  if (!sessions) {
831
+ logPromptRejectBeforeSave({
832
+ sessionId,
833
+ status: 503,
834
+ reason: "session_manager_unavailable",
835
+ opId: requestOpId,
836
+ });
793
837
  json(res, 503, { error: "Session manager not available" });
794
838
  return;
795
839
  }
@@ -801,6 +845,13 @@ export function createRequestHandler(deps) {
801
845
  await sessions.ensureResumed(bridge, sessionId);
802
846
  }
803
847
  catch (err) {
848
+ logPromptRejectBeforeSave({
849
+ sessionId,
850
+ status: 500,
851
+ reason: "resume_failed",
852
+ opId,
853
+ error: errorMessage(err),
854
+ });
804
855
  json(res, 500, {
805
856
  error: `Failed to resume session: ${err instanceof Error ? err.message : String(err)}`,
806
857
  });
@@ -809,6 +860,13 @@ export function createRequestHandler(deps) {
809
860
  // Check if session is busy
810
861
  const busyKind = sessions.getBusyKind(sessionId);
811
862
  if (busyKind) {
863
+ logPromptRejectBeforeSave({
864
+ sessionId,
865
+ status: 409,
866
+ reason: "session_busy",
867
+ opId,
868
+ busyKind,
869
+ });
812
870
  json(res, 409, { error: "Session is busy", busyKind });
813
871
  return;
814
872
  }
@@ -817,10 +875,26 @@ export function createRequestHandler(deps) {
817
875
  body = JSON.parse(await readBody(req));
818
876
  }
819
877
  catch {
878
+ logPromptRejectBeforeSave({
879
+ sessionId,
880
+ status: 400,
881
+ reason: "invalid_json",
882
+ opId,
883
+ });
820
884
  json(res, 400, { error: "Invalid JSON" });
821
885
  return;
822
886
  }
823
887
  if (!body.text) {
888
+ logPromptRejectBeforeSave({
889
+ sessionId,
890
+ status: 400,
891
+ reason: "missing_text",
892
+ opId,
893
+ textLength: 0,
894
+ attachmentCount: Array.isArray(body.attachments)
895
+ ? body.attachments.length
896
+ : undefined,
897
+ });
824
898
  json(res, 400, { error: "Missing required field: text" });
825
899
  return;
826
900
  }
@@ -830,6 +904,13 @@ export function createRequestHandler(deps) {
830
904
  const attachments = body.attachments;
831
905
  if (attachments) {
832
906
  if (!Array.isArray(attachments)) {
907
+ logPromptRejectBeforeSave({
908
+ sessionId,
909
+ status: 400,
910
+ reason: "attachments_not_array",
911
+ opId,
912
+ textLength: body.text.length,
913
+ });
833
914
  json(res, 400, { error: "attachments must be an array" });
834
915
  return;
835
916
  }
@@ -841,14 +922,32 @@ export function createRequestHandler(deps) {
841
922
  typeof att.attachmentId !== "string" ||
842
923
  typeof att.displayName !== "string" ||
843
924
  typeof att.mimeType !== "string") {
925
+ logPromptRejectBeforeSave({
926
+ sessionId,
927
+ status: 400,
928
+ reason: "invalid_attachment_entry",
929
+ opId,
930
+ textLength: body.text.length,
931
+ attachmentCount: attachments.length,
932
+ });
844
933
  json(res, 400, { error: "Invalid attachment entry" });
845
934
  return;
846
935
  }
847
936
  if (typeof att.uri === "string" ||
848
937
  typeof att.data === "string" ||
849
- typeof att.path === "string") {
938
+ typeof att.path === "string" ||
939
+ typeof att.width === "number" ||
940
+ typeof att.height === "number") {
941
+ logPromptRejectBeforeSave({
942
+ sessionId,
943
+ status: 400,
944
+ reason: "client_supplied_attachment_data",
945
+ opId,
946
+ textLength: body.text.length,
947
+ attachmentCount: attachments.length,
948
+ });
850
949
  json(res, 400, {
851
- error: "Client must not supply uri/data/path",
950
+ error: "Client must not supply uri/data/path/width/height",
852
951
  });
853
952
  return;
854
953
  }
@@ -873,6 +972,9 @@ export function createRequestHandler(deps) {
873
972
  displayName: a.displayName,
874
973
  mimeType: a.mimeType,
875
974
  path: `/api/v1/sessions/${sessionId}/attachments/${fileName}`,
975
+ ...(row.width != null && row.height != null
976
+ ? { width: row.width, height: row.height }
977
+ : {}),
876
978
  },
877
979
  ];
878
980
  });
@@ -1885,21 +1987,27 @@ export function createRequestHandler(deps) {
1885
1987
  else {
1886
1988
  sessionIdPatch = null;
1887
1989
  }
1888
- if (deps.pushService) {
1889
- const { becameVisibleForSession } = deps.pushService.updateClient(clientId, {
1990
+ if (deps.clientRegistry) {
1991
+ // setVisibility no-ops on unknown clients; auto-register here so
1992
+ // SSE-only clients (which haven't sent /hello yet) still take
1993
+ // effect. The trust boundary above already gated on identity.
1994
+ if (!deps.clientRegistry.get(clientId)) {
1995
+ deps.clientRegistry.register(clientId, { capabilities: [] });
1996
+ }
1997
+ const { becameVisibleFor } = deps.clientRegistry.setVisibility(clientId, {
1890
1998
  visible: body.visible,
1891
- sessionId: sessionIdPatch,
1999
+ active: sessionIdPatch,
1892
2000
  });
1893
2001
  // Edge-triggered only: heartbeat refreshes repeat the same
1894
2002
  // (visible:true, sessionId:X) POST every 15s — firing sendClose
1895
2003
  // on each would hammer banner recall. Only the first such
1896
2004
  // transition after a change should recall stale banners.
1897
- if (becameVisibleForSession) {
1898
- void deps.pushService.sendClose(`sess-${becameVisibleForSession}-done`);
2005
+ if (becameVisibleFor && deps.pushService) {
2006
+ void deps.pushService.sendClose(`sess-${becameVisibleFor}-done`);
1899
2007
  if (sessions) {
1900
2008
  for (const perm of sessions.pendingPermissions.values()) {
1901
- if (perm.sessionId === becameVisibleForSession) {
1902
- void deps.pushService.sendClose(`sess-${becameVisibleForSession}-perm-${perm.requestId}`);
2009
+ if (perm.sessionId === becameVisibleFor) {
2010
+ void deps.pushService.sendClose(`sess-${becameVisibleFor}-perm-${perm.requestId}`);
1903
2011
  }
1904
2012
  }
1905
2013
  }
@@ -2014,7 +2122,11 @@ export function createRequestHandler(deps) {
2014
2122
  const ext = extname(filePath);
2015
2123
  const base = filePath.slice(filePath.lastIndexOf("/") + 1);
2016
2124
  const isHashedAsset = /\.[A-Za-z0-9_-]{8,}\.(js|css)$/.test(base);
2017
- const cacheControl = isHashedAsset
2125
+ // `/lib/**` is vendored third-party content pinned by upstream SHA in
2126
+ // package metadata — treat as immutable just like content-hashed bundles.
2127
+ // Avoids re-downloading multi-hundred-KB wasm on every consumer init.
2128
+ const isVendoredLib = staticPath.startsWith("/lib/");
2129
+ const cacheControl = isHashedAsset || isVendoredLib
2018
2130
  ? "public, max-age=31536000, immutable"
2019
2131
  : "no-cache";
2020
2132
  const headers = {