@ashlr/hub 3.7.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 (155) hide show
  1. package/CHANGELOG.md +257 -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/policy/local-only.d.ts +142 -2
  6. package/dist/core/policy/local-only.js +204 -15
  7. package/dist/core/policy/local-only.js.map +1 -1
  8. package/dist/core/resources/native-profile.d.ts +56 -0
  9. package/dist/core/resources/native-profile.js +368 -6
  10. package/dist/core/resources/native-profile.js.map +1 -1
  11. package/dist/core/run/budget.d.ts +22 -8
  12. package/dist/core/run/budget.js +80 -12
  13. package/dist/core/run/budget.js.map +1 -1
  14. package/dist/core/run/model-catalog.js +8 -1
  15. package/dist/core/run/model-catalog.js.map +1 -1
  16. package/dist/core/run/sandboxed-engine.js +3 -3
  17. package/dist/core/run/sandboxed-engine.js.map +1 -1
  18. package/dist/core/universe/builtins/preparation/manifest.json +1 -1
  19. package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +69 -10
  20. package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +68 -9
  21. package/dist/core/verse/adapters/claude.d.ts +49 -2
  22. package/dist/core/verse/adapters/claude.js +317 -56
  23. package/dist/core/verse/adapters/claude.js.map +1 -1
  24. package/dist/core/verse/adapters/codex.d.ts +81 -5
  25. package/dist/core/verse/adapters/codex.js +409 -47
  26. package/dist/core/verse/adapters/codex.js.map +1 -1
  27. package/dist/core/verse/adapters/grok.d.ts +13 -1
  28. package/dist/core/verse/adapters/grok.js +28 -2
  29. package/dist/core/verse/adapters/grok.js.map +1 -1
  30. package/dist/core/verse/adapters/index.d.ts +32 -0
  31. package/dist/core/verse/adapters/index.js +2 -0
  32. package/dist/core/verse/adapters/index.js.map +1 -1
  33. package/dist/core/verse/codex-rollout.d.ts +284 -0
  34. package/dist/core/verse/codex-rollout.js +786 -0
  35. package/dist/core/verse/codex-rollout.js.map +1 -0
  36. package/dist/core/verse/context-fit.d.ts +63 -0
  37. package/dist/core/verse/context-fit.js +355 -0
  38. package/dist/core/verse/context-fit.js.map +1 -0
  39. package/dist/core/verse/context-math.d.ts +215 -0
  40. package/dist/core/verse/context-math.js +365 -0
  41. package/dist/core/verse/context-math.js.map +1 -0
  42. package/dist/core/verse/local-models.d.ts +137 -1
  43. package/dist/core/verse/local-models.js +209 -5
  44. package/dist/core/verse/local-models.js.map +1 -1
  45. package/dist/core/verse/model-windows.d.ts +217 -0
  46. package/dist/core/verse/model-windows.js +616 -0
  47. package/dist/core/verse/model-windows.js.map +1 -0
  48. package/dist/core/verse/preferences.d.ts +123 -0
  49. package/dist/core/verse/preferences.js +403 -0
  50. package/dist/core/verse/preferences.js.map +1 -0
  51. package/dist/core/verse/project-memory.d.ts +112 -0
  52. package/dist/core/verse/project-memory.js +295 -0
  53. package/dist/core/verse/project-memory.js.map +1 -0
  54. package/dist/core/verse/seats.d.ts +106 -4
  55. package/dist/core/verse/seats.js +291 -89
  56. package/dist/core/verse/seats.js.map +1 -1
  57. package/dist/core/verse/session-engine.d.ts +117 -2
  58. package/dist/core/verse/session-engine.js +763 -65
  59. package/dist/core/verse/session-engine.js.map +1 -1
  60. package/dist/core/verse/session-handoff.d.ts +94 -0
  61. package/dist/core/verse/session-handoff.js +551 -0
  62. package/dist/core/verse/session-handoff.js.map +1 -0
  63. package/dist/core/verse/session-search.d.ts +63 -0
  64. package/dist/core/verse/session-search.js +213 -0
  65. package/dist/core/verse/session-search.js.map +1 -0
  66. package/dist/core/verse/session-store.d.ts +16 -2
  67. package/dist/core/verse/session-store.js +80 -6
  68. package/dist/core/verse/session-store.js.map +1 -1
  69. package/dist/core/verse/types.d.ts +333 -1
  70. package/dist/core/verse/types.js +54 -2
  71. package/dist/core/verse/types.js.map +1 -1
  72. package/dist/core/verse/verse-api.d.ts +40 -5
  73. package/dist/core/verse/verse-api.js +724 -25
  74. package/dist/core/verse/verse-api.js.map +1 -1
  75. package/dist/core/web/api.d.ts +14 -4
  76. package/dist/core/web/api.js +26 -11
  77. package/dist/core/web/api.js.map +1 -1
  78. package/dist/core/web/public/next/assets/{App-vtaHB9mp.js → App-XURmiAAp.js} +5 -5
  79. package/dist/core/web/public/next/assets/{App-vtaHB9mp.js.map → App-XURmiAAp.js.map} +1 -1
  80. package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js → ApprovalsSection-BriUzoZc.js} +2 -2
  81. package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js.map → ApprovalsSection-BriUzoZc.js.map} +1 -1
  82. package/dist/core/web/public/next/assets/AutonomySection-C4GBsQVc.js +2 -0
  83. package/dist/core/web/public/next/assets/{AutonomySection-DoqmsCjw.js.map → AutonomySection-C4GBsQVc.js.map} +1 -1
  84. package/dist/core/web/public/next/assets/ChatSection-BpOBOUvg.css +1 -0
  85. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js +84 -0
  86. package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js.map +1 -0
  87. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js +2 -0
  88. package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js.map +1 -0
  89. package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js → Dialog-BxdIfmFH.js} +2 -2
  90. package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js.map → Dialog-BxdIfmFH.js.map} +1 -1
  91. package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js → DiffViewer-DmeRWuc4.js} +2 -2
  92. package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
  93. package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js → McpSection-q34T7Tho.js} +2 -2
  94. package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js.map → McpSection-q34T7Tho.js.map} +1 -1
  95. package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
  96. package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
  97. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
  98. package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
  99. package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
  100. package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js.map → RouteErrorBoundary-ZL9F4qUn.js.map} +1 -1
  101. package/dist/core/web/public/next/assets/{Meter-CLcr-4wS.css → Segmented-8cf0Fypn.css} +1 -1
  102. package/dist/core/web/public/next/assets/{Meter--h3MmZS-.js → Segmented-DbRjVye3.js} +2 -2
  103. package/dist/core/web/public/next/assets/Segmented-DbRjVye3.js.map +1 -0
  104. package/dist/core/web/public/next/assets/SettingsSection-nJGGGYqv.js +2 -0
  105. package/dist/core/web/public/next/assets/{SettingsSection-BwrpQlpP.js.map → SettingsSection-nJGGGYqv.js.map} +1 -1
  106. package/dist/core/web/public/next/assets/UniverseConsoleApp-6GJydgo4.js +2 -0
  107. package/dist/core/web/public/next/assets/{UniverseConsoleApp-_FSohAdN.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
  108. package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js → UniverseView-DLDtNYL8.js} +2 -2
  109. package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js.map → UniverseView-DLDtNYL8.js.map} +1 -1
  110. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js +2 -0
  111. package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js.map +1 -0
  112. package/dist/core/web/public/next/assets/VerseConsoleApp-DsdKQoTH.css +1 -0
  113. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js +3 -0
  114. package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js.map +1 -0
  115. package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js → charts-CVooqJWN.js} +2 -2
  116. package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js.map → charts-CVooqJWN.js.map} +1 -1
  117. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js +2 -0
  118. package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js.map +1 -0
  119. package/dist/core/web/public/next/assets/global-eaY9GyX3.js +2 -0
  120. package/dist/core/web/public/next/assets/global-eaY9GyX3.js.map +1 -0
  121. package/dist/core/web/public/next/assets/{icons-D9vlf85u.js → icons-vex8rclC.js} +2 -2
  122. package/dist/core/web/public/next/assets/{icons-D9vlf85u.js.map → icons-vex8rclC.js.map} +1 -1
  123. package/dist/core/web/public/next/assets/{index-CZamKRUg.js → index-D2Eik-zt.js} +3 -3
  124. package/dist/core/web/public/next/assets/{index-CZamKRUg.js.map → index-D2Eik-zt.js.map} +1 -1
  125. package/dist/core/web/public/next/assets/{queries-B8eBnFox.js → queries-bAYU0VNy.js} +2 -2
  126. package/dist/core/web/public/next/assets/{queries-B8eBnFox.js.map → queries-bAYU0VNy.js.map} +1 -1
  127. package/dist/core/web/public/next/assets/use-guarded-action-C7QrP7Yp.js +2 -0
  128. package/dist/core/web/public/next/assets/{use-guarded-action-bzrVVAYq.js.map → use-guarded-action-C7QrP7Yp.js.map} +1 -1
  129. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js +2 -0
  130. package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js.map +1 -0
  131. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js → workspace-model-CHol5RVp.js} +2 -2
  132. package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js.map → workspace-model-CHol5RVp.js.map} +1 -1
  133. package/dist/core/web/public/next/index.html +1 -1
  134. package/dist/release-dependency-inventory.json +1 -1
  135. package/docs/README.md +1 -0
  136. package/package.json +1 -1
  137. package/dist/core/web/public/next/assets/AutonomySection-DoqmsCjw.js +0 -2
  138. package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
  139. package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js +0 -80
  140. package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js.map +0 -1
  141. package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js +0 -2
  142. package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js.map +0 -1
  143. package/dist/core/web/public/next/assets/Meter--h3MmZS-.js.map +0 -1
  144. package/dist/core/web/public/next/assets/SettingsSection-BwrpQlpP.js +0 -2
  145. package/dist/core/web/public/next/assets/UniverseConsoleApp-_FSohAdN.js +0 -2
  146. package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js +0 -2
  147. package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js.map +0 -1
  148. package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
  149. package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js +0 -3
  150. package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js.map +0 -1
  151. package/dist/core/web/public/next/assets/global-D6bTxIRn.js +0 -2
  152. package/dist/core/web/public/next/assets/global-D6bTxIRn.js.map +0 -1
  153. package/dist/core/web/public/next/assets/use-guarded-action-bzrVVAYq.js +0 -2
  154. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js +0 -2
  155. package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js.map +0 -1
@@ -10,16 +10,40 @@
10
10
  * its own process-group leader (`detached: true`) so cancellation signals the
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
+ *
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
+ *
27
+ * LOCAL-ONLY. Because the spawn is raw rather than routed through
28
+ * `run/engines.spawnEngine`, this module is a SECOND subprocess funnel and none
29
+ * of the daemon's gates cover it. It therefore carries its own call to the one
30
+ * shared predicate (`policy/local-only.decidePermission`, reached through its
31
+ * published wrappers) at the top of `startTurn` — see `verseSeatPermitted`.
13
32
  */
14
33
  import { spawn as nodeSpawn } from 'node:child_process';
15
34
  import { randomUUID } from 'node:crypto';
16
35
  import { realpathSync, statSync } from 'node:fs';
17
36
  import { homedir } from 'node:os';
18
37
  import { basename, dirname, isAbsolute, join, sep } from 'node:path';
38
+ import { loadConfigReadOnly } from '../config.js';
39
+ import { endpointPermitted, enginePermitted, } from '../policy/local-only.js';
19
40
  import { scrubSecrets } from '../util/scrub.js';
20
- import { adapterFor } from './adapters/index.js';
21
- import { createVerseSessionStore } from './session-store.js';
22
- 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';
23
47
  const VERSE_ERROR_STATUS = {
24
48
  VERSE_SESSION_NOT_FOUND: 404,
25
49
  VERSE_SESSION_BUSY: 409,
@@ -37,6 +61,45 @@ export class VerseError extends Error {
37
61
  }
38
62
  }
39
63
  // ---------------------------------------------------------------------------
64
+ // Local-only
65
+ // ---------------------------------------------------------------------------
66
+ /**
67
+ * Is this SEAT permitted to run a turn right now?
68
+ *
69
+ * NOT a second policy predicate — that is the point. Both branches delegate to
70
+ * `decidePermission()` through its published wrappers, so "local" means here
71
+ * exactly what it means at the daemon's chokepoints. This function contributes
72
+ * only the mapping from a Verse seat to a subject the predicate understands:
73
+ *
74
+ * claude / codex / grok — a Verse engine id IS the registry engine id, so
75
+ * `enginePermitted` classifies it directly (cli-agent → cloud for claude and
76
+ * codex, api-model at api.x.ai → cloud for grok).
77
+ *
78
+ * local — a local seat is discovered from a serving runtime and has no
79
+ * registry engine to name, so the ENDPOINT it dispatches to is the truth
80
+ * and `endpointPermitted` classifies that. A loopback runtime (the default,
81
+ * and every local seat today) is permitted exactly as before. One repointed
82
+ * at a remote inference host is refused, because that one would spend — the
83
+ * same rule the raw-transport sites apply to `cfg.foundry.ollamaBaseUrl`.
84
+ */
85
+ export function verseSeatPermitted(engine, launch, cfg) {
86
+ if (engine === 'local') {
87
+ const dispatchUrl = launch.anthropicBaseUrl?.trim() || launch.ollamaBaseUrl;
88
+ return endpointPermitted(dispatchUrl, cfg);
89
+ }
90
+ return enginePermitted(engine, cfg);
91
+ }
92
+ /** Read the live config for the gate. A config we cannot read is not an excuse to dispatch. */
93
+ function readConfigForPolicy() {
94
+ try {
95
+ return loadConfigReadOnly();
96
+ }
97
+ catch {
98
+ // undefined → the policy falls back to env + latch, which errs toward refusal.
99
+ return undefined;
100
+ }
101
+ }
102
+ // ---------------------------------------------------------------------------
40
103
  // Env containment
41
104
  // ---------------------------------------------------------------------------
42
105
  const BASE_ENV_KEYS = ['PATH', 'HOME', 'TMPDIR', 'LANG', 'LC_ALL'];
@@ -89,6 +152,19 @@ function isObject(value) {
89
152
  function isStringArray(value) {
90
153
  return Array.isArray(value) && value.every((item) => typeof item === 'string');
91
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
+ }
92
168
  function isSeatLaunch(value) {
93
169
  if (!isObject(value))
94
170
  return false;
@@ -101,10 +177,17 @@ function isSeatLaunch(value) {
101
177
  && typeof value['ollamaBaseUrl'] === 'string'
102
178
  // Absent is valid: that is every launch record written before the
103
179
  // llama-server lane existed, and it means the Ollama lane.
104
- && (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']));
105
184
  }
106
185
  function cloneSession(session) {
107
- return { ...session, usage: { ...session.usage } };
186
+ return {
187
+ ...session,
188
+ usage: { ...session.usage },
189
+ ...(session.handoffFrom ? { handoffFrom: { ...session.handoffFrom } } : {}),
190
+ };
108
191
  }
109
192
  function normaliseTitle(raw) {
110
193
  const collapsed = raw.replace(/\s+/g, ' ').trim();
@@ -116,14 +199,98 @@ function autoTitle(text) {
116
199
  return DEFAULT_TITLE;
117
200
  return firstLine.length > AUTO_TITLE_CHARS ? `${firstLine.slice(0, AUTO_TITLE_CHARS).trimEnd()}…` : firstLine;
118
201
  }
119
- function contextWindowFor(seat, model, engine) {
120
- const option = seat.models.find((m) => m.id === model);
121
- if (option && typeof option.contextWindow === 'number')
122
- return option.contextWindow;
123
- if (typeof seat.contextWindow === 'number')
124
- return seat.contextWindow;
125
- const fallback = VERSE_DEFAULT_CONTEXT_WINDOWS[engine];
126
- 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
+ };
127
294
  }
128
295
  function resolveProjectDir(projectPath) {
129
296
  if (typeof projectPath !== 'string' || !projectPath.trim()) {
@@ -283,6 +450,9 @@ export function createVerseEngine(opts = {}) {
283
450
  const now = opts.now ?? (() => new Date());
284
451
  const turnTimeoutMs = opts.turnTimeoutMs ?? VERSE_TURN_TIMEOUT_MS;
285
452
  const killGraceMs = opts.killGraceMs ?? DEFAULT_KILL_GRACE_MS;
453
+ const loadCfg = opts.loadConfig ?? readConfigForPolicy;
454
+ const adapterFor = opts.adapterFor ?? defaultAdapterFor;
455
+ const telemetryPollMs = positiveInt(opts.telemetryPollMs) ?? VERSE_TELEMETRY_POLL_MS;
286
456
  const store = createVerseSessionStore(root);
287
457
  const running = new Map();
288
458
  const listeners = new Map();
@@ -296,8 +466,84 @@ export function createVerseEngine(opts = {}) {
296
466
  const session = store.get(id);
297
467
  if (!session)
298
468
  throw new VerseError('VERSE_SESSION_NOT_FOUND', `session not found: ${id}`);
469
+ materializeLegacyMode(session);
299
470
  return session;
300
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
+ }
301
547
  function emit(id, event) {
302
548
  const stored = store.appendEvent(id, event, nowIso());
303
549
  const subs = listeners.get(id);
@@ -346,6 +592,7 @@ export function createVerseEngine(opts = {}) {
346
592
  save(session);
347
593
  }
348
594
  for (const session of store.list()) {
595
+ materializeLegacyMode(session);
349
596
  if (session.status === 'running')
350
597
  reconcileInterrupted(session);
351
598
  }
@@ -377,6 +624,9 @@ export function createVerseEngine(opts = {}) {
377
624
  clearTimeout(timer);
378
625
  turn[key] = null;
379
626
  }
627
+ if (turn.pollTimer !== null)
628
+ clearInterval(turn.pollTimer);
629
+ turn.pollTimer = null;
380
630
  }
381
631
  function beginDrain(id, turn) {
382
632
  if (turn.settled || turn.drainTimer !== null)
@@ -410,39 +660,262 @@ export function createVerseEngine(opts = {}) {
410
660
  }, killGraceMs);
411
661
  }
412
662
  // ---- turn completion ----------------------------------------------------
413
- function applyUsage(session, event) {
414
- if (event.type !== 'usage')
415
- return event;
416
- const window = session.usage.contextWindow;
417
- // Adapters report the vendor's own figure; codex only exposes the turn
418
- // total (an upper bound on the live prompt), so never let the meter
419
- // exceed the window it is measured against.
420
- const contextTokens = window !== null ? Math.min(event.usage.contextTokens, window) : event.usage.contextTokens;
421
- const u = { ...event.usage, contextTokens, contextWindow: window };
422
- session.usage = {
423
- inputTokens: session.usage.inputTokens + u.inputTokens,
424
- outputTokens: session.usage.outputTokens + u.outputTokens,
425
- cacheReadTokens: session.usage.cacheReadTokens + u.cacheReadTokens,
426
- cacheCreationTokens: session.usage.cacheCreationTokens + u.cacheCreationTokens,
427
- 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)),
428
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 } : {}),
429
723
  };
430
- return { ...event, usage: u };
431
724
  }
432
- function handleParsed(id, turn, events) {
433
- if (events.length === 0)
434
- return;
435
- 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) {
436
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;
437
850
  for (const event of events) {
851
+ if (untilSettled && turn.settled)
852
+ return;
438
853
  if (event.type === 'error')
439
854
  turn.sawError = true;
440
- const enriched = session ? applyUsage(session, event) : event;
855
+ const enriched = applyUsage(session, turn.option, event, turn.turnId);
856
+ if (enriched === null)
857
+ continue;
441
858
  emit(id, enriched);
442
- 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))
443
864
  save(session);
444
865
  }
445
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
+ }
446
919
  function pushLine(id, turn, line) {
447
920
  if (turn.settled)
448
921
  return;
@@ -487,7 +960,23 @@ export function createVerseEngine(opts = {}) {
487
960
  if (turn.termination !== null)
488
961
  finished = finished.filter((event) => event.type !== 'error');
489
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');
490
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
+ }
491
980
  let ok = exitCode === 0 && !turn.sawError;
492
981
  let lastError = null;
493
982
  // Stop is a normal action, not a failure: the session returns to idle
@@ -517,25 +1006,34 @@ export function createVerseEngine(opts = {}) {
517
1006
  }
518
1007
  }
519
1008
  const nativeFromOutput = turn.parser.nativeSessionId();
520
- const nativeSessionId = nativeFromOutput ?? session?.nativeSessionId ?? null;
521
- emit(id, {
522
- type: 'turn-done',
523
- turnId: turn.turnId,
524
- ok,
525
- nativeSessionId,
526
- durationMs: Math.max(0, Date.now() - turn.startedAt),
527
- });
528
- if (session) {
529
- if (nativeFromOutput && !session.nativeSessionId)
530
- session.nativeSessionId = nativeFromOutput;
531
- // Count the turn once the CLI engaged: a resumed conversation exists on
532
- // the vendor side even when the turn was cancelled part-way.
533
- if (ok || turn.sawOutput)
534
- session.turnCount += 1;
535
- session.status = ok || stopped ? 'idle' : 'error';
536
- session.lastError = ok || stopped ? null : (lastError ?? session.lastError ?? 'turn failed');
537
- 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
+ });
538
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) {
539
1037
  turn.child.removeAllListeners();
540
1038
  turn.child.on('error', () => { });
541
1039
  turn.child.stdout?.removeAllListeners();
@@ -544,10 +1042,40 @@ export function createVerseEngine(opts = {}) {
544
1042
  turn.child.unref();
545
1043
  }
546
1044
  // ---- spawn ----------------------------------------------------------------
547
- function startTurn(session, turnId, launch, redactions) {
1045
+ function startTurn(session, turnId, seatLaunch, launch, redactions) {
548
1046
  const id = session.id;
1047
+ // ---- LOCAL-ONLY: the last gate before a vendor process exists ----------
1048
+ //
1049
+ // The cockpit's Local-only panel tells the operator that nothing can spend
1050
+ // money while the mode is on. That claim is only true if the refusal lands
1051
+ // HERE — before the spawn — because an interactive turn bypasses
1052
+ // `run/engines.spawnEngine` and every gate the daemon path carries.
1053
+ //
1054
+ // Reported as an ordinary failed turn (`error` then `turn-done`) rather
1055
+ // than thrown: the refusal has to be legible in the session the person is
1056
+ // looking at. A silent no-op, or an exception escaping into the control
1057
+ // API, both read as a broken app rather than a policy doing its job. The
1058
+ // reason is the policy's OWN sentence, quoted verbatim — it already names
1059
+ // the seat, how the mode resolved, and how to turn it off.
1060
+ const verdict = verseSeatPermitted(session.engine, seatLaunch, loadCfg());
1061
+ if (!verdict.permitted) {
1062
+ const message = verdict.reason ?? 'local-only: refused';
1063
+ emit(id, { type: 'error', turnId, message });
1064
+ emit(id, {
1065
+ type: 'turn-done',
1066
+ turnId,
1067
+ ok: false,
1068
+ nativeSessionId: session.nativeSessionId,
1069
+ durationMs: 0,
1070
+ });
1071
+ session.status = 'error';
1072
+ session.lastError = message;
1073
+ save(session);
1074
+ return;
1075
+ }
549
1076
  const adapter = adapterFor(session.engine);
550
1077
  const parser = adapter.createParser(turnId);
1078
+ const option = effectiveModelOption(seatLaunch.seat, session.model, session.engine);
551
1079
  const env = buildTurnEnv(launch.env);
552
1080
  const [bin, ...args] = launch.argv;
553
1081
  const detached = process.platform !== 'win32';
@@ -570,12 +1098,24 @@ export function createVerseEngine(opts = {}) {
570
1098
  save(session);
571
1099
  return;
572
1100
  }
1101
+ const startedAt = Date.now();
573
1102
  const turn = {
574
1103
  turnId,
575
1104
  child,
576
1105
  pgid: detached && typeof child.pid === 'number' && child.pid > 0 ? child.pid : null,
577
- startedAt: Date.now(),
1106
+ startedAt,
578
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
+ },
579
1119
  stdoutBuf: '',
580
1120
  stderrTail: [],
581
1121
  redactions,
@@ -586,6 +1126,7 @@ export function createVerseEngine(opts = {}) {
586
1126
  timeoutTimer: null,
587
1127
  escalationTimer: null,
588
1128
  drainTimer: null,
1129
+ pollTimer: null,
589
1130
  };
590
1131
  running.set(id, turn);
591
1132
  emit(id, { type: 'turn-started', turnId, pid: typeof child.pid === 'number' ? child.pid : null });
@@ -639,29 +1180,50 @@ export function createVerseEngine(opts = {}) {
639
1180
  }, turnTimeoutMs);
640
1181
  if (turn.timeoutTimer.unref)
641
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
+ }
642
1194
  }
