@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
@@ -11,6 +11,19 @@
11
11
  * whole group, escalating SIGINT → SIGKILL after a grace period, with a bounded
12
12
  * drain so a descendant holding our pipes cannot keep a turn "running" forever.
13
13
  *
14
+ * CONTEXT (V3.9, docs/VERSE-CONTEXT.md). The engine is the one place a
15
+ * session's context budget is decided and kept current:
16
+ * - at creation, from the seat's model option and the session's mode
17
+ * (`budgetFor`), recorded on `usage` with where the window came from;
18
+ * - on every reading, a window the CLI reports at runtime WINS over the
19
+ * catalog (a 1M Claude model the CLI clamps to 200k is a 200k session),
20
+ * and the compaction point is recomputed for it (`reconcileAutoCompactAt`);
21
+ * - `context` / `compaction` events replace occupancy and count compactions;
22
+ * - adapters that can only see exact occupancy in the CLI's own files
23
+ * (codex rollouts) get bounded telemetry hooks: polled while the turn
24
+ * runs, and called once after the parser has flushed.
25
+ * None of this spends: it reads what the CLI already reported or wrote.
26
+ *
14
27
  * LOCAL-ONLY. Because the spawn is raw rather than routed through
15
28
  * `run/engines.spawnEngine`, this module is a SECOND subprocess funnel and none
16
29
  * of the daemon's gates cover it. It therefore carries its own call to the one
@@ -25,9 +38,12 @@ import { basename, dirname, isAbsolute, join, sep } from 'node:path';
25
38
  import { loadConfigReadOnly } from '../config.js';
26
39
  import { endpointPermitted, enginePermitted, } from '../policy/local-only.js';
27
40
  import { scrubSecrets } from '../util/scrub.js';
28
- import { adapterFor } from './adapters/index.js';
29
- import { createVerseSessionStore } from './session-store.js';
30
- import { VERSE_DEFAULT_CONTEXT_WINDOWS, VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_TURN_TIMEOUT_MS, } from './types.js';
41
+ import { adapterFor as defaultAdapterFor, VERSE_TELEMETRY_POLL_MS, } from './adapters/index.js';
42
+ import { budgetFor, canonicalModelId, claudeAutoCompactAt, claudeAutocompactFlag, CODEX_EFFECTIVE_WINDOW_PERCENT, codexAutoCompactAt, grokAutoCompactAt, hasExpansiveMode, reconcileAutoCompactAt, } from './context-math.js';
43
+ import { legacyModelOptionFallback } from './model-windows.js';
44
+ import { stripUnsafeControlChars } from './project-memory.js';
45
+ import { createVerseSessionStore, isVerseWindowSource } from './session-store.js';
46
+ import { VERSE_CONTEXT_MODES, VERSE_DEFAULT_CONTEXT_WINDOWS, VERSE_MAX_TURN_TEXT_BYTES, VERSE_MAX_WORKSPACE_ROOTS, VERSE_TURN_TIMEOUT_MS, } from './types.js';
31
47
  const VERSE_ERROR_STATUS = {
32
48
  VERSE_SESSION_NOT_FOUND: 404,
33
49
  VERSE_SESSION_BUSY: 409,
@@ -136,6 +152,19 @@ function isObject(value) {
136
152
  function isStringArray(value) {
137
153
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
138
154
  }
155
+ /**
156
+ * Upper bound on a memory block accepted into a launch record. The block U5
157
+ * renders is ≤ 6 KB; this is a sanity cap so a malformed caller cannot pin an
158
+ * arbitrarily large string into every turn's system prompt.
159
+ */
160
+ const MEMORY_BLOCK_MAX_BYTES = 16 * 1024;
161
+ function isSessionMemory(value) {
162
+ return isObject(value)
163
+ && typeof value['dir'] === 'string' && value['dir'].length > 0 && isAbsolute(value['dir'])
164
+ && typeof value['block'] === 'string'
165
+ && Buffer.byteLength(value['block'], 'utf8') <= MEMORY_BLOCK_MAX_BYTES
166
+ && typeof value['writable'] === 'boolean';
167
+ }
139
168
  function isSeatLaunch(value) {
140
169
  if (!isObject(value))
141
170
  return false;
@@ -148,10 +177,17 @@ function isSeatLaunch(value) {
148
177
  && typeof value['ollamaBaseUrl'] === 'string'
149
178
  // Absent is valid: that is every launch record written before the
150
179
  // llama-server lane existed, and it means the Ollama lane.
151
- && (value['anthropicBaseUrl'] === undefined || typeof value['anthropicBaseUrl'] === 'string');
180
+ && (value['anthropicBaseUrl'] === undefined || typeof value['anthropicBaseUrl'] === 'string')
181
+ // Absent is valid (memory off, or a pre-3.9 record). PRESENT but malformed
182
+ // is not: a half-formed memory snapshot would feed a bogus `--add-dir`.
183
+ && (value['memory'] === undefined || isSessionMemory(value['memory']));
152
184
  }
153
185
  function cloneSession(session) {
154
- return { ...session, usage: { ...session.usage } };
186
+ return {
187
+ ...session,
188
+ usage: { ...session.usage },
189
+ ...(session.handoffFrom ? { handoffFrom: { ...session.handoffFrom } } : {}),
190
+ };
155
191
  }
156
192
  function normaliseTitle(raw) {
157
193
  const collapsed = raw.replace(/\s+/g, ' ').trim();
@@ -163,14 +199,98 @@ function autoTitle(text) {
163
199
  return DEFAULT_TITLE;
164
200
  return firstLine.length > AUTO_TITLE_CHARS ? `${firstLine.slice(0, AUTO_TITLE_CHARS).trimEnd()}…` : firstLine;
165
201
  }
166
- function contextWindowFor(seat, model, engine) {
167
- const option = seat.models.find((m) => m.id === model);
168
- if (option && typeof option.contextWindow === 'number')
169
- return option.contextWindow;
170
- if (typeof seat.contextWindow === 'number')
171
- return seat.contextWindow;
172
- const fallback = VERSE_DEFAULT_CONTEXT_WINDOWS[engine];
173
- return typeof fallback === 'number' ? fallback : null;
202
+ // ---- context budgets (V3.9) -------------------------------------------------
203
+ function positiveInt(value) {
204
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
205
+ }
206
+ function nonNegativeInt(value) {
207
+ return typeof value === 'number' && Number.isFinite(value) && value >= 0 ? Math.floor(value) : null;
208
+ }
209
+ /** A counter delta from an adapter; anything non-numeric counts as zero rather than poisoning a total. */
210
+ function tokenDelta(value) {
211
+ return nonNegativeInt(value) ?? 0;
212
+ }
213
+ function isContextMode(value) {
214
+ return typeof value === 'string' && VERSE_CONTEXT_MODES.includes(value);
215
+ }
216
+ /**
217
+ * The seat's option for a session model: exact id first, then by canonical id
218
+ * so a request (or record) naming the retired alias `claude-opus-5.5` finds a
219
+ * seat that lists `claude-opus-5-5`, and vice versa.
220
+ */
221
+ function findModelOption(seat, model) {
222
+ const exact = seat.models.find((m) => m.id === model);
223
+ if (exact)
224
+ return exact;
225
+ const wanted = canonicalModelId(model);
226
+ return seat.models.find((m) => canonicalModelId(m.id) === wanted) ?? null;
227
+ }
228
+ /**
229
+ * The option whose budgets govern a session's readings.
230
+ *
231
+ * Normally the seat's own option. The exception is a launch snapshot written
232
+ * before 3.9: its options carry one flat window and no budgets, yet the live
233
+ * seat the web UI reads (and the claude/codex adapters, which build the CLI
234
+ * flags) take that model's budgets from the documented builders in
235
+ * model-windows.ts. `legacyModelOptionFallback` is that ONE rule, shared with
236
+ * the adapters, so the mode the UI offers is the mode this engine accepts and
237
+ * the compaction point recorded is the one the CLI was told — two sources of
238
+ * truth for one flag would drift (it did: a 3.8 codex chat was offered
239
+ * Expansive and then refused it with a 400).
240
+ *
241
+ * Local snapshots are left as they are: a local window is the runtime's
242
+ * allocation, which `refreshLocalWindow` re-reads from live discovery.
243
+ */
244
+ function effectiveModelOption(seat, model, engine) {
245
+ return legacyModelOptionFallback(engine, model, findModelOption(seat, model));
246
+ }
247
+ /**
248
+ * The compaction point each CLI itself uses for a window Verse only knows as
249
+ * a named default. Applied ONLY to `VERSE_DEFAULT_CONTEXT_WINDOWS`, which are
250
+ * the very figures those CLIs assume for an unknown model — so the formula
251
+ * over them is still the CLI's, not a guess of ours.
252
+ */
253
+ function defaultWindowAutoCompactAt(engine, window, maxOutputTokens) {
254
+ switch (engine) {
255
+ case 'claude':
256
+ case 'local':
257
+ return claudeAutoCompactAt(window, maxOutputTokens);
258
+ case 'codex':
259
+ return codexAutoCompactAt(Math.round((window * 100) / CODEX_EFFECTIVE_WINDOW_PERCENT));
260
+ case 'grok':
261
+ return grokAutoCompactAt(window);
262
+ default:
263
+ return null;
264
+ }
265
+ }
266
+ /**
267
+ * A session's budget in a mode: the model option's budget (`budgetFor`), else
268
+ * the seat's default-model window, else the engine's named default. Only the
269
+ * option path carries a real source; everything past it is `fallback`, which
270
+ * the UI draws as an estimate.
271
+ */
272
+ function sessionBudgetFor(seat, option, mode, engine) {
273
+ const budget = budgetFor(option, mode);
274
+ if (budget) {
275
+ return {
276
+ contextWindow: budget.contextWindow,
277
+ autoCompactAt: budget.autoCompactAt,
278
+ source: option?.windowSource ?? 'fallback',
279
+ };
280
+ }
281
+ // A seat-level window is the DEFAULT model's; it says nothing about this
282
+ // model's compaction point, so none is claimed.
283
+ const seatWindow = positiveInt(seat.contextWindow);
284
+ if (seatWindow !== null)
285
+ return { contextWindow: seatWindow, autoCompactAt: null, source: 'fallback' };
286
+ const fallback = positiveInt(VERSE_DEFAULT_CONTEXT_WINDOWS[engine]);
287
+ if (fallback === null)
288
+ return { contextWindow: null, autoCompactAt: null, source: 'fallback' };
289
+ return {
290
+ contextWindow: fallback,
291
+ autoCompactAt: defaultWindowAutoCompactAt(engine, fallback, option?.maxOutputTokens ?? null),
292
+ source: 'fallback',
293
+ };
174
294
  }
175
295
  function resolveProjectDir(projectPath) {
176
296
  if (typeof projectPath !== 'string' || !projectPath.trim()) {
@@ -331,6 +451,8 @@ export function createVerseEngine(opts = {}) {
331
451
  const turnTimeoutMs = opts.turnTimeoutMs ?? VERSE_TURN_TIMEOUT_MS;
332
452
  const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
333
453
  const loadCfg = opts.loadConfig ?? readConfigForPolicy;
454
+ const adapterFor = opts.adapterFor ?? defaultAdapterFor;
455
+ const telemetryPollMs = positiveInt(opts.telemetryPollMs) ?? VERSE_TELEMETRY_POLL_MS;
334
456
  const store = createVerseSessionStore(root);
335
457
  const running = new Map();
336
458
  const listeners = new Map();
@@ -344,8 +466,84 @@ export function createVerseEngine(opts = {}) {
344
466
  const session = store.get(id);
345
467
  if (!session)
346
468
  throw new VerseError('VERSE_SESSION_NOT_FOUND', `session not found: ${id}`);
469
+ materializeLegacyMode(session);
347
470
  return session;
348
471
  }
472
+ /**
473
+ * A CLAUDE record written before 3.9 has no `contextMode` and no
474
+ * `usage.contextWindowSource` (neither key existed). 3.8 launched it with no
475
+ * `--autocompact`, so on a 1M model the CLI ran its native window and
476
+ * compacted near 967k. Reading "absent" as `standard` would start passing
477
+ * `--autocompact 400000` on the first turn after the upgrade — and a chat
478
+ * already holding 400k–967k would be compacted by the CLI before it answered:
479
+ * an unrequested, paid summarisation of the whole context, with nothing on
480
+ * screen to warn about it (3.8 stored occupancy CLAMPED at its 200k catalog
481
+ * window, so the meter could not know either).
482
+ *
483
+ * So such a record keeps what it had: `expansive` (the CLI's `auto`) when its
484
+ * model has an expansive budget, with that budget recorded on `usage` so the
485
+ * meter draws the compaction point the CLI will actually use. It is written
486
+ * to disk ONCE, the first time the session is touched (engine load, get,
487
+ * list, a turn, a mode switch) — from then on it is an ordinary expansive
488
+ * session the operator can switch to standard like any other.
489
+ *
490
+ * Left alone: a model with no expansive budget (a 200k model's standard IS
491
+ * the CLI's `auto`), codex/grok/local (their standard is their native
492
+ * behaviour), a record that already names a mode, and a record carrying a
493
+ * window source (written by 3.9 code, which knew what it chose). The stored
494
+ * occupancy is NOT touched: it may be 3.8's clamped figure, but the first
495
+ * turn's reading replaces it, and with the native compaction point restored
496
+ * nothing compacts that would not have under 3.8.
497
+ *
498
+ * Written with `store.save`, not `save`: a migration is not activity, and
499
+ * bumping `updatedAt` would reorder every old chat to the top of the list.
500
+ */
501
+ function materializeLegacyMode(session) {
502
+ if (session.engine !== 'claude' || session.contextMode !== undefined)
503
+ return;
504
+ if (!isObject(session.usage) || session.usage.contextWindowSource !== undefined)
505
+ return;
506
+ const launch = store.loadLaunch(session.id);
507
+ if (!isSeatLaunch(launch))
508
+ return;
509
+ const option = effectiveModelOption(launch.seat, session.model, session.engine);
510
+ if (!hasExpansiveMode(option))
511
+ return;
512
+ const budget = sessionBudgetFor(launch.seat, option, 'expansive', session.engine);
513
+ session.contextMode = 'expansive';
514
+ session.usage.contextWindow = budget.contextWindow;
515
+ session.usage.contextWindowSource = budget.source;
516
+ session.usage.autoCompactAt = budget.autoCompactAt;
517
+ store.save(session);
518
+ }
519
+ /**
520
+ * Handoff provenance. The API hands the resolved source in `opts`; a bare
521
+ * `req.handoffFromSessionId` is accepted too. Either way the SOURCE RECORD
522
+ * in this store is the authority for the title — never a caller's string —
523
+ * and a source that does not exist is refused rather than pinned.
524
+ */
525
+ function resolveHandoffSource(req, opts) {
526
+ const fromOpts = opts.handoffFrom;
527
+ if (fromOpts !== undefined && fromOpts !== null
528
+ && !(isObject(fromOpts) && typeof fromOpts.sessionId === 'string' && typeof fromOpts.title === 'string')) {
529
+ throw new VerseError('VERSE_INVALID', 'handoff source is malformed');
530
+ }
531
+ const fromReq = req.handoffFromSessionId;
532
+ if (fromReq !== undefined && typeof fromReq !== 'string') {
533
+ throw new VerseError('VERSE_INVALID', 'handoffFromSessionId must be a string');
534
+ }
535
+ const optsId = fromOpts ? fromOpts.sessionId : undefined;
536
+ if (optsId !== undefined && fromReq !== undefined && optsId !== fromReq) {
537
+ throw new VerseError('VERSE_INVALID', 'handoff source does not match handoffFromSessionId');
538
+ }
539
+ const sourceId = optsId ?? fromReq;
540
+ if (sourceId === undefined)
541
+ return null;
542
+ const source = store.get(sourceId);
543
+ if (!source)
544
+ throw new VerseError('VERSE_INVALID', `handoff source session not found: ${sourceId}`);
545
+ return { sessionId: source.id, title: source.title };
546
+ }
349
547
  function emit(id, event) {
350
548
  const stored = store.appendEvent(id, event, nowIso());
351
549
  const subs = listeners.get(id);
@@ -394,6 +592,7 @@ export function createVerseEngine(opts = {}) {
394
592
  save(session);
395
593
  }
396
594
  for (const session of store.list()) {
595
+ materializeLegacyMode(session);
397
596
  if (session.status === 'running')
398
597
  reconcileInterrupted(session);
399
598
  }
@@ -425,6 +624,9 @@ export function createVerseEngine(opts = {}) {
425
624
  clearTimeout(timer);
426
625
  turn[key] = null;
427
626
  }
627
+ if (turn.pollTimer !== null)
628
+ clearInterval(turn.pollTimer);
629
+ turn.pollTimer = null;
428
630
  }
429
631
  function beginDrain(id, turn) {
430
632
  if (turn.settled || turn.drainTimer !== null)
@@ -458,39 +660,262 @@ export function createVerseEngine(opts = {}) {
458
660
  }, killGraceMs);
459
661
  }
460
662
  // ---- turn completion ----------------------------------------------------
461
- function applyUsage(session, event) {
462
- if (event.type !== 'usage')
463
- return event;
464
- const window = session.usage.contextWindow;
465
- // Adapters report the vendor's own figure; codex only exposes the turn
466
- // total (an upper bound on the live prompt), so never let the meter
467
- // exceed the window it is measured against.
468
- const contextTokens = window !== null ? Math.min(event.usage.contextTokens, window) : event.usage.contextTokens;
469
- const u = { ...event.usage, contextTokens, contextWindow: window };
470
- session.usage = {
471
- inputTokens: session.usage.inputTokens + u.inputTokens,
472
- outputTokens: session.usage.outputTokens + u.outputTokens,
473
- cacheReadTokens: session.usage.cacheReadTokens + u.cacheReadTokens,
474
- cacheCreationTokens: session.usage.cacheCreationTokens + u.cacheCreationTokens,
475
- contextTokens: u.contextTokens,
663
+ /**
664
+ * A window the CLI reported for this call WINS over the catalog: Claude Code
665
+ * clamps a 1M model to 200k when long-context credit runs out, and codex
666
+ * measures against whatever `model_context_window` it was launched with.
667
+ * The compaction point is recomputed for the window actually in force, with
668
+ * the `--autocompact` value this seat's adapter passes for the mode.
669
+ *
670
+ * LOCAL is exempt: Verse TELLS that CLI its window
671
+ * (`CLAUDE_CODE_MAX_CONTEXT_TOKENS`), so the figure it echoes back is ours,
672
+ * and the CLI's own guess for an unknown model id (200k) would be wrong.
673
+ */
674
+ function applyRuntimeWindow(session, option, reported) {
675
+ if (session.engine === 'local')
676
+ return;
677
+ const runtimeWindow = positiveInt(reported);
678
+ if (runtimeWindow === null)
679
+ return;
680
+ const mode = session.contextMode ?? 'standard';
681
+ session.usage.contextWindow = runtimeWindow;
682
+ session.usage.contextWindowSource = 'runtime';
683
+ session.usage.autoCompactAt = reconcileAutoCompactAt({
684
+ engine: session.engine,
685
+ runtimeWindow,
686
+ budget: budgetFor(option, mode),
687
+ autocompactWindow: session.engine === 'claude' ? claudeAutocompactFlag(option, mode) : null,
688
+ maxOutputTokens: option?.maxOutputTokens ?? null,
689
+ });
690
+ }
691
+ /**
692
+ * One `context` event after the budget changed BETWEEN turns (a mode switch,
693
+ * a local window refreshed from discovery), so every open client redraws the
694
+ * meter against it; occupancy itself is unchanged. It names the budget's
695
+ * source: a catalog figure must never be drawn as a CLI measurement.
696
+ */
697
+ function emitBudgetChange(id, session) {
698
+ emit(id, {
699
+ type: 'context',
700
+ turnId: null,
701
+ contextTokens: Math.max(0, Math.floor(session.usage.contextTokens || 0)),
476
702
  contextWindow: session.usage.contextWindow,
703
+ exact: session.usage.contextTokensExact !== false,
704
+ autoCompactAt: session.usage.autoCompactAt ?? null,
705
+ ...(session.usage.contextWindowSource !== undefined
706
+ ? { contextWindowSource: session.usage.contextWindowSource }
707
+ : {}),
708
+ });
709
+ }
710
+ /** Absent means exact, so an exact reading REMOVES the flag rather than writing `true`. */
711
+ function setExactness(usage, exact) {
712
+ if (exact)
713
+ delete usage.contextTokensExact;
714
+ else
715
+ usage.contextTokensExact = false;
716
+ }
717
+ /** The optional V3.9 usage keys, copied only when the session record has them. */
718
+ function contextFields(usage) {
719
+ return {
720
+ ...(usage.contextWindowSource !== undefined ? { contextWindowSource: usage.contextWindowSource } : {}),
721
+ ...(usage.autoCompactAt !== undefined ? { autoCompactAt: usage.autoCompactAt } : {}),
722
+ ...(usage.contextTokensExact === false ? { contextTokensExact: false } : {}),
477
723
  };
478
- return { ...event, usage: u };
479
724
  }
480
- function handleParsed(id, turn, events) {
481
- if (events.length === 0)
482
- return;
483
- turn.sawOutput = true;
725
+ /**
726
+ * Fold one parsed event into the session and return the event as it is to
727
+ * be stored — or null to drop it (a malformed context reading must never
728
+ * reach the log, where the store would skip it on read and the seq it took
729
+ * would be reused after a restart).
730
+ *
731
+ * usage — counters are summed; `contextTokens` is REPLACED and stored
732
+ * UNCLAMPED (a reading past the window is information: the CLI
733
+ * is about to compact or overflow; only the UI decides how to
734
+ * draw it). The stored event carries the window in force and,
735
+ * when the session has resolved them, its window source,
736
+ * compaction point and (only when false) exactness — so a
737
+ * client redraws the meter from the frame alone instead of
738
+ * waiting for a session refresh.
739
+ * A ZERO-COUNT frame is still a reading: a manual `/compact`
740
+ * turn makes no model call (`result.usage` is all zeros) and
741
+ * its `contextTokens` is the CLI's post-compaction size, which
742
+ * must replace the pre-compaction occupancy. Only a frame with
743
+ * NO numeric `contextTokens` leaves occupancy as it was — that
744
+ * is "no reading", and zero would be an invented one.
745
+ * context — a non-summed occupancy reading (codex rollout). Replaces
746
+ * `contextTokens`; the engine fills `autoCompactAt`.
747
+ * compaction — counted on the session; counts normalised to number|null.
748
+ *
749
+ * Every telemetry type is rebuilt from its known fields rather than spread:
750
+ * a stray key from an adapter (or a value JSON cannot encode) never reaches
751
+ * the durable log, where a failed `JSON.stringify` inside a poll timer would
752
+ * be an uncaught exception.
753
+ */
754
+ function applyUsage(session, option, event, fallbackTurnId) {
755
+ switch (event.type) {
756
+ case 'usage': {
757
+ const reported = isObject(event.usage) ? event.usage : {};
758
+ applyRuntimeWindow(session, option, reported.contextWindow);
759
+ const previous = session.usage;
760
+ const reading = nonNegativeInt(reported.contextTokens);
761
+ const next = {
762
+ inputTokens: previous.inputTokens + tokenDelta(reported.inputTokens),
763
+ outputTokens: previous.outputTokens + tokenDelta(reported.outputTokens),
764
+ cacheReadTokens: previous.cacheReadTokens + tokenDelta(reported.cacheReadTokens),
765
+ cacheCreationTokens: previous.cacheCreationTokens + tokenDelta(reported.cacheCreationTokens),
766
+ contextTokens: reading ?? previous.contextTokens,
767
+ contextWindow: previous.contextWindow,
768
+ ...contextFields(previous),
769
+ };
770
+ // Exactness describes a reading; with none, the previous one's stands.
771
+ if (reading !== null)
772
+ setExactness(next, reported.contextTokensExact !== false);
773
+ session.usage = next;
774
+ return {
775
+ type: 'usage',
776
+ turnId: typeof event.turnId === 'string' ? event.turnId : fallbackTurnId,
777
+ usage: {
778
+ inputTokens: tokenDelta(reported.inputTokens),
779
+ outputTokens: tokenDelta(reported.outputTokens),
780
+ cacheReadTokens: tokenDelta(reported.cacheReadTokens),
781
+ cacheCreationTokens: tokenDelta(reported.cacheCreationTokens),
782
+ contextTokens: next.contextTokens,
783
+ contextWindow: next.contextWindow,
784
+ ...contextFields(next),
785
+ },
786
+ };
787
+ }
788
+ case 'context': {
789
+ const tokens = nonNegativeInt(event.contextTokens);
790
+ if (tokens === null)
791
+ return null;
792
+ applyRuntimeWindow(session, option, event.contextWindow);
793
+ const exact = event.exact !== false;
794
+ session.usage.contextTokens = tokens;
795
+ setExactness(session.usage, exact);
796
+ return {
797
+ type: 'context',
798
+ turnId: typeof event.turnId === 'string' ? event.turnId : null,
799
+ contextTokens: tokens,
800
+ // The window IN FORCE, never null when the session knows one: a
801
+ // client that replaces its window from this event must not lose it
802
+ // because the adapter had no window to report.
803
+ contextWindow: session.usage.contextWindow,
804
+ exact,
805
+ autoCompactAt: session.usage.autoCompactAt ?? null,
806
+ // ...and WHERE that window came from. When the adapter reported
807
+ // none, the window in force is still the catalog's, and a client
808
+ // must not relabel it as a CLI measurement.
809
+ ...(session.usage.contextWindowSource !== undefined
810
+ ? { contextWindowSource: session.usage.contextWindowSource }
811
+ : {}),
812
+ };
813
+ }
814
+ case 'compaction': {
815
+ session.compactionCount = (session.compactionCount ?? 0) + 1;
816
+ return {
817
+ type: 'compaction',
818
+ turnId: typeof event.turnId === 'string' ? event.turnId : null,
819
+ trigger: event.trigger === 'manual' ? 'manual' : 'auto',
820
+ preTokens: nonNegativeInt(event.preTokens),
821
+ postTokens: nonNegativeInt(event.postTokens),
822
+ durationMs: nonNegativeInt(event.durationMs),
823
+ };
824
+ }
825
+ default:
826
+ return event;
827
+ }
828
+ }
829
+ /** Event types whose application changes the session record. */
830
+ const SESSION_MUTATING_EVENTS = new Set(['usage', 'context', 'compaction']);
831
+ /**
832
+ * Telemetry hooks may only contribute readings. An `error` or `turn-done`
833
+ * from a hook would let a best-effort file read decide whether a turn
834
+ * failed, which only the CLI's own output and exit code may do.
835
+ */
836
+ const TELEMETRY_EVENTS = new Set(['usage', 'context', 'compaction']);
837
+ /**
838
+ * `untilSettled` (the live poll): stop as soon as the turn settles. `emit`
839
+ * runs subscriber callbacks synchronously, so a subscriber that stops or
840
+ * deletes the session can settle the turn in the middle of this loop; the
841
+ * rest of a poll's readings would then land AFTER `turn-done`. The parser's
842
+ * final flush and `afterTurn` deliberately run while settled, so they omit it.
843
+ */
844
+ function applyEvents(id, turn, events, untilSettled = false) {
484
845
  const session = store.get(id);
846
+ // No record, no events: appending would re-create the log of a session
847
+ // that is gone, and `save` its record — a deleted chat back as an orphan.
848
+ if (!session)
849
+ return;
485
850
  for (const event of events) {
851
+ if (untilSettled && turn.settled)
852
+ return;
486
853
  if (event.type === 'error')
487
854
  turn.sawError = true;
488
- const enriched = session ? applyUsage(session, event) : event;
855
+ const enriched = applyUsage(session, turn.option, event, turn.turnId);
856
+ if (enriched === null)
857
+ continue;
489
858
  emit(id, enriched);
490
- if (session && event.type === 'usage')
859
+ // Deleted by a subscriber of the event just emitted: neither `save` nor
860
+ // the rest of the batch may write the record or log back.
861
+ if (store.get(id) !== session)
862
+ return;
863
+ if (SESSION_MUTATING_EVENTS.has(event.type))
491
864
  save(session);
492
865
  }
493
866
  }
867
+ function handleParsed(id, turn, events) {
868
+ if (events.length === 0)
869
+ return;
870
+ turn.sawOutput = true;
871
+ applyEvents(id, turn, events);
872
+ }
873
+ /**
874
+ * Run one telemetry hook. Hooks read the CLI's own files; they are bounded
875
+ * and synchronous by contract, but a hook that throws, returns garbage or
876
+ * returns a non-telemetry event is contained here — telemetry is never
877
+ * allowed to break, fail or extend a turn.
878
+ *
879
+ * The WHOLE body is guarded, not just the hook call: the poll runs from a
880
+ * timer, where anything thrown (a store write failing, a subscriber's
881
+ * callback) would be an uncaught exception in the server process rather
882
+ * than a failed turn.
883
+ */
884
+ function runTelemetryHook(id, turn, hook) {
885
+ try {
886
+ const fn = turn.adapter[hook];
887
+ if (typeof fn !== 'function')
888
+ return;
889
+ const current = store.get(id);
890
+ if (!current)
891
+ return;
892
+ const ctx = turn.hookCtx;
893
+ ctx.session = cloneSession(current);
894
+ let observed = null;
895
+ try {
896
+ observed = turn.parser.nativeSessionId();
897
+ }
898
+ catch {
899
+ observed = null;
900
+ }
901
+ ctx.nativeSessionId = observed ?? current.nativeSessionId;
902
+ let produced;
903
+ try {
904
+ produced = fn.call(turn.adapter, ctx);
905
+ }
906
+ catch {
907
+ return;
908
+ }
909
+ if (!Array.isArray(produced))
910
+ return;
911
+ const events = produced.filter((event) => isObject(event) && typeof event['type'] === 'string' && TELEMETRY_EVENTS.has(event['type']));
912
+ if (events.length > 0)
913
+ applyEvents(id, turn, events, hook === 'pollTelemetry');
914
+ }
915
+ catch {
916
+ // Best effort by design; the turn's own outcome is decided elsewhere.
917
+ }
918
+ }
494
919
  function pushLine(id, turn, line) {
495
920
  if (turn.settled)
496
921
  return;
@@ -535,7 +960,23 @@ export function createVerseEngine(opts = {}) {
535
960
  if (turn.termination !== null)
536
961
  finished = finished.filter((event) => event.type !== 'error');
537
962
  handleParsed(id, turn, finished);
963
+ // After the parser flushed (so its own usage is already applied and a
964
+ // file-based reading REPLACES it rather than being overwritten by it), and
965
+ // before `turn-done`, so every client sees the final occupancy and any
966
+ // compaction as part of the turn. Runs for stopped turns too: a turn that
967
+ // was cancelled part-way can still have compacted. Skipped only when the
968
+ // process never existed (async spawn failure, no pid): it wrote nothing,
969
+ // and a file read then could only find some OTHER run's records.
970
+ if (turn.child.pid !== undefined)
971
+ runTelemetryHook(id, turn, 'afterTurn');
538
972
  const session = store.get(id);
973
+ if (!session) {
974
+ // Deleted while settling (a subscriber of one of the events above
975
+ // removed the chat). Its subscribers went with it; a close-out written
976
+ // now would only re-create the log of a chat that no longer exists.
977
+ detachChild(turn);
978
+ return;
979
+ }
539
980
  let ok = exitCode === 0 && !turn.sawError;
540
981
  let lastError = null;
541
982
  // Stop is a normal action, not a failure: the session returns to idle
@@ -565,25 +1006,34 @@ export function createVerseEngine(opts = {}) {
565
1006
  }
566
1007
  }
567
1008
  const nativeFromOutput = turn.parser.nativeSessionId();
568
- const nativeSessionId = nativeFromOutput ?? session?.nativeSessionId ?? null;
569
- emit(id, {
570
- type: 'turn-done',
571
- turnId: turn.turnId,
572
- ok,
573
- nativeSessionId,
574
- durationMs: Math.max(0, Date.now() - turn.startedAt),
575
- });
576
- if (session) {
577
- if (nativeFromOutput && !session.nativeSessionId)
578
- session.nativeSessionId = nativeFromOutput;
579
- // Count the turn once the CLI engaged: a resumed conversation exists on
580
- // the vendor side even when the turn was cancelled part-way.
581
- if (ok || turn.sawOutput)
582
- session.turnCount += 1;
583
- session.status = ok || stopped ? 'idle' : 'error';
584
- session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
585
- save(session);
1009
+ const nativeSessionId = nativeFromOutput ?? session.nativeSessionId ?? null;
1010
+ // Same rule for a subscriber of the close-out events themselves.
1011
+ const alive = () => store.get(id) === session;
1012
+ if (alive()) {
1013
+ emit(id, {
1014
+ type: 'turn-done',
1015
+ turnId: turn.turnId,
1016
+ ok,
1017
+ nativeSessionId,
1018
+ durationMs: Math.max(0, Date.now() - turn.startedAt),
1019
+ });
586
1020
  }
1021
+ if (!alive()) {
1022
+ detachChild(turn);
1023
+ return;
1024
+ }
1025
+ if (nativeFromOutput && !session.nativeSessionId)
1026
+ session.nativeSessionId = nativeFromOutput;
1027
+ // Count the turn once the CLI engaged: a resumed conversation exists on
1028
+ // the vendor side even when the turn was cancelled part-way.
1029
+ if (ok || turn.sawOutput)
1030
+ session.turnCount += 1;
1031
+ session.status = ok || stopped ? 'idle' : 'error';
1032
+ session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
1033
+ save(session);
1034
+ detachChild(turn);
1035
+ }
1036
+ function detachChild(turn) {
587
1037
  turn.child.removeAllListeners();
588
1038
  turn.child.on('error', () => { });
589
1039
  turn.child.stdout?.removeAllListeners();
@@ -625,6 +1075,7 @@ export function createVerseEngine(opts = {}) {
625
1075
  }
626
1076
  const adapter = adapterFor(session.engine);
627
1077
  const parser = adapter.createParser(turnId);
1078
+ const option = effectiveModelOption(seatLaunch.seat, session.model, session.engine);
628
1079
  const env = buildTurnEnv(launch.env);
629
1080
  const [bin, ...args] = launch.argv;
630
1081
  const detached = process.platform !== 'win32';
@@ -647,12 +1098,24 @@ export function createVerseEngine(opts = {}) {
647
1098
  save(session);
648
1099
  return;
649
1100
  }
1101
+ const startedAt = Date.now();
650
1102
  const turn = {
651
1103
  turnId,
652
1104
  child,
653
1105
  pgid: detached && typeof child.pid === 'number' && child.pid > 0 ? child.pid : null,
654
- startedAt: Date.now(),
1106
+ startedAt,
655
1107
  parser,
1108
+ adapter,
1109
+ option,
1110
+ hookCtx: {
1111
+ session: cloneSession(session),
1112
+ launch: seatLaunch,
1113
+ turnId,
1114
+ startedAt,
1115
+ nativeSessionId: session.nativeSessionId,
1116
+ parser,
1117
+ state: {},
1118
+ },
656
1119
  stdoutBuf: '',
657
1120
  stderrTail: [],
658
1121
  redactions,
@@ -663,6 +1126,7 @@ export function createVerseEngine(opts = {}) {
663
1126
  timeoutTimer: null,
664
1127
  escalationTimer: null,
665
1128
  drainTimer: null,
1129
+ pollTimer: null,
666
1130
  };
667
1131
  running.set(id, turn);
668
1132
  emit(id, { type: 'turn-started', turnId, pid: typeof child.pid === 'number' ? child.pid : null });
@@ -716,29 +1180,50 @@ export function createVerseEngine(opts = {}) {
716
1180
  }, turnTimeoutMs);
717
1181
  if (turn.timeoutTimer.unref)
718
1182
  turn.timeoutTimer.unref();
1183
+ // Live meter for CLIs that only record exact occupancy in their own files.
1184
+ // Unref'd so a poll never keeps the process alive; cleared in finalize.
1185
+ if (typeof adapter.pollTelemetry === 'function') {
1186
+ turn.pollTimer = setInterval(() => {
1187
+ if (turn.settled)
1188
+ return;
1189
+ runTelemetryHook(id, turn, 'pollTelemetry');
1190
+ }, telemetryPollMs);
1191
+ if (turn.pollTimer.unref)
1192
+ turn.pollTimer.unref();
1193
+ }
719
1194
  }
720
1195
  // ---- handle -----------------------------------------------------------------
721
1196
  return {
722
1197
  listSessions() {
723
- return store.list()
1198
+ const sessions = store.list();
1199
+ // Normally a no-op (the startup pass already ran); covers a record that
1200
+ // reached the store after this engine started.
1201
+ for (const session of sessions)
1202
+ materializeLegacyMode(session);
1203
+ return sessions
724
1204
  .map(cloneSession)
725
1205
  .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
726
1206
  },
727
1207
  getSession(id) {
728
1208
  const session = typeof id === 'string' ? store.get(id) : null;
729
- return session ? cloneSession(session) : null;
1209
+ if (!session)
1210
+ return null;
1211
+ materializeLegacyMode(session);
1212
+ return cloneSession(session);
730
1213
  },
731
1214
  getEvents(id, fromSeq = 0) {
732
1215
  require(id);
733
1216
  return store.readEvents(id, fromSeq);
734
1217
  },
735
- createSession(req, launch) {
1218
+ createSession(req, launch, opts = {}) {
736
1219
  if (closed)
737
1220
  throw new VerseError('VERSE_INVALID', 'verse engine is closed');
738
1221
  if (!isObject(req))
739
1222
  throw new VerseError('VERSE_INVALID', 'request body must be an object');
740
1223
  if (!isSeatLaunch(launch))
741
1224
  throw new VerseError('VERSE_INVALID', 'seat launch is malformed');
1225
+ if (!isObject(opts))
1226
+ throw new VerseError('VERSE_INVALID', 'create options must be an object');
742
1227
  const projectPath = resolveProjectDir(req.projectPath);
743
1228
  const extraRoots = resolveExtraRoots(req.extraRoots, projectPath);
744
1229
  if (req.workspaceId !== undefined && typeof req.workspaceId !== 'string') {
@@ -752,15 +1237,58 @@ export function createVerseEngine(opts = {}) {
752
1237
  if (engine !== 'local' && (!launch.launcher || launch.launcher.length === 0)) {
753
1238
  throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no launcher`);
754
1239
  }
755
- const model = typeof req.model === 'string' && req.model.trim() ? req.model.trim() : seat.models[0]?.id;
756
- if (!model)
757
- throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no models`);
758
- if (!seat.models.some((m) => m.id === model)) {
759
- throw new VerseError('VERSE_INVALID', `model ${model} is not available on seat ${seat.id}`);
1240
+ // Model. An explicit request may name the retired alias; the record
1241
+ // stores the id the SEAT lists, so new sessions carry the canonical id.
1242
+ // The default is the first RUNNABLE model — a listed-but-unavailable
1243
+ // model (e.g. one the pinned CLI is too old for) is never picked silently.
1244
+ let option;
1245
+ if (typeof req.model === 'string' && req.model.trim()) {
1246
+ const requested = req.model.trim();
1247
+ option = findModelOption(seat, requested);
1248
+ if (!option)
1249
+ throw new VerseError('VERSE_INVALID', `model ${requested} is not available on seat ${seat.id}`);
1250
+ }
1251
+ else {
1252
+ if (seat.models.length === 0)
1253
+ throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no models`);
1254
+ option = seat.models.find((m) => !m.unavailableReason) ?? null;
1255
+ if (!option)
1256
+ throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no runnable models`);
760
1257
  }
1258
+ if (typeof option.unavailableReason === 'string' && option.unavailableReason.trim()) {
1259
+ throw new VerseError('VERSE_INVALID', `model ${option.id} is unavailable on seat ${seat.id}: ${option.unavailableReason.trim()}`);
1260
+ }
1261
+ const model = option.id;
1262
+ // Context mode. A request without one gets `standard`. Standard always
1263
+ // exists — it is the CLI's own behaviour, known or estimated — but a
1264
+ // mode the model has no budget for is refused, never faked.
1265
+ let contextMode = 'standard';
1266
+ if (req.contextMode !== undefined) {
1267
+ if (!isContextMode(req.contextMode)) {
1268
+ throw new VerseError('VERSE_INVALID', `contextMode must be one of: ${VERSE_CONTEXT_MODES.join(', ')}`);
1269
+ }
1270
+ contextMode = req.contextMode;
1271
+ }
1272
+ const budgetOption = effectiveModelOption(seat, model, engine);
1273
+ if (contextMode !== 'standard' && !budgetFor(budgetOption, contextMode)) {
1274
+ throw new VerseError('VERSE_INVALID', `model ${model} has no ${contextMode} context mode on seat ${seat.id}`);
1275
+ }
1276
+ const budget = sessionBudgetFor(seat, budgetOption, contextMode, engine);
761
1277
  if (req.title !== undefined && typeof req.title !== 'string') {
762
1278
  throw new VerseError('VERSE_INVALID', 'title must be a string');
763
1279
  }
1280
+ // Memory: `undefined` = the caller did not decide (pre-3.9) → no key;
1281
+ // `null` = decided OFF → `memoryEnabled: false`; a snapshot → pinned.
1282
+ let memory;
1283
+ if (opts.memory !== undefined && opts.memory !== null) {
1284
+ if (!isSessionMemory(opts.memory))
1285
+ throw new VerseError('VERSE_INVALID', 'memory snapshot is malformed');
1286
+ memory = { dir: opts.memory.dir, block: opts.memory.block, writable: opts.memory.writable };
1287
+ }
1288
+ else {
1289
+ memory = opts.memory;
1290
+ }
1291
+ const handoffFrom = resolveHandoffSource(req, opts);
764
1292
  const title = req.title ? normaliseTitle(req.title) : '';
765
1293
  const at = nowIso();
766
1294
  const session = {
@@ -792,9 +1320,18 @@ export function createVerseEngine(opts = {}) {
792
1320
  cacheReadTokens: 0,
793
1321
  cacheCreationTokens: 0,
794
1322
  contextTokens: 0,
795
- contextWindow: contextWindowFor(seat, model, engine),
1323
+ contextWindow: budget.contextWindow,
1324
+ contextWindowSource: budget.source,
1325
+ autoCompactAt: budget.autoCompactAt,
796
1326
  },
797
1327
  lastError: null,
1328
+ // ALWAYS written, even for `standard`: from 3.9 on, a record with no
1329
+ // `contextMode` key is by definition one created before modes existed
1330
+ // (see `materializeLegacyMode`), and that inference is only sound if
1331
+ // no new record ever omits the key.
1332
+ contextMode,
1333
+ ...(handoffFrom ? { handoffFrom } : {}),
1334
+ ...(memory !== undefined ? { memoryEnabled: memory !== null } : {}),
798
1335
  };
799
1336
  store.save(session);
800
1337
  store.saveLaunch(session.id, {
@@ -807,6 +1344,9 @@ export function createVerseEngine(opts = {}) {
807
1344
  ...(typeof launch.anthropicBaseUrl === 'string' && launch.anthropicBaseUrl.length > 0
808
1345
  ? { anthropicBaseUrl: launch.anthropicBaseUrl }
809
1346
  : {}),
1347
+ // Pinned for the same reason, and so the block sent every turn is the
1348
+ // same bytes every turn (prompt-cache stable).
1349
+ ...(memory ? { memory } : {}),
810
1350
  });
811
1351
  return cloneSession(session);
812
1352
  },
@@ -829,6 +1369,13 @@ export function createVerseEngine(opts = {}) {
829
1369
  if (!isSeatLaunch(launch)) {
830
1370
  throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
831
1371
  }
1372
+ // A memory snapshot pinned before control characters were stripped can
1373
+ // hold a NUL, which no OS accepts inside an argv entry — the session
1374
+ // could never start again. Repair the in-memory copy for this launch;
1375
+ // the pinned record stays as written (it is the provenance).
1376
+ if (launch.memory && launch.memory.block !== stripUnsafeControlChars(launch.memory.block)) {
1377
+ launch.memory = { ...launch.memory, block: stripUnsafeControlChars(launch.memory.block) };
1378
+ }
832
1379
  const turnId = randomUUID();
833
1380
  if (session.turnCount === 0 && session.title === DEFAULT_TITLE)
834
1381
  session.title = autoTitle(text);
@@ -852,6 +1399,80 @@ export function createVerseEngine(opts = {}) {
852
1399
  startTurn(session, turnId, launch, turnLaunch, redactionsFor(launch, turnLaunch));
853
1400
  return { turnId, session: cloneSession(store.get(id) ?? session) };
854
1401
  },
1402
+ setContextMode(id, mode) {
1403
+ if (closed)
1404
+ throw new VerseError('VERSE_INVALID', 'verse engine is closed');
1405
+ const session = require(id);
1406
+ if (!isContextMode(mode)) {
1407
+ throw new VerseError('VERSE_INVALID', `mode must be one of: ${VERSE_CONTEXT_MODES.join(', ')}`);
1408
+ }
1409
+ if (running.has(id)) {
1410
+ throw new VerseError('VERSE_SESSION_BUSY', 'the context mode can change between turns; wait for this turn to finish or stop it');
1411
+ }
1412
+ const launch = store.loadLaunch(id);
1413
+ if (!isSeatLaunch(launch)) {
1414
+ throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
1415
+ }
1416
+ const option = effectiveModelOption(launch.seat, session.model, session.engine);
1417
+ if (mode !== 'standard' && !budgetFor(option, mode)) {
1418
+ throw new VerseError('VERSE_INVALID', `model ${session.model} has no ${mode} context mode on seat ${session.seatId}`);
1419
+ }
1420
+ if ((session.contextMode ?? 'standard') === mode)
1421
+ return cloneSession(session);
1422
+ session.contextMode = mode;
1423
+ const runtimeWindow = session.usage.contextWindowSource === 'runtime' ? positiveInt(session.usage.contextWindow) : null;
1424
+ if (runtimeWindow !== null && session.engine === 'claude') {
1425
+ // Claude's window is a property of the MODEL (the CLI reported it);
1426
+ // the mode only moves the `--autocompact` point. Keep the measured
1427
+ // window and recompute where it now compacts.
1428
+ applyRuntimeWindow(session, option, runtimeWindow);
1429
+ }
1430
+ else {
1431
+ // Codex's window IS the mode (`-c model_context_window=…`), so a
1432
+ // previous runtime reading describes the old mode. Show the new
1433
+ // budget until the next turn reports its own.
1434
+ const budget = sessionBudgetFor(launch.seat, option, mode, session.engine);
1435
+ session.usage.contextWindow = budget.contextWindow;
1436
+ session.usage.contextWindowSource = budget.source;
1437
+ session.usage.autoCompactAt = budget.autoCompactAt;
1438
+ }
1439
+ save(session);
1440
+ emitBudgetChange(id, session);
1441
+ return cloneSession(session);
1442
+ },
1443
+ refreshLocalWindow(id, option) {
1444
+ if (closed)
1445
+ throw new VerseError('VERSE_INVALID', 'verse engine is closed');
1446
+ const session = require(id);
1447
+ if (session.engine !== 'local')
1448
+ return cloneSession(session);
1449
+ if (running.has(id)) {
1450
+ throw new VerseError('VERSE_SESSION_BUSY', 'the window can change between turns; wait for this turn to finish or stop it');
1451
+ }
1452
+ if (!isObject(option) || typeof option.id !== 'string' || canonicalModelId(option.id) !== canonicalModelId(session.model)) {
1453
+ throw new VerseError('VERSE_INVALID', `live option does not describe model ${session.model}`);
1454
+ }
1455
+ // A live option with no usable window is "no reading" — keep the stored
1456
+ // one rather than erase the only window the CLI can be told.
1457
+ const budget = budgetFor(option, 'standard');
1458
+ if (!budget)
1459
+ return cloneSession(session);
1460
+ // The caller's option is data: an unknown source would make the record
1461
+ // fail validation on its next read, so it is recorded as a fallback.
1462
+ const source = isVerseWindowSource(option.windowSource) ? option.windowSource : 'fallback';
1463
+ const usage = session.usage;
1464
+ if (usage.contextWindow === budget.contextWindow
1465
+ && (usage.autoCompactAt ?? null) === budget.autoCompactAt
1466
+ && usage.contextWindowSource === source) {
1467
+ return cloneSession(session);
1468
+ }
1469
+ usage.contextWindow = budget.contextWindow;
1470
+ usage.autoCompactAt = budget.autoCompactAt;
1471
+ usage.contextWindowSource = source;
1472
+ save(session);
1473
+ emitBudgetChange(id, session);
1474
+ return cloneSession(session);
1475
+ },
855
1476
  cancelTurn(id) {
856
1477
  require(id);
857
1478
  const turn = running.get(id);