@ashlr/hub 3.8.0 → 3.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.
Files changed (147) hide show
  1. package/CHANGELOG.md +201 -0
  2. package/dist/build-identity.json +2 -2
  3. package/dist/cli/resource-profile.js +95 -21
  4. package/dist/cli/resource-profile.js.map +1 -1
  5. package/dist/core/resources/native-profile.d.ts +56 -0
  6. package/dist/core/resources/native-profile.js +368 -6
  7. package/dist/core/resources/native-profile.js.map +1 -1
  8. package/dist/core/run/model-catalog.js +8 -1
  9. package/dist/core/run/model-catalog.js.map +1 -1
  10. package/dist/core/universe/builtins/preparation/manifest.json +1 -1
  11. package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +9 -2
  12. package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +8 -1
  13. package/dist/core/verse/adapters/claude.d.ts +49 -2
  14. package/dist/core/verse/adapters/claude.js +317 -56
  15. package/dist/core/verse/adapters/claude.js.map +1 -1
  16. package/dist/core/verse/adapters/codex.d.ts +81 -5
  17. package/dist/core/verse/adapters/codex.js +409 -47
  18. package/dist/core/verse/adapters/codex.js.map +1 -1
  19. package/dist/core/verse/adapters/grok.d.ts +13 -1
  20. package/dist/core/verse/adapters/grok.js +28 -2
  21. package/dist/core/verse/adapters/grok.js.map +1 -1
  22. package/dist/core/verse/adapters/index.d.ts +32 -0
  23. package/dist/core/verse/adapters/index.js +2 -0
  24. package/dist/core/verse/adapters/index.js.map +1 -1
  25. package/dist/core/verse/codex-rollout.d.ts +284 -0
  26. package/dist/core/verse/codex-rollout.js +786 -0
  27. package/dist/core/verse/codex-rollout.js.map +1 -0
  28. package/dist/core/verse/context-fit.d.ts +63 -0
  29. package/dist/core/verse/context-fit.js +355 -0
  30. package/dist/core/verse/context-fit.js.map +1 -0
  31. package/dist/core/verse/context-math.d.ts +215 -0
  32. package/dist/core/verse/context-math.js +365 -0
  33. package/dist/core/verse/context-math.js.map +1 -0
  34. package/dist/core/verse/local-models.d.ts +137 -1
  35. package/dist/core/verse/local-models.js +209 -5
  36. package/dist/core/verse/local-models.js.map +1 -1
  37. package/dist/core/verse/model-windows.d.ts +217 -0
  38. package/dist/core/verse/model-windows.js +616 -0
  39. package/dist/core/verse/model-windows.js.map +1 -0
  40. package/dist/core/verse/preferences.d.ts +123 -0
  41. package/dist/core/verse/preferences.js +403 -0
  42. package/dist/core/verse/preferences.js.map +1 -0
  43. package/dist/core/verse/project-memory.d.ts +112 -0
  44. package/dist/core/verse/project-memory.js +295 -0
  45. package/dist/core/verse/project-memory.js.map +1 -0
  46. package/dist/core/verse/seats.d.ts +106 -4
  47. package/dist/core/verse/seats.js +291 -89
  48. package/dist/core/verse/seats.js.map +1 -1
  49. package/dist/core/verse/session-engine.d.ts +76 -2
  50. package/dist/core/verse/session-engine.js +684 -63
  51. package/dist/core/verse/session-engine.js.map +1 -1
  52. package/dist/core/verse/session-handoff.d.ts +94 -0
  53. package/dist/core/verse/session-handoff.js +551 -0
  54. package/dist/core/verse/session-handoff.js.map +1 -0
  55. package/dist/core/verse/session-search.d.ts +63 -0
  56. package/dist/core/verse/session-search.js +213 -0
  57. package/dist/core/verse/session-search.js.map +1 -0
  58. package/dist/core/verse/session-store.d.ts +16 -2
  59. package/dist/core/verse/session-store.js +80 -6
  60. package/dist/core/verse/session-store.js.map +1 -1
  61. package/dist/core/verse/types.d.ts +333 -1
  62. package/dist/core/verse/types.js +54 -2
  63. package/dist/core/verse/types.js.map +1 -1
  64. package/dist/core/verse/verse-api.d.ts +40 -5
  65. package/dist/core/verse/verse-api.js +724 -25
  66. package/dist/core/verse/verse-api.js.map +1 -1
  67. package/dist/core/web/api.d.ts +14 -4
  68. package/dist/core/web/api.js +26 -11
  69. package/dist/core/web/api.js.map +1 -1
  70. package/dist/core/web/public/next/assets/{App-BsbJFA2j.js → App-XURmiAAp.js} +5 -5
  71. package/dist/core/web/public/next/assets/{App-BsbJFA2j.js.map → App-XURmiAAp.js.map} +1 -1
  72. package/dist/core/web/public/next/assets/{ApprovalsSection-2CCSoBTJ.js → ApprovalsSection-BriUzoZc.js} +2 -2
  73. package/dist/core/web/public/next/assets/{ApprovalsSection-2CCSoBTJ.js.map → ApprovalsSection-BriUzoZc.js.map} +1 -1
  74. package/dist/core/web/public/next/assets/AutonomySection-C4GBsQVc.js +2 -0
  75. package/dist/core/web/public/next/assets/{AutonomySection-yeNNUPur.js.map → AutonomySection-C4GBsQVc.js.map} +1 -1
  76. package/dist/core/web/public/next/assets/ChatSection-BpOBOUvg.css +1 -0
  77. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js +84 -0
  78. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js.map +1 -0
  79. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js +2 -0
  80. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js.map +1 -0
  81. package/dist/core/web/public/next/assets/{Dialog-nK15L1M0.js → Dialog-BxdIfmFH.js} +2 -2
  82. package/dist/core/web/public/next/assets/{Dialog-nK15L1M0.js.map → Dialog-BxdIfmFH.js.map} +1 -1
  83. package/dist/core/web/public/next/assets/{DiffViewer-D369eZe8.js → DiffViewer-DmeRWuc4.js} +2 -2
  84. package/dist/core/web/public/next/assets/{DiffViewer-D369eZe8.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
  85. package/dist/core/web/public/next/assets/{McpSection-DVgiSYG1.js → McpSection-q34T7Tho.js} +2 -2
  86. package/dist/core/web/public/next/assets/{McpSection-DVgiSYG1.js.map → McpSection-q34T7Tho.js.map} +1 -1
  87. package/dist/core/web/public/next/assets/{MutationTokenDialog-B9I4PAcp.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
  88. package/dist/core/web/public/next/assets/{MutationTokenDialog-B9I4PAcp.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
  89. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-z6tJj3s9.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
  90. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-z6tJj3s9.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
  91. package/dist/core/web/public/next/assets/{RouteErrorBoundary-OSajL45F.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
  92. package/dist/core/web/public/next/assets/{RouteErrorBoundary-OSajL45F.js.map → RouteErrorBoundary-ZL9F4qUn.js.map} +1 -1
  93. package/dist/core/web/public/next/assets/{Meter-CLcr-4wS.css → Segmented-8cf0Fypn.css} +1 -1
  94. package/dist/core/web/public/next/assets/{Meter-Cmo1pLbk.js → Segmented-DbRjVye3.js} +2 -2
  95. package/dist/core/web/public/next/assets/Segmented-DbRjVye3.js.map +1 -0
  96. package/dist/core/web/public/next/assets/SettingsSection-nJGGGYqv.js +2 -0
  97. package/dist/core/web/public/next/assets/{SettingsSection-B3YH2Npi.js.map → SettingsSection-nJGGGYqv.js.map} +1 -1
  98. package/dist/core/web/public/next/assets/UniverseConsoleApp-6GJydgo4.js +2 -0
  99. package/dist/core/web/public/next/assets/{UniverseConsoleApp-wEKIzl1M.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
  100. package/dist/core/web/public/next/assets/{UniverseView-CQVpJqqI.js → UniverseView-DLDtNYL8.js} +2 -2
  101. package/dist/core/web/public/next/assets/{UniverseView-CQVpJqqI.js.map → UniverseView-DLDtNYL8.js.map} +1 -1
  102. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js +2 -0
  103. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js.map +1 -0
  104. package/dist/core/web/public/next/assets/VerseConsoleApp-DsdKQoTH.css +1 -0
  105. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js +3 -0
  106. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js.map +1 -0
  107. package/dist/core/web/public/next/assets/{charts-BopViIha.js → charts-CVooqJWN.js} +2 -2
  108. package/dist/core/web/public/next/assets/{charts-BopViIha.js.map → charts-CVooqJWN.js.map} +1 -1
  109. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js +2 -0
  110. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js.map +1 -0
  111. package/dist/core/web/public/next/assets/global-eaY9GyX3.js +2 -0
  112. package/dist/core/web/public/next/assets/global-eaY9GyX3.js.map +1 -0
  113. package/dist/core/web/public/next/assets/{icons-L06pQH6j.js → icons-vex8rclC.js} +2 -2
  114. package/dist/core/web/public/next/assets/{icons-L06pQH6j.js.map → icons-vex8rclC.js.map} +1 -1
  115. package/dist/core/web/public/next/assets/{index-CXgVQHek.js → index-D2Eik-zt.js} +3 -3
  116. package/dist/core/web/public/next/assets/{index-CXgVQHek.js.map → index-D2Eik-zt.js.map} +1 -1
  117. package/dist/core/web/public/next/assets/{queries-D3sSlan1.js → queries-bAYU0VNy.js} +2 -2
  118. package/dist/core/web/public/next/assets/{queries-D3sSlan1.js.map → queries-bAYU0VNy.js.map} +1 -1
  119. package/dist/core/web/public/next/assets/use-guarded-action-C7QrP7Yp.js +2 -0
  120. package/dist/core/web/public/next/assets/{use-guarded-action-BxfJ9E-n.js.map → use-guarded-action-C7QrP7Yp.js.map} +1 -1
  121. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js +2 -0
  122. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js.map +1 -0
  123. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js → workspace-model-CHol5RVp.js} +2 -2
  124. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js.map → workspace-model-CHol5RVp.js.map} +1 -1
  125. package/dist/core/web/public/next/index.html +1 -1
  126. package/dist/release-dependency-inventory.json +1 -1
  127. package/docs/README.md +1 -0
  128. package/package.json +1 -1
  129. package/dist/core/web/public/next/assets/AutonomySection-yeNNUPur.js +0 -2
  130. package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
  131. package/dist/core/web/public/next/assets/ChatSection-DwDDnCZQ.js +0 -80
  132. package/dist/core/web/public/next/assets/ChatSection-DwDDnCZQ.js.map +0 -1
  133. package/dist/core/web/public/next/assets/ConfirmDialog-C363ty_c.js +0 -2
  134. package/dist/core/web/public/next/assets/ConfirmDialog-C363ty_c.js.map +0 -1
  135. package/dist/core/web/public/next/assets/Meter-Cmo1pLbk.js.map +0 -1
  136. package/dist/core/web/public/next/assets/SettingsSection-B3YH2Npi.js +0 -2
  137. package/dist/core/web/public/next/assets/UniverseConsoleApp-wEKIzl1M.js +0 -2
  138. package/dist/core/web/public/next/assets/UsageSection-dToBThXe.js +0 -2
  139. package/dist/core/web/public/next/assets/UsageSection-dToBThXe.js.map +0 -1
  140. package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
  141. package/dist/core/web/public/next/assets/VerseConsoleApp-DHU32JCQ.js +0 -3
  142. package/dist/core/web/public/next/assets/VerseConsoleApp-DHU32JCQ.js.map +0 -1
  143. package/dist/core/web/public/next/assets/global-DMWakEhP.js +0 -2
  144. package/dist/core/web/public/next/assets/global-DMWakEhP.js.map +0 -1
  145. package/dist/core/web/public/next/assets/use-guarded-action-BxfJ9E-n.js +0 -2
  146. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js +0 -2
  147. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js.map +0 -1
@@ -6,10 +6,10 @@
6
6
  * - GETs sit behind the read-session boundary in server.ts.
7
7
  * - POSTs are 404 unless ctx.allowDispatch, then passesMutationGate()
8
8
  * (constant-time x-ashlr-token + JSON Content-Type), then readBody()
9
- * (64 KB cap).
9
+ * (64 KB cap; POST /memory alone gets VERSE_MEMORY_BODY_MAX_BYTES).
10
10
  * - Every response goes through sendJson() → sanitizePublicJson().
11
11
  *
12
- * Routes (see docs/VERSE-CONTRACT-V1.md):
12
+ * Routes (see docs/VERSE-CONTRACT-V1.md, docs/VERSE-CONTEXT.md for V3.9):
13
13
  * GET /api/verse/bootstrap → VerseBootstrap
14
14
  * GET /api/verse/seats → VerseSeatsResponse (pollable)
15
15
  * GET /api/verse/sessions → VerseSession[]
@@ -20,28 +20,57 @@
20
20
  * POST /api/verse/sessions/:id/delete → { ok: true }
21
21
  * POST /api/verse/sessions/:id/rename → VerseSession
22
22
  * GET /api/verse/sessions/:id/events → SSE (verse-stream.ts)
23
+ * V3.9 context orchestration — every one of these is ZERO SPEND: none starts
24
+ * a model call; the only way a token is spent is still POST .../turns.
25
+ * POST /api/verse/sessions/:id/context-mode → VerseSession
26
+ * POST /api/verse/sessions/:id/handoff-preview → VerseHandoffPreview
27
+ * GET /api/verse/preferences → VersePreferences
28
+ * POST /api/verse/preferences → VersePreferences
29
+ * GET /api/verse/context-fit → VerseContextFit
30
+ * GET /api/verse/search → VerseSearchResponse
31
+ * GET /api/verse/memory → VerseProjectMemory (+ contentSanitized?)
32
+ * POST /api/verse/memory → VerseProjectMemory (+ contentSanitized?)
23
33
  *
24
34
  * Errors: { error, code? } with VERSE_SESSION_NOT_FOUND 404,
25
- * VERSE_SESSION_BUSY 409, VERSE_INVALID 400, VERSE_TOO_LARGE 413.
35
+ * VERSE_SESSION_BUSY 409, VERSE_INVALID 400, VERSE_TOO_LARGE 413, and two
36
+ * route-local 409s: VERSE_MODEL_UNAVAILABLE (turns: the session's model is
37
+ * listed but unrunnable on its seat) and VERSE_MEMORY_REDACTED (memory: the
38
+ * content would write redaction placeholders over real values).
39
+ *
40
+ * STRICT BODIES AND QUERIES (V3.9 routes and POST /sessions): an unknown body
41
+ * key or query parameter is a 400, never silently ignored — a misspelt
42
+ * `contextMode` would otherwise create a session in a mode nobody asked for.
26
43
  *
27
44
  * ENGINE LIFETIME: one engine handle per server process — a module-level
28
45
  * lazy singleton (`getVerseEngine()`), with `resetVerseEngine()` for tests to
29
46
  * inject a fake or force re-creation under a relocated HOME. The engine
30
47
  * module is loaded lazily (dynamic import) so this file has no load-time
31
- * dependency on it; tests that inject a fake never touch it.
48
+ * dependency on it; tests that inject a fake never touch it. The V3.9 service
49
+ * modules keep that property: they report bad input with their own
50
+ * `VerseServiceError` (same `code` vocabulary, mapped by duck type below)
51
+ * rather than importing the engine's `VerseError`.
32
52
  *
33
53
  * PRIVACY: seat launchers (the native-profile commands) come from seats.ts's
34
54
  * private `launches` map and go straight to engine.createSession(). They are
35
55
  * never part of any response, log line, or session record this file writes.
56
+ * The same holds for the shared-memory snapshot (`VerseCreateOptions.memory`):
57
+ * its directory and prompt block go into the private launch record only.
36
58
  */
37
59
  import { homedir } from 'node:os';
38
60
  import { join } from 'node:path';
39
61
  import { passesMutationGate, readBody, sendJson } from '../web/api.js';
62
+ import { sanitizePublicJson } from '../util/public-json.js';
63
+ import { budgetFor, canonicalModelId, hasExpansiveMode } from './context-math.js';
64
+ import { estimateContextFit } from './context-fit.js';
40
65
  import { checkWorkspaceRootPath, expandHomePrefix } from './path-guard.js';
66
+ import { loadVersePreferences, memoryEnabledFor, parseVersePreferencesUpdate, seatDefaultMode, updateVersePreferences, } from './preferences.js';
67
+ import { prepareProjectMemory, readProjectMemory, writeProjectMemory } from './project-memory.js';
41
68
  import { discoverProjects } from './projects.js';
42
69
  import { discoverSeats, refreshSeatTelemetry } from './seats.js';
70
+ import { buildHandoffPreview } from './session-handoff.js';
71
+ import { searchSessions } from './session-search.js';
43
72
  import { buildAutonomyScopeView, createVerseWorkspaceStore, describeRoots, rootNotes, VerseWorkspaceError, } from './workspaces.js';
44
- import { VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_ROOT_PRIORITIES, verseSessionRoots, } from './types.js';
73
+ import { VERSE_CONTEXT_MODES, VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_MEMORY_MAX_BYTES, VERSE_ROOT_PRIORITIES, verseSessionRoots, VERSE_MEMORY_BODY_MAX_BYTES, } from './types.js';
45
74
  import { handleVerseEventsSse, VERSE_EVENTS_PATH_RE, VERSE_SESSION_ID_RE } from './verse-stream.js';
46
75
  // ---------------------------------------------------------------------------
47
76
  // Route matching
@@ -218,11 +247,15 @@ function sendInvalid(res, message) {
218
247
  function isRecord(value) {
219
248
  return value !== null && typeof value === 'object' && !Array.isArray(value);
220
249
  }
221
- /** readBody() + JSON.parse with the contract's error shape. Returns null after responding. */
222
- async function readJsonBody(req, res) {
250
+ /**
251
+ * readBody() + JSON.parse with the contract's error shape. Returns null after
252
+ * responding. `maxBytes` is omitted by every route but POST /memory, so each
253
+ * of them keeps readBody's shared 64 KB cap.
254
+ */
255
+ async function readJsonBody(req, res, maxBytes) {
223
256
  let raw;
224
257
  try {
225
- raw = await readBody(req);
258
+ raw = await readBody(req, maxBytes);
226
259
  }
227
260
  catch {
228
261
  sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: 'request body too large' });
@@ -244,6 +277,80 @@ async function readJsonBody(req, res) {
244
277
  }
245
278
  const MAX_TITLE_CHARS = 200;
246
279
  const MAX_PATH_CHARS = 4096;
280
+ /**
281
+ * Respond 400 and return true when `body` carries a key outside `allowed`.
282
+ *
283
+ * Unknown keys are refused rather than ignored for the same reason
284
+ * control-api.ts refuses them: a misspelt field (`contextmode`) that is
285
+ * silently dropped produces a request that "worked" and did something other
286
+ * than what the operator asked for.
287
+ */
288
+ function rejectUnknownKeys(body, allowed, res) {
289
+ for (const key of Object.keys(body)) {
290
+ if (!allowed.has(key)) {
291
+ sendInvalid(res, `unknown key: ${key}`);
292
+ return true;
293
+ }
294
+ }
295
+ return false;
296
+ }
297
+ /**
298
+ * Parse the query string of a V3.9 GET under the same strictness as a body:
299
+ * an unknown parameter is a 400, and a parameter that is not `repeatable` may
300
+ * appear at most once (two `projectPath`s have no honest meaning). Returns
301
+ * null after responding.
302
+ *
303
+ * The read boundary's `?client=` proof never reaches these routes — only the
304
+ * SSE paths accept it (web/read-session.ts) — so no auth parameter needs to be
305
+ * tolerated here.
306
+ */
307
+ function readQuery(req, res, allowed, repeatable = []) {
308
+ let params;
309
+ try {
310
+ params = new URL(req.url ?? '/', 'http://localhost').searchParams;
311
+ }
312
+ catch {
313
+ sendInvalid(res, 'invalid query string');
314
+ return null;
315
+ }
316
+ for (const key of new Set(params.keys())) {
317
+ if (!allowed.includes(key)) {
318
+ sendInvalid(res, `unknown query parameter: ${key}`);
319
+ return null;
320
+ }
321
+ if (!repeatable.includes(key) && params.getAll(key).length > 1) {
322
+ sendInvalid(res, `query parameter ${key} may appear only once`);
323
+ return null;
324
+ }
325
+ }
326
+ return params;
327
+ }
328
+ function isContextMode(value) {
329
+ return typeof value === 'string' && VERSE_CONTEXT_MODES.includes(value);
330
+ }
331
+ const CONTEXT_MODE_LIST = VERSE_CONTEXT_MODES.join(', ');
332
+ /**
333
+ * Validate one project/root path with EXACTLY the rule POST /sessions uses
334
+ * (`checkWorkspaceRootPath`: absolute, an existing directory, never `/`,
335
+ * `$HOME`, `~/.ashlr` or `~/.codex/artifacts`, lexically or through a
336
+ * symlink). Every V3.9 route that takes a path goes through here, so a path
337
+ * that could never be a session root can never be measured, remembered or
338
+ * opted out either.
339
+ *
340
+ * `path` is the `~`-expanded spelling the caller sent (what the engine would
341
+ * receive); `physical` is the resolved one the memory and preference stores
342
+ * key on, so `/tmp/x` and `/private/tmp/x` are one project.
343
+ */
344
+ function guardRoot(raw, field) {
345
+ if (typeof raw !== 'string' || raw.trim().length === 0 || raw.length > MAX_PATH_CHARS) {
346
+ return { ok: false, error: `${field} is required` };
347
+ }
348
+ const trimmed = raw.trim();
349
+ const check = checkWorkspaceRootPath(trimmed);
350
+ if (!check.ok)
351
+ return { ok: false, error: check.error };
352
+ return { ok: true, path: expandHomePrefix(trimmed), physical: check.path };
353
+ }
247
354
  /**
248
355
  * sanitizePublicJson() rewrites the home directory as `~` on every outbound
249
356
  * payload, so a project path the UI read from bootstrap comes back as
@@ -275,12 +382,54 @@ function parseSeatFields(body, res) {
275
382
  return null;
276
383
  }
277
384
  const out = { seatId: seatId.trim() };
385
+ // Canonicalised, never refused, when it is a retired alias: a client that
386
+ // remembered `claude-opus-5.5` from an older session (the per-project seat
387
+ // memory does exactly that) means the model the label promised, and the
388
+ // canonical id is what the seat catalog lists — and what the CLI resolves.
278
389
  if (typeof model === 'string')
279
- out.model = model;
390
+ out.model = canonicalModelId(model);
280
391
  if (typeof title === 'string' && title.trim().length > 0)
281
392
  out.title = title.trim();
282
393
  return out;
283
394
  }
395
+ /**
396
+ * Keys POST /api/verse/sessions accepts. `workspaceName` is deliberately
397
+ * absent: it is SERVER-FILLED from the registry (types.ts), and is refused
398
+ * with its own message below rather than as an unknown key.
399
+ */
400
+ const CREATE_SESSION_KEYS = new Set([
401
+ 'projectPath',
402
+ 'seatId',
403
+ 'model',
404
+ 'title',
405
+ 'extraRoots',
406
+ 'workspaceId',
407
+ 'contextMode',
408
+ 'handoffFromSessionId',
409
+ ]);
410
+ /**
411
+ * The V3.9 half of a create request: an EXPLICIT context mode and the handoff
412
+ * source id. Both are only shape-checked here — whether the mode exists for
413
+ * the chosen model, and whether the source session exists, are decided once
414
+ * the seat and engine are in hand (`resolveCreation`). Returns null after
415
+ * responding.
416
+ */
417
+ function parseContextFields(body, res) {
418
+ const contextMode = body['contextMode'];
419
+ const handoffFrom = body['handoffFromSessionId'];
420
+ if (contextMode !== undefined && !isContextMode(contextMode)) {
421
+ sendInvalid(res, `contextMode must be one of: ${CONTEXT_MODE_LIST}`);
422
+ return null;
423
+ }
424
+ if (handoffFrom !== undefined && (typeof handoffFrom !== 'string' || !VERSE_SESSION_ID_RE.test(handoffFrom))) {
425
+ sendInvalid(res, 'handoffFromSessionId must be a session id');
426
+ return null;
427
+ }
428
+ return {
429
+ ...(contextMode !== undefined ? { contextMode } : {}),
430
+ ...(typeof handoffFrom === 'string' ? { handoffFromSessionId: handoffFrom } : {}),
431
+ };
432
+ }
284
433
  /**
285
434
  * Parse a create request in either of its two spellings.
286
435
  *
@@ -294,9 +443,18 @@ function parseSeatFields(body, res) {
294
443
  * KILL sentinel, the 0600 launcher records) can never become a session root.
295
444
  */
296
445
  function parseCreateRequest(body, res, store) {
446
+ if (body['workspaceName'] !== undefined) {
447
+ sendInvalid(res, 'workspaceName is filled in by the server from workspaceId; do not send it');
448
+ return null;
449
+ }
450
+ if (rejectUnknownKeys(body, CREATE_SESSION_KEYS, res))
451
+ return null;
297
452
  const seatFields = parseSeatFields(body, res);
298
453
  if (!seatFields)
299
454
  return null;
455
+ const contextFields = parseContextFields(body, res);
456
+ if (!contextFields)
457
+ return null;
300
458
  const workspaceId = body['workspaceId'];
301
459
  const projectPath = body['projectPath'];
302
460
  const extraRoots = body['extraRoots'];
@@ -327,6 +485,7 @@ function parseCreateRequest(body, res, store) {
327
485
  // Read from the registry, never from the body.
328
486
  workspaceName: workspace.name,
329
487
  ...seatFields,
488
+ ...contextFields,
330
489
  };
331
490
  }
332
491
  if (typeof projectPath !== 'string' || projectPath.trim().length === 0 || projectPath.length > MAX_PATH_CHARS) {
@@ -380,6 +539,103 @@ function parseCreateRequest(body, res, store) {
380
539
  projectPath: primary,
381
540
  ...(resolvedExtras.length > 0 ? { extraRoots: resolvedExtras } : {}),
382
541
  ...seatFields,
542
+ ...contextFields,
543
+ };
544
+ }
545
+ /**
546
+ * Everything POST /sessions resolves SERVER-SIDE before the engine sees the
547
+ * request: the context mode (explicit, else the seat's preference), the
548
+ * handoff provenance, and the shared-memory snapshot. Returns null after
549
+ * responding.
550
+ *
551
+ * ORDER MATTERS. Every refusal below happens BEFORE `prepareProjectMemory`,
552
+ * which creates the project's private memory directory — a request that is
553
+ * going to be refused must not leave one behind. The engine re-validates the
554
+ * model and mode (it is the authority and also serves non-HTTP callers); these
555
+ * checks exist so the refusal comes first and carries a precise message.
556
+ */
557
+ function resolveCreation(create, launch, engine, res) {
558
+ const seat = launch.seat;
559
+ // Never forwarded: the engine takes provenance from `options.handoffFrom`,
560
+ // which is resolved from the store below, never from the body.
561
+ const { handoffFromSessionId, ...request } = create;
562
+ // 1. Handoff source — the TITLE is read from the store, so a session can
563
+ // never be labelled "Continued from <anything the client chose>".
564
+ let handoffFrom = null;
565
+ if (handoffFromSessionId !== undefined) {
566
+ const source = engine.getSession(handoffFromSessionId);
567
+ if (!source) {
568
+ sendInvalid(res, `handoff source session not found: ${handoffFromSessionId}`);
569
+ return null;
570
+ }
571
+ handoffFrom = { sessionId: source.id, title: source.title };
572
+ }
573
+ // 2. The model this session will run: the requested one, else the seat's
574
+ // first RUNNABLE model — the same default the engine applies, so a
575
+ // listed-but-unavailable model (one the pinned CLI is too old for) is
576
+ // never picked silently.
577
+ if (seat.models.length === 0) {
578
+ sendInvalid(res, `seat ${seat.id} has no models`);
579
+ return null;
580
+ }
581
+ const modelId = request.model ?? seat.models.find((m) => !m.unavailableReason?.trim())?.id;
582
+ if (!modelId) {
583
+ sendInvalid(res, `seat ${seat.id} has no runnable models`);
584
+ return null;
585
+ }
586
+ const option = seat.models.find((m) => m.id === modelId);
587
+ if (!option) {
588
+ sendInvalid(res, `model ${modelId} is not available on seat ${seat.id}`);
589
+ return null;
590
+ }
591
+ if (typeof option.unavailableReason === 'string' && option.unavailableReason.trim().length > 0) {
592
+ sendInvalid(res, `model ${modelId} cannot run on seat ${seat.id}: ${option.unavailableReason.trim()}`);
593
+ return null;
594
+ }
595
+ // 3. Context mode. An EXPLICIT mode the model has no budget for is refused;
596
+ // a PREFERRED one is only a default, so it quietly yields to `standard`
597
+ // rather than failing a request that never named a mode. The mode is
598
+ // ALWAYS passed explicitly, `standard` included, so every 3.9 record
599
+ // states its mode and an ABSENT `contextMode` reliably means a pre-3.9
600
+ // record (a legacy Claude session ran at the CLI's native window, which
601
+ // the engine preserves as `expansive`). (Loading preferences is total —
602
+ // a missing or mangled file reads as defaults.)
603
+ const prefs = loadVersePreferences();
604
+ if (request.contextMode !== undefined) {
605
+ if (request.contextMode !== 'standard' && budgetFor(option, request.contextMode) === null) {
606
+ sendInvalid(res, `model ${modelId} has no ${request.contextMode} context mode on seat ${seat.id}`);
607
+ return null;
608
+ }
609
+ }
610
+ else {
611
+ const preferred = seatDefaultMode(prefs, seat.id);
612
+ request.contextMode = preferred !== 'standard' && budgetFor(option, preferred) !== null ? preferred : 'standard';
613
+ }
614
+ // 4. Shared project memory, snapshotted now and pinned for the session's
615
+ // life. Grok can reach only its `--cwd`, so it is offered the memory
616
+ // read-only (the block says so); every other engine can be granted the
617
+ // directory. A failure to prepare it never blocks the chat.
618
+ //
619
+ // `memory` is ALWAYS passed: a snapshot, or `null` for "decided off"
620
+ // (disabled by preference, or it could not be prepared). The engine
621
+ // records `null` as `memoryEnabled: false`, so the record says exactly
622
+ // what the session was given rather than leaving it unknown — absent is
623
+ // reserved for callers that never decided (pre-3.9 records).
624
+ let memory = null;
625
+ if (memoryEnabledFor(prefs, request.projectPath)) {
626
+ try {
627
+ memory = prepareProjectMemory(request.projectPath, { writable: seat.engine !== 'grok' });
628
+ }
629
+ catch {
630
+ memory = null;
631
+ }
632
+ }
633
+ return {
634
+ request,
635
+ options: {
636
+ memory,
637
+ ...(handoffFrom ? { handoffFrom } : {}),
638
+ },
383
639
  };
384
640
  }
385
641
  // ---------------------------------------------------------------------------
@@ -539,6 +795,368 @@ async function handleWorkspaces(ctx, req, res, path, method) {
539
795
  return sendWorkspaceError(res, err);
540
796
  }
541
797
  }
798
+ const CONTEXT_ROUTE_METHODS = {
799
+ preferences: ['GET', 'POST'],
800
+ 'context-fit': ['GET'],
801
+ search: ['GET'],
802
+ memory: ['GET', 'POST'],
803
+ };
804
+ function contextRouteOf(path) {
805
+ const segment = path.slice(`${VERSE_API_PREFIX}/`.length);
806
+ return Object.prototype.hasOwnProperty.call(CONTEXT_ROUTE_METHODS, segment) ? segment : null;
807
+ }
808
+ /**
809
+ * Every key any `VersePreferencesUpdate` form may carry. Which COMBINATION is
810
+ * valid is decided by preferences.ts's own parser, so the API and the store
811
+ * can never disagree about what a well-formed update is.
812
+ */
813
+ const PREFERENCES_KEYS = new Set(['seatId', 'contextMode', 'memoryEnabled', 'projectPath']);
814
+ const MEMORY_WRITE_KEYS = new Set(['projectPath', 'content']);
815
+ const CONTEXT_MODE_KEYS = new Set(['mode']);
816
+ const HANDOFF_PREVIEW_KEYS = new Set(['includeLastAssistant', 'focus']);
817
+ /** Every POST /api/verse/sessions/:id/<action>; anything else is a 404. */
818
+ const SESSION_POST_ACTIONS = new Set([
819
+ 'turns',
820
+ 'cancel',
821
+ 'delete',
822
+ 'rename',
823
+ 'context-mode',
824
+ 'handoff-preview',
825
+ ]);
826
+ /**
827
+ * The gate every POST in this file sits behind, in the same order: 404 unless
828
+ * the server allows dispatch, then the constant-time mutation token + JSON
829
+ * Content-Type, then the body cap (64 KB unless `maxBytes` raises it for the
830
+ * one route that needs more). Null after responding.
831
+ */
832
+ async function readMutationBody(ctx, req, res, maxBytes) {
833
+ if (!ctx.allowDispatch) {
834
+ sendJson(res, 404, { error: 'not found' });
835
+ return null;
836
+ }
837
+ if (!passesMutationGate(req, res, ctx.token))
838
+ return null;
839
+ return readJsonBody(req, res, maxBytes);
840
+ }
841
+ /**
842
+ * The body cap for POST /api/verse/memory ONLY.
843
+ *
844
+ * The memory file may be VERSE_MEMORY_MAX_BYTES of content, and JSON escaping
845
+ * can roughly double that (every `"`, `\` and newline becomes two bytes; a
846
+ * control character becomes six, but a memory file is prose). Under the shared
847
+ * 65,536-byte cap a file anywhere near the documented limit could never be
848
+ * saved: 64 KiB of content made a 65,711-byte body. 2× plus 8 KiB for the
849
+ * envelope (`projectPath`, keys) admits any realistic file at the limit while
850
+ * staying a bounded, small read. The CONTENT itself is still held to
851
+ * VERSE_MEMORY_MAX_BYTES (413) after parsing.
852
+ */
853
+ export { VERSE_MEMORY_BODY_MAX_BYTES }; // defined in types.ts so the browser mirrors it exactly
854
+ // ── Memory round-trip honesty ─────────────────────────────────────────────
855
+ //
856
+ // Every response goes through sendJson() → sanitizePublicJson(), which rewrites
857
+ // the home directory as `~` and replaces secret-shaped strings with
858
+ // scrubSecrets()' marker. For most payloads that is pure hygiene; for
859
+ // `VerseProjectMemory.content` it is a lossy view of a file the operator can
860
+ // EDIT AND SAVE BACK. The sanitizer has no per-field opt-out, and adding one
861
+ // would mean serving raw secrets to the browser, so the view stays sanitized
862
+ // and the API is honest about it instead:
863
+ //
864
+ // - GET/POST responses carry `contentSanitized: true` whenever the content
865
+ // the client receives differs from the bytes on disk (absent otherwise, so
866
+ // the wire shape is unchanged for the common case). The editor can say so
867
+ // before the operator saves.
868
+ // - POST refuses (409 VERSE_MEMORY_REDACTED) content with MORE redaction
869
+ // markers than the file on disk — i.e. a save that would overwrite real
870
+ // values with placeholders. The `~` rewrite is NOT refused: `~/x` still
871
+ // names the same path for every reader of MEMORY.md, the flag discloses it,
872
+ // and refusing it would make any memory that mentions a home path unsavable.
873
+ /** The placeholder scrubSecrets() (src/core/util/scrub.ts) writes in every rule. */
874
+ const SCRUB_REDACTION_MARKER = '[REDACTED]';
875
+ function countRedactionMarkers(text) {
876
+ return text.split(SCRUB_REDACTION_MARKER).length - 1;
877
+ }
878
+ function flagSanitizedMemory(memory) {
879
+ // The exact transform sendJson() is about to apply, so the flag is true iff
880
+ // the client will receive something other than the file's bytes. Spread
881
+ // only when set: an unsanitized memory keeps the pre-flag response shape.
882
+ return sanitizePublicJson(memory.content) === memory.content ? memory : { ...memory, contentSanitized: true };
883
+ }
884
+ /**
885
+ * `/api/verse/{preferences,context-fit,search,memory}`.
886
+ *
887
+ * NOTHING HERE SPENDS. Preferences and memory are private files under
888
+ * ~/.ashlr/verse; context-fit runs `git ls-files` and stats; search reads the
889
+ * durable event logs. None of them reaches a model, so none of them needs —
890
+ * or bypasses — the local-only gate, which stays exactly where it was: at the
891
+ * top of the engine's `startTurn`.
892
+ *
893
+ * Service modules report bad input as `VerseServiceError` (VERSE_INVALID →
894
+ * 400, VERSE_TOO_LARGE → 413); those propagate to `handleVerseApi`'s catch
895
+ * and reach the client through its shared, duck-typed `sendVerseError`.
896
+ */
897
+ async function handleContextRoutes(ctx, req, res, path, method, route) {
898
+ if (!CONTEXT_ROUTE_METHODS[route].includes(method)) {
899
+ sendJson(res, 404, { error: `not found: ${method} ${path}` });
900
+ return true;
901
+ }
902
+ switch (route) {
903
+ // ── /api/verse/preferences ───────────────────────────────────────────
904
+ case 'preferences': {
905
+ if (method === 'GET') {
906
+ if (!readQuery(req, res, []))
907
+ return true;
908
+ const body = loadVersePreferences();
909
+ sendJson(res, 200, body);
910
+ return true;
911
+ }
912
+ const body = await readMutationBody(ctx, req, res);
913
+ if (!body)
914
+ return true;
915
+ if (rejectUnknownKeys(body, PREFERENCES_KEYS, res))
916
+ return true;
917
+ // Exactly one form, every value typed; throws VERSE_INVALID (400).
918
+ parseVersePreferencesUpdate(body);
919
+ let update = body;
920
+ if ('seatId' in update) {
921
+ // Setting a NON-default mode must name a seat that exists and has at
922
+ // least one runnable model with a real budget for it — otherwise the
923
+ // preference could never take effect and the dialog would show a
924
+ // default the server silently ignores. Resetting to `standard` only
925
+ // removes an entry, so it is allowed for a seat that is not currently
926
+ // discovered (a local seat while Ollama is down).
927
+ const { seatId, contextMode } = update;
928
+ if (contextMode !== 'standard') {
929
+ const discovery = await cachedSeats(ctx.cfg);
930
+ const seat = discovery.seats.find((s) => s.id === seatId);
931
+ if (!seat) {
932
+ sendInvalid(res, `unknown seat: ${seatId}`);
933
+ return true;
934
+ }
935
+ const supports = seat.models.some((m) => !m.unavailableReason && budgetFor(m, contextMode) !== null
936
+ && (contextMode !== 'expansive' || hasExpansiveMode(m)));
937
+ if (!supports) {
938
+ sendInvalid(res, `seat ${seat.id} has no model with a ${contextMode} context mode`);
939
+ return true;
940
+ }
941
+ }
942
+ }
943
+ else if ('projectPath' in update) {
944
+ // The same path rule a session root obeys, and the PHYSICAL spelling
945
+ // the store keys opt-outs on.
946
+ const guard = guardRoot(update.projectPath, 'projectPath');
947
+ if (!guard.ok) {
948
+ sendInvalid(res, guard.error);
949
+ return true;
950
+ }
951
+ update = { projectPath: guard.physical, memoryEnabled: update.memoryEnabled };
952
+ }
953
+ const stored = updateVersePreferences(update);
954
+ sendJson(res, 200, stored);
955
+ return true;
956
+ }
957
+ // ── GET /api/verse/context-fit ───────────────────────────────────────
958
+ // ?workspaceId=<id> | ?projectPath=<abs>[&extraRoots=<abs>]…
959
+ // `extraRoots` REPEATS rather than being comma-joined: a comma is legal
960
+ // in a path, a repeated parameter is unambiguous.
961
+ case 'context-fit': {
962
+ const params = readQuery(req, res, ['workspaceId', 'projectPath', 'extraRoots'], ['extraRoots']);
963
+ if (!params)
964
+ return true;
965
+ const workspaceId = params.get('workspaceId');
966
+ const projectPath = params.get('projectPath');
967
+ const extraRoots = params.getAll('extraRoots');
968
+ let candidates;
969
+ if (workspaceId !== null) {
970
+ // Never mixed — the same rule POST /sessions applies, for the same
971
+ // reason: the roots measured must be the roots the operator named.
972
+ if (projectPath !== null || extraRoots.length > 0) {
973
+ sendInvalid(res, 'send workspaceId or projectPath/extraRoots, not both');
974
+ return true;
975
+ }
976
+ const id = workspaceId.trim();
977
+ if (id.length === 0) {
978
+ sendInvalid(res, 'workspaceId must be a non-empty string');
979
+ return true;
980
+ }
981
+ const workspace = getVerseWorkspaceStore().get(id);
982
+ if (!workspace) {
983
+ sendInvalid(res, `unknown workspace: ${id}`);
984
+ return true;
985
+ }
986
+ const primary = workspace.roots.find((r) => r.primary) ?? workspace.roots[0];
987
+ if (!primary) {
988
+ sendInvalid(res, `workspace ${workspace.name} has no roots`);
989
+ return true;
990
+ }
991
+ candidates = [primary.path, ...workspace.roots.filter((r) => r.path !== primary.path).map((r) => r.path)];
992
+ }
993
+ else {
994
+ if (projectPath === null) {
995
+ sendInvalid(res, 'workspaceId or projectPath is required');
996
+ return true;
997
+ }
998
+ if (extraRoots.length > VERSE_MAX_WORKSPACE_ROOTS - 1) {
999
+ sendInvalid(res, `a session may have at most ${VERSE_MAX_WORKSPACE_ROOTS} roots`);
1000
+ return true;
1001
+ }
1002
+ candidates = [projectPath, ...extraRoots];
1003
+ }
1004
+ // Workspace roots are re-checked too: they were valid when saved, but a
1005
+ // root deleted since would otherwise be measured as zero tokens and the
1006
+ // badge would say "fits" about code that is not there. Duplicates (a
1007
+ // symlink and its target) are measured once.
1008
+ const roots = [];
1009
+ const seen = new Set();
1010
+ for (const [index, candidate] of candidates.entries()) {
1011
+ const guard = guardRoot(candidate, index === 0 ? 'projectPath' : 'extraRoots entry');
1012
+ if (!guard.ok) {
1013
+ sendInvalid(res, guard.error);
1014
+ return true;
1015
+ }
1016
+ if (seen.has(guard.physical))
1017
+ continue;
1018
+ seen.add(guard.physical);
1019
+ roots.push(guard.path);
1020
+ }
1021
+ const fit = await estimateContextFit(roots);
1022
+ sendJson(res, 200, fit);
1023
+ return true;
1024
+ }
1025
+ // ── GET /api/verse/search?q=&limit= ──────────────────────────────────
1026
+ case 'search': {
1027
+ const params = readQuery(req, res, ['q', 'limit']);
1028
+ if (!params)
1029
+ return true;
1030
+ const q = params.get('q');
1031
+ if (q === null || q.trim().length === 0) {
1032
+ sendInvalid(res, 'q is required');
1033
+ return true;
1034
+ }
1035
+ const rawLimit = params.get('limit');
1036
+ let limit;
1037
+ if (rawLimit !== null) {
1038
+ // Positive integer or 400, then CLAMPED by the service to its maximum
1039
+ // — the same convention GET /api/verse/audit follows.
1040
+ if (!/^\d{1,9}$/.test(rawLimit) || Number(rawLimit) < 1) {
1041
+ sendInvalid(res, 'limit must be a positive integer');
1042
+ return true;
1043
+ }
1044
+ limit = Number(rawLimit);
1045
+ }
1046
+ const engine = await getVerseEngine();
1047
+ const result = searchSessions({
1048
+ sessions: engine.listSessions(),
1049
+ readEvents: (id) => engine.getEvents(id),
1050
+ query: q.trim(),
1051
+ ...(limit !== undefined ? { limit } : {}),
1052
+ });
1053
+ sendJson(res, 200, result);
1054
+ return true;
1055
+ }
1056
+ // ── /api/verse/memory ────────────────────────────────────────────────
1057
+ case 'memory': {
1058
+ if (method === 'GET') {
1059
+ const params = readQuery(req, res, ['projectPath']);
1060
+ if (!params)
1061
+ return true;
1062
+ const guard = guardRoot(params.get('projectPath'), 'projectPath');
1063
+ if (!guard.ok) {
1064
+ sendInvalid(res, guard.error);
1065
+ return true;
1066
+ }
1067
+ // `enabled` is what a NEW session on this project would be offered;
1068
+ // sessions already running keep what they were created with.
1069
+ const memory = readProjectMemory(guard.physical, memoryEnabledFor(loadVersePreferences(), guard.physical));
1070
+ sendJson(res, 200, flagSanitizedMemory(memory));
1071
+ return true;
1072
+ }
1073
+ const body = await readMutationBody(ctx, req, res, VERSE_MEMORY_BODY_MAX_BYTES);
1074
+ if (!body)
1075
+ return true;
1076
+ if (rejectUnknownKeys(body, MEMORY_WRITE_KEYS, res))
1077
+ return true;
1078
+ const guard = guardRoot(body['projectPath'], 'projectPath');
1079
+ if (!guard.ok) {
1080
+ sendInvalid(res, guard.error);
1081
+ return true;
1082
+ }
1083
+ const content = body['content'];
1084
+ if (typeof content !== 'string') {
1085
+ sendInvalid(res, 'content must be a string ("" clears the memory)');
1086
+ return true;
1087
+ }
1088
+ if (Buffer.byteLength(content, 'utf8') > VERSE_MEMORY_MAX_BYTES) {
1089
+ sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: `content exceeds ${VERSE_MEMORY_MAX_BYTES} bytes` });
1090
+ return true;
1091
+ }
1092
+ // A save must not write the sanitizer's placeholders over the secrets
1093
+ // they stand for. Only an INCREASE is refused: a file that already holds
1094
+ // the literal marker (an agent quoting a scrubbed log) stays editable.
1095
+ const onDisk = readProjectMemory(guard.physical, false).content;
1096
+ if (countRedactionMarkers(content) > countRedactionMarkers(onDisk)) {
1097
+ sendJson(res, 409, {
1098
+ code: 'VERSE_MEMORY_REDACTED',
1099
+ error: `content contains ${SCRUB_REDACTION_MARKER} placeholders that stand in for secret-looking text in MEMORY.md; `
1100
+ + 'saving would replace the real values. Remove the placeholders, or edit MEMORY.md directly.',
1101
+ });
1102
+ return true;
1103
+ }
1104
+ // Allowed whether or not memory is enabled for the project: it is the
1105
+ // operator's own file, and clearing it must always work.
1106
+ const written = writeProjectMemory(guard.physical, content);
1107
+ sendJson(res, 200, flagSanitizedMemory(written));
1108
+ return true;
1109
+ }
1110
+ default: {
1111
+ const never = route;
1112
+ sendJson(res, 404, { error: `not found: ${method} ${String(never)}` });
1113
+ return true;
1114
+ }
1115
+ }
1116
+ }
1117
+ // ---------------------------------------------------------------------------
1118
+ // Turn-time model check
1119
+ // ---------------------------------------------------------------------------
1120
+ /**
1121
+ * The session's model as its seat lists it RIGHT NOW: `reason` is why it
1122
+ * cannot run (null lets the turn proceed), `option` the live catalog entry
1123
+ * (null when discovery failed, the seat is gone or the model is not listed).
1124
+ *
1125
+ * Create refuses an unavailable model, but a session outlives the check: a
1126
+ * chat created on `claude-opus-5.5` before its seat was pinned to a CLI that
1127
+ * predates the model (2.1.257 < 2.1.280) is stored with that id and would
1128
+ * otherwise start a CLI that rejects it — a spawned process and a confusing
1129
+ * native error instead of the seat's own one-line reason. The model id is
1130
+ * canonicalised first, so a retired dotted alias finds its option.
1131
+ *
1132
+ * Deliberately permissive: only a CURRENT seat that LISTS the model with a
1133
+ * non-empty `unavailableReason` blocks. Discovery failing, the seat being gone
1134
+ * or the model not being listed at all keeps today's behaviour — the engine
1135
+ * starts the turn and reports whatever happens — because this is a fail-fast
1136
+ * convenience, not a new gate, and a flaky discovery must never lock a chat.
1137
+ */
1138
+ async function liveModelFor(cfg, session) {
1139
+ const none = { reason: null, option: null };
1140
+ if (!session)
1141
+ return none; // sendTurn owns the 404
1142
+ let discovery;
1143
+ try {
1144
+ discovery = await cachedSeats(cfg);
1145
+ }
1146
+ catch {
1147
+ return none;
1148
+ }
1149
+ const seat = discovery.seats.find((s) => s.id === session.seatId);
1150
+ if (!seat)
1151
+ return none;
1152
+ const modelId = canonicalModelId(session.model);
1153
+ const option = seat.models.find((m) => m.id === modelId) ?? null;
1154
+ const reason = option?.unavailableReason?.trim();
1155
+ return {
1156
+ reason: reason ? `model ${modelId} cannot run on seat ${seat.id}: ${reason}` : null,
1157
+ option,
1158
+ };
1159
+ }
542
1160
  // ---------------------------------------------------------------------------
543
1161
  // Handler
544
1162
  // ---------------------------------------------------------------------------
@@ -602,6 +1220,14 @@ export async function handleVerseApi(ctx, req, res, path, method) {
602
1220
  sendJson(res, 200, view);
603
1221
  return true;
604
1222
  }
1223
+ // ── /api/verse/{preferences,context-fit,search,memory} (V3.9) ────────
1224
+ // AWAITED, not returned: a rejection must land in this function's catch
1225
+ // (which maps VERSE_INVALID → 400, VERSE_TOO_LARGE → 413) rather than
1226
+ // escape to handleApi's generic 500.
1227
+ const contextRoute = contextRouteOf(path);
1228
+ if (contextRoute !== null) {
1229
+ return await handleContextRoutes(ctx, req, res, path, method, contextRoute);
1230
+ }
605
1231
  // ── /api/verse/sessions ──────────────────────────────────────────────
606
1232
  if (path === `${VERSE_API_PREFIX}/sessions`) {
607
1233
  if (method === 'GET') {
@@ -610,13 +1236,7 @@ export async function handleVerseApi(ctx, req, res, path, method) {
610
1236
  return true;
611
1237
  }
612
1238
  if (method === 'POST') {
613
- if (!ctx.allowDispatch) {
614
- sendJson(res, 404, { error: 'not found' });
615
- return true;
616
- }
617
- if (!passesMutationGate(req, res, ctx.token))
618
- return true;
619
- const body = await readJsonBody(req, res);
1239
+ const body = await readMutationBody(ctx, req, res);
620
1240
  if (!body)
621
1241
  return true;
622
1242
  const create = parseCreateRequest(body, res, getVerseWorkspaceStore());
@@ -629,7 +1249,10 @@ export async function handleVerseApi(ctx, req, res, path, method) {
629
1249
  return true;
630
1250
  }
631
1251
  const engine = await getVerseEngine();
632
- const session = engine.createSession(create, launch);
1252
+ const resolved = resolveCreation(create, launch, engine, res);
1253
+ if (!resolved)
1254
+ return true;
1255
+ const session = engine.createSession(resolved.request, launch, resolved.options);
633
1256
  sendJson(res, 201, session);
634
1257
  return true;
635
1258
  }
@@ -697,20 +1320,71 @@ export async function handleVerseApi(ctx, req, res, path, method) {
697
1320
  sendJson(res, 200, detail);
698
1321
  return true;
699
1322
  }
700
- if (method !== 'POST' || !['turns', 'cancel', 'delete', 'rename'].includes(action)) {
1323
+ if (method !== 'POST' || !SESSION_POST_ACTIONS.has(action)) {
701
1324
  sendJson(res, 404, { error: `not found: ${method} ${path}` });
702
1325
  return true;
703
1326
  }
704
- if (!ctx.allowDispatch) {
705
- sendJson(res, 404, { error: 'not found' });
706
- return true;
707
- }
708
- if (!passesMutationGate(req, res, ctx.token))
709
- return true;
710
- const body = await readJsonBody(req, res);
1327
+ const body = await readMutationBody(ctx, req, res);
711
1328
  if (!body)
712
1329
  return true;
713
1330
  const engine = await getVerseEngine();
1331
+ // POST /api/verse/sessions/:id/context-mode {mode}
1332
+ //
1333
+ // Changes CLI FLAGS from the next turn on, never prompt content, so the
1334
+ // provider cache survives the switch. The engine is the authority on
1335
+ // whether the model has a budget for the mode (VERSE_INVALID) and
1336
+ // refuses a switch while a turn runs (VERSE_SESSION_BUSY).
1337
+ if (action === 'context-mode') {
1338
+ if (rejectUnknownKeys(body, CONTEXT_MODE_KEYS, res))
1339
+ return true;
1340
+ const mode = body['mode'];
1341
+ if (!isContextMode(mode)) {
1342
+ sendInvalid(res, `mode must be one of: ${CONTEXT_MODE_LIST}`);
1343
+ return true;
1344
+ }
1345
+ if (!engine.getSession(id)) {
1346
+ sendJson(res, 404, { code: 'VERSE_SESSION_NOT_FOUND', error: `session not found: ${id}` });
1347
+ return true;
1348
+ }
1349
+ const updated = engine.setContextMode(id, mode);
1350
+ sendJson(res, 200, updated);
1351
+ return true;
1352
+ }
1353
+ // POST /api/verse/sessions/:id/handoff-preview {includeLastAssistant?, focus?}
1354
+ //
1355
+ // A POST although it changes nothing, because building it runs
1356
+ // `git diff --stat` in every root — a subprocess, so it sits behind the
1357
+ // same gate as every other route that starts one. ZERO SPEND: the text
1358
+ // is assembled from the event log; the new session's first turn is
1359
+ // spent only when the operator presses send on it.
1360
+ if (action === 'handoff-preview') {
1361
+ if (rejectUnknownKeys(body, HANDOFF_PREVIEW_KEYS, res))
1362
+ return true;
1363
+ const includeLastAssistant = body['includeLastAssistant'];
1364
+ const focus = body['focus'];
1365
+ if (includeLastAssistant !== undefined && typeof includeLastAssistant !== 'boolean') {
1366
+ sendInvalid(res, 'includeLastAssistant must be a boolean');
1367
+ return true;
1368
+ }
1369
+ // Its LENGTH (≤ 500 after trimming) is session-handoff.ts's rule and is
1370
+ // enforced there, so the API and the builder cannot disagree.
1371
+ if (focus !== undefined && typeof focus !== 'string') {
1372
+ sendInvalid(res, 'focus must be a string');
1373
+ return true;
1374
+ }
1375
+ const session = engine.getSession(id);
1376
+ if (!session) {
1377
+ sendJson(res, 404, { code: 'VERSE_SESSION_NOT_FOUND', error: `session not found: ${id}` });
1378
+ return true;
1379
+ }
1380
+ const focusLine = typeof focus === 'string' ? focus.trim() : '';
1381
+ const preview = buildHandoffPreview(session, engine.getEvents(id), {
1382
+ ...(includeLastAssistant === true ? { includeLastAssistant: true } : {}),
1383
+ ...(focusLine.length > 0 ? { focus: focusLine } : {}),
1384
+ });
1385
+ sendJson(res, 200, preview);
1386
+ return true;
1387
+ }
714
1388
  if (action === 'turns') {
715
1389
  const text = body['text'];
716
1390
  if (typeof text !== 'string' || text.trim().length === 0) {
@@ -721,6 +1395,31 @@ export async function handleVerseApi(ctx, req, res, path, method) {
721
1395
  sendJson(res, 413, { code: 'VERSE_TOO_LARGE', error: `text exceeds ${VERSE_MAX_TURN_TEXT_BYTES} bytes` });
722
1396
  return true;
723
1397
  }
1398
+ // Turn text rides on the CLI's argv (`-- <text>`), where a NUL makes
1399
+ // spawn throw ERR_INVALID_ARG_VALUE after the turn was already recorded.
1400
+ if (text.includes('\0')) {
1401
+ sendInvalid(res, 'text must not contain NUL bytes');
1402
+ return true;
1403
+ }
1404
+ const current = engine.getSession(id);
1405
+ const live = await liveModelFor(ctx.cfg, current);
1406
+ if (live.reason !== null) {
1407
+ // A distinct code, not VERSE_SESSION_BUSY's: the UI must be able to
1408
+ // tell "a turn is running" from "this model cannot run on this seat".
1409
+ sendJson(res, 409, { code: 'VERSE_MODEL_UNAVAILABLE', error: live.reason });
1410
+ return true;
1411
+ }
1412
+ // Local windows are volatile (server default, dispatch lane, a re-pulled
1413
+ // tag), and the SAME number must reach both the CLI
1414
+ // (CLAUDE_CODE_MAX_CONTEXT_TOKENS) and the meter: the session's stored
1415
+ // `usage.contextWindow`. Refresh it from live discovery before the CLI
1416
+ // launches, so a stale record (a pre-3.9 262144 for a 64k tag) is
1417
+ // corrected rather than told to the CLI turn after turn. No live option
1418
+ // (Ollama down, tag gone) keeps the stored window: discovery failing
1419
+ // must never rewrite a session.
1420
+ if (current?.engine === 'local' && live.option !== null) {
1421
+ engine.refreshLocalWindow(id, live.option);
1422
+ }
724
1423
  const result = engine.sendTurn(id, text);
725
1424
  sendJson(res, 202, result);
726
1425
  return true;