643
1195
  // ---- handle -----------------------------------------------------------------
644
1196
  return {
645
1197
  listSessions() {
646
- 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
647
1204
  .map(cloneSession)
648
1205
  .sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : a.updatedAt > b.updatedAt ? -1 : 0));
649
1206
  },
650
1207
  getSession(id) {
651
1208
  const session = typeof id === 'string' ? store.get(id) : null;
652
- return session ? cloneSession(session) : null;
1209
+ if (!session)
1210
+ return null;
1211
+ materializeLegacyMode(session);
1212
+ return cloneSession(session);
653
1213
  },
654
1214
  getEvents(id, fromSeq = 0) {
655
1215
  require(id);
656
1216
  return store.readEvents(id, fromSeq);
657
1217
  },
658
- createSession(req, launch) {
1218
+ createSession(req, launch, opts = {}) {
659
1219
  if (closed)
660
1220
  throw new VerseError('VERSE_INVALID', 'verse engine is closed');
661
1221
  if (!isObject(req))
662
1222
  throw new VerseError('VERSE_INVALID', 'request body must be an object');
663
1223
  if (!isSeatLaunch(launch))
664
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');
665
1227
  const projectPath = resolveProjectDir(req.projectPath);
666
1228
  const extraRoots = resolveExtraRoots(req.extraRoots, projectPath);
667
1229
  if (req.workspaceId !== undefined && typeof req.workspaceId !== 'string') {
@@ -675,15 +1237,58 @@ export function createVerseEngine(opts = {}) {
675
1237
  if (engine !== 'local' && (!launch.launcher || launch.launcher.length === 0)) {
676
1238
  throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no launcher`);
677
1239
  }
678
- const model = typeof req.model === 'string' && req.model.trim() ? req.model.trim() : seat.models[0]?.id;
679
- if (!model)
680
- throw new VerseError('VERSE_INVALID', `seat ${seat.id} has no models`);
681
- if (!seat.models.some((m) => m.id === model)) {
682
- 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`);
683
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);
684
1277
  if (req.title !== undefined && typeof req.title !== 'string') {
685
1278
  throw new VerseError('VERSE_INVALID', 'title must be a string');
686
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);
687
1292
  const title = req.title ? normaliseTitle(req.title) : '';
688
1293
  const at = nowIso();
689
1294
  const session = {
@@ -715,9 +1320,18 @@ export function createVerseEngine(opts = {}) {
715
1320
  cacheReadTokens: 0,
716
1321
  cacheCreationTokens: 0,
717
1322
  contextTokens: 0,
718
- contextWindow: contextWindowFor(seat, model, engine),
1323
+ contextWindow: budget.contextWindow,
1324
+ contextWindowSource: budget.source,
1325
+ autoCompactAt: budget.autoCompactAt,
719
1326
  },
720
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 } : {}),
721
1335
  };
722
1336
  store.save(session);
723
1337
  store.saveLaunch(session.id, {
@@ -730,6 +1344,9 @@ export function createVerseEngine(opts = {}) {
730
1344
  ...(typeof launch.anthropicBaseUrl === 'string' && launch.anthropicBaseUrl.length > 0
731
1345
  ? { anthropicBaseUrl: launch.anthropicBaseUrl }
732
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 } : {}),
733
1350
  });
734
1351
  return cloneSession(session);
735
1352
  },
@@ -752,6 +1369,13 @@ export function createVerseEngine(opts = {}) {
752
1369
  if (!isSeatLaunch(launch)) {
753
1370
  throw new VerseError('VERSE_INVALID', 'session launch record is missing or unreadable');
754
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
+ }
755
1379
  const turnId = randomUUID();
756
1380
  if (session.turnCount === 0 && session.title === DEFAULT_TITLE)
757
1381
  session.title = autoTitle(text);
@@ -772,9 +1396,83 @@ export function createVerseEngine(opts = {}) {
772
1396
  save(session);
773
1397
  return { turnId, session: cloneSession(session) };
774
1398
  }
775
- startTurn(session, turnId, turnLaunch, redactionsFor(launch, turnLaunch));
1399
+ startTurn(session, turnId, launch, turnLaunch, redactionsFor(launch, turnLaunch));
776
1400
  return { turnId, session: cloneSession(store.get(id) ?? session) };
777
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
+ },
778
1476
  cancelTurn(id) {
779
1477
  require(id);
780
1478
  const turn = running.get(id);