newmark-agent 0.6.4 → 0.6.7

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 (70) hide show
  1. package/config.example.json +13 -3
  2. package/dist/cli-commands.d.ts +1 -1
  3. package/dist/cli-commands.js +53 -2
  4. package/dist/cli-discovery.js +3 -0
  5. package/dist/conversation-utility-host.bundle.cjs +2880 -1069
  6. package/dist/conversation-utility-host.js +94 -7
  7. package/dist/core/agent.d.ts +126 -12
  8. package/dist/core/agent.js +783 -172
  9. package/dist/core/agentKernelRunner.d.ts +7 -1
  10. package/dist/core/agentKernelRunner.js +156 -20
  11. package/dist/core/autoRouter.d.ts +49 -44
  12. package/dist/core/autoRouter.js +117 -302
  13. package/dist/core/config.js +27 -11
  14. package/dist/core/continuation/contracts.d.ts +1 -1
  15. package/dist/core/continuation/store.d.ts +91 -1
  16. package/dist/core/continuation/store.js +291 -61
  17. package/dist/core/conversationKernel.d.ts +49 -7
  18. package/dist/core/conversationKernel.js +303 -51
  19. package/dist/core/conversationStateDocument.d.ts +18 -0
  20. package/dist/core/conversationStateDocument.js +88 -0
  21. package/dist/core/conversationVisualBudget.d.ts +74 -0
  22. package/dist/core/conversationVisualBudget.js +153 -0
  23. package/dist/core/electronUtilityAgentClient.d.ts +3 -2
  24. package/dist/core/electronUtilityAgentClient.js +32 -8
  25. package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
  26. package/dist/core/electronUtilityRuntimePool.js +93 -24
  27. package/dist/core/hostRuntimeHooks.d.ts +23 -0
  28. package/dist/core/hostRuntimeHooks.js +17 -0
  29. package/dist/core/jevDecision.d.ts +144 -0
  30. package/dist/core/jevDecision.js +226 -0
  31. package/dist/core/memoryProbe.d.ts +24 -0
  32. package/dist/core/memoryProbe.js +104 -0
  33. package/dist/core/mobilePairing.d.ts +7 -1
  34. package/dist/core/mobilePairing.js +9 -1
  35. package/dist/core/performanceDiagnostics.d.ts +1 -1
  36. package/dist/core/routeDecisionValidator.d.ts +86 -0
  37. package/dist/core/routeDecisionValidator.js +249 -0
  38. package/dist/core/routeEligibility.d.ts +52 -0
  39. package/dist/core/routeEligibility.js +108 -0
  40. package/dist/core/runtimeMemoryBudget.d.ts +6 -0
  41. package/dist/core/runtimeMemoryBudget.js +12 -0
  42. package/dist/core/utilityAgentProtocol.d.ts +8 -10
  43. package/dist/core/utilityHostToolRouter.js +2 -0
  44. package/dist/core/visualDownscale.d.ts +6 -0
  45. package/dist/core/visualDownscale.js +137 -0
  46. package/dist/core/wslAgentClient.d.ts +1 -2
  47. package/dist/core/wslAgentClient.js +0 -8
  48. package/dist/core/wslAgentProtocol.d.ts +1 -10
  49. package/dist/core/wslAgentRuntimePool.d.ts +1 -3
  50. package/dist/core/wslAgentRuntimePool.js +17 -22
  51. package/dist/llm/provider.d.ts +3 -1
  52. package/dist/llm/provider.js +53 -9
  53. package/dist/main.js +107 -30
  54. package/dist/preload.js +3 -1
  55. package/dist/server.js +30 -18
  56. package/dist/tools/computerUse.d.ts +4 -0
  57. package/dist/tools/computerUse.js +202 -4
  58. package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
  59. package/dist/tools/computerUsePowerShellHost.js +105 -31
  60. package/dist/tools/index.js +4 -1
  61. package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
  62. package/dist/tui/src/i18n.js +12 -1
  63. package/dist/tui/src/render.js +9 -2
  64. package/dist/tui/src/settings-schema.js +19 -0
  65. package/dist/tui/src/state.js +69 -1
  66. package/dist/ui/index.html +651 -233
  67. package/dist/ui/lucide-sprite.svg +0 -8
  68. package/dist/wsl-agent-host.bundle.cjs +2685 -1044
  69. package/dist/wsl-agent-host.js +0 -3
  70. package/package.json +36 -9
@@ -68,6 +68,8 @@ const pngjs_1 = require("pngjs");
68
68
  const modelValidation_1 = require("./modelValidation");
69
69
  const modelValidationStore_1 = require("./modelValidationStore");
70
70
  const autoRouter_1 = require("./autoRouter");
71
+ const jevDecision_1 = require("./jevDecision");
72
+ const routeDecisionValidator_1 = require("./routeDecisionValidator");
71
73
  const agent_context_manager_1 = require("../context/services/agent-context-manager");
72
74
  const toolchain_1 = require("../toolchain");
73
75
  const agent_runtime_1 = require("../agent-runtime");
@@ -178,7 +180,7 @@ let CORE_SYSTEM_PROMPT = `You are Newmark Agent, a powerful AI coding assistant
178
180
  - Be thorough and precise. Verify your work.
179
181
  - Use tools appropriately - don't just describe, do it.
180
182
  - Deletion safety (intrinsic, non-overridable): file deletion is allowed only ONE file at a time under Agent supervision. Use the delete_file tool with an absolute path for every single-file delete. Never use bash or the terminal to delete files in bulk: recursive deletes (rm -r/-rf, Remove-Item -Recurse, rmdir /s, del /s), wildcard deletes (rm *.log, del *), loop deletes (for/foreach/while + rm), pipe-fed deletes (Get-ChildItem | Remove-Item), find/xargs deletes, and multi-target or multi-statement deletes are hard-blocked by the runtime and will be rejected. To remove a directory, delete its files one by one first, then remove the now-empty directory without a recursive flag.
181
- - For desktop Computer Use requests, follow observe -> decide -> act -> observe. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
183
+ - For desktop Computer Use requests, follow observe -> decide -> act -> observe. For passive waits, prefer one observe call with sparse_wait_ms (up to 5000) over repeated immediate full observations; it still returns a fresh full screenshot/UIA whether signaled or timed out, and no signal is not proof of no change. Start visible takeover with computer_use takeover_start before multi-step desktop control and stop it when finished. Prefer app-scoped actions through app_list/app_observe/app_* when controlling one taskbar application, because this preserves human collaboration around other windows. Prefer target_id from the latest high-priority semantic UI objects, otherwise precise coordinates from the latest observation. Use vision plus UI controls together when the selected model has vision input. Avoid destructive UI actions unless the user asked for them, and do not claim YOLO/OCR perception unless an actual detector/OCR result is present.
182
184
  - For browser buttons and scanned document pages, the strict recognition sequence is text layer/DOM -> screenshot to a validated vision model -> local OCR. Do not skip directly to OCR. If OCR is used, treat it as approximate evidence and repair only what surrounding context supports.
183
185
  - Multiple tool calls emitted in one provider turn run concurrently. Treat their returned records as one barrier: continue reasoning only after every call in that batch has returned either a successful receipt or a failure receipt.
184
186
  - terminal_takeover start creates or reuses a persistent session. Its successful start receipt completes that Build step immediately; continue with write/read or other work without waiting for detach, stop, shell exit, or terminal closure.
@@ -275,6 +277,8 @@ class Agent {
275
277
  // Bounded caller-owned peer slots retain protocol compatibility learning
276
278
  // between mailbox jobs. They never retain the child Agent or go to disk.
277
279
  peerProviderCaches = new Map();
280
+ /** Reuses the provider connection for current-model JEV calls without sharing conversation build state. */
281
+ jevProviderCache = {};
278
282
  awaitingAgentKernelRuntime = false;
279
283
  pendingAgentKernelQueue = [];
280
284
  linkedPlanAccess;
@@ -294,7 +298,7 @@ class Agent {
294
298
  finalizingWorkRunId = '';
295
299
  agentRunService = null;
296
300
  agentRunByWorkRunId = new Map();
297
- autoRouter;
301
+ routeController;
298
302
  resolvedDeployment = null;
299
303
  fixedDeployment = null;
300
304
  preferredConversationModelSelection = null;
@@ -306,11 +310,21 @@ class Agent {
306
310
  lastRouteRetryDelayMs = 0;
307
311
  routeAttemptStartedAt = 0;
308
312
  providerBalanceBlockedUntilByDeployment = new Map();
309
- explicitlyRatedRoutes = new Map();
310
313
  conversationStateCache = new Map();
311
314
  conversationStateCacheFingerprint = new Map();
315
+ conversationStateWrittenFingerprint = new Map();
316
+ conversationShardFingerprint = new Map();
317
+ hydratedShardEntries = new WeakSet();
318
+ /**
319
+ * Revision of the durable queue snapshot this runtime is working from. The
320
+ * merge only accepts a writer's queue when that writer has seen the persisted
321
+ * revision, so an outdated in-memory snapshot can neither drop queued work
322
+ * nor resurrect an entry that was already consumed or cleared.
323
+ */
324
+ continuationRevision = 0;
312
325
  conversationStateFlushTimers = new Map();
313
326
  conversationStateDirty = new Map();
327
+ conversationStateLastWriteAt = new Map();
314
328
  systemPromptCache = null;
315
329
  toolDefinitionCache = new Map();
316
330
  modelValidationPromise = null;
@@ -381,8 +395,7 @@ class Agent {
381
395
  /* crash recovery is best-effort at startup */
382
396
  }
383
397
  }
384
- this.autoRouter = new autoRouter_1.AutoRouter({ policyVersion: 'newmark-auto-v1' });
385
- this.loadLearnedRouteFeedback();
398
+ this.routeController = new autoRouter_1.RouteExecutionController({ policyVersion: autoRouter_1.ROUTE_POLICY_VERSION });
386
399
  if (this.isSubagentRuntime || this.agentOnly) {
387
400
  this.config.set('workspace', 'auto_create_timestamp_workspace', false);
388
401
  }
@@ -857,7 +870,6 @@ class Agent {
857
870
  }
858
871
  return;
859
872
  }
860
- const previousAuto = this.model === 'auto' ? this.resolvedDeployment : null;
861
873
  const qualified = parseDeploymentSelectionValue(requested);
862
874
  const legacyQualified = requested.includes('/')
863
875
  ? this.config.allModels().filter(model => `${model.provider_id}/${model.name}` === requested || `${model.provider}/${model.name}` === requested)
@@ -871,11 +883,8 @@ class Agent {
871
883
  this.pendingAutoAttempts = [];
872
884
  if (current?.provider_id)
873
885
  this.config.set('models', 'auto_switch_anchor_provider', current.provider_id);
874
- if (previousAuto && current) {
875
- const taskClass = this.lastRouteDecision?.taskClasses[0] || 'chat';
876
- this.recordRouteFeedbackFor(previousAuto, taskClass, -1, 'manual_switch');
877
- this.recordRouteFeedbackFor(this.deploymentRef(current), taskClass, 1, 'manual_switch');
878
- }
886
+ // A manual model switch is explicit user control. Auto Router v2 records
887
+ // no feedback for it: switching models must never train future Auto routes.
879
888
  this.lastRouteDecision = null;
880
889
  this.routeAttemptStartedAt = 0;
881
890
  this.routeStreamCommitted = false;
@@ -1052,7 +1061,7 @@ class Agent {
1052
1061
  }
1053
1062
  resetAutoRoute() {
1054
1063
  if (this.routeTransactionId)
1055
- this.autoRouter.endTransaction(this.routeTransactionId);
1064
+ this.routeController.endTransaction(this.routeTransactionId);
1056
1065
  this.resolvedDeployment = null;
1057
1066
  this.lastRouteDecision = null;
1058
1067
  this.pendingAutoAttempts = [];
@@ -1095,7 +1104,7 @@ class Agent {
1095
1104
  const deployment = this.activeDeployment();
1096
1105
  if (!deployment)
1097
1106
  return;
1098
- this.autoRouter.recordEndpointSuccess(deployment, latencyMs, throughput);
1107
+ this.routeController.recordEndpointSuccess(deployment, latencyMs, throughput);
1099
1108
  if (this.lastRouteDecision) {
1100
1109
  const attempt = [...this.lastRouteDecision.attempts].reverse().find(item => deploymentIdentity(item.deployment) === deploymentIdentity(deployment));
1101
1110
  if (attempt) {
@@ -1114,7 +1123,7 @@ class Agent {
1114
1123
  const deployment = this.activeDeployment();
1115
1124
  if (!deployment)
1116
1125
  return;
1117
- this.autoRouter.recordToolOutcome(deployment, valid);
1126
+ this.routeController.recordToolOutcome(deployment, valid);
1118
1127
  }
1119
1128
  updateProviders(value) {
1120
1129
  const before = this.config.providers();
@@ -1135,7 +1144,7 @@ class Agent {
1135
1144
  if (!provider)
1136
1145
  continue;
1137
1146
  for (const model of provider.models || []) {
1138
- this.autoRouter.resetEndpointAfterConfigChange({
1147
+ this.routeController.resetEndpointAfterConfigChange({
1139
1148
  providerId: provider.id,
1140
1149
  modelId: model.name,
1141
1150
  logicalModelGroupId: model.logical_model_group_id || undefined,
@@ -1146,76 +1155,59 @@ class Agent {
1146
1155
  if (changed)
1147
1156
  this.resetAutoRoute();
1148
1157
  }
1149
- clearLearnedModelPreferences() {
1150
- this.autoRouter.clearLearnedPreferences();
1151
- const target = path.join(this.rootPath, 'routing', 'feedback.jsonl');
1152
- try {
1153
- fs.rmSync(target, { force: true });
1154
- }
1155
- catch { }
1156
- }
1157
- rateActiveAutoRoute(score, expectedRouteId = '') {
1158
- const normalizedScore = Number(score);
1159
- if (normalizedScore !== -1 && normalizedScore !== 1) {
1160
- return { ok: false, reason: 'invalid_score' };
1161
- }
1162
- const decision = this.lastRouteDecision;
1163
- const deployment = this.activeDeployment();
1164
- if (this.model !== 'auto'
1165
- || decision?.requestedSelection.kind !== 'auto'
1166
- || !decision.routeId
1167
- || !decision.resolvedDeployment
1168
- || !deployment) {
1169
- return { ok: false, reason: 'no_active_auto_route' };
1170
- }
1171
- const requestedRouteId = String(expectedRouteId || '').trim();
1172
- if (requestedRouteId && requestedRouteId !== decision.routeId) {
1173
- return { ok: false, routeId: decision.routeId, reason: 'stale_route' };
1174
- }
1175
- const previousScore = this.explicitlyRatedRoutes.get(decision.routeId);
1176
- if (previousScore !== undefined) {
1177
- return { ok: false, score: previousScore, routeId: decision.routeId, reason: 'already_rated' };
1178
- }
1179
- const taskClasses = [...new Set(decision.taskClasses)];
1180
- for (const taskClass of taskClasses) {
1181
- this.recordRouteFeedbackFor(deployment, taskClass, normalizedScore, 'explicit_rating');
1182
- }
1183
- this.recordRouteQualityObservation(deployment, taskClasses, normalizedScore > 0);
1184
- this.explicitlyRatedRoutes.set(decision.routeId, normalizedScore);
1185
- while (this.explicitlyRatedRoutes.size > 1_000) {
1186
- const oldest = this.explicitlyRatedRoutes.keys().next().value;
1187
- if (!oldest)
1188
- break;
1189
- this.explicitlyRatedRoutes.delete(oldest);
1190
- }
1191
- return { ok: true, score: normalizedScore, routeId: decision.routeId };
1158
+ /**
1159
+ * Auto master switch. Every automatic model decision in this runtime is
1160
+ * gated on it: when it is off the Agent neither routes nor edits the current
1161
+ * model decision settings, and no code path may move the active model on its own.
1162
+ */
1163
+ autoDecisionEnabled() {
1164
+ return this.config.autoSwitchEnabled();
1192
1165
  }
1193
- recordObjectiveRouteResult(success) {
1194
- const deployment = this.activeDeployment();
1195
- if (!deployment || !this.lastRouteDecision)
1196
- return;
1197
- const taskClasses = [...new Set(this.lastRouteDecision.taskClasses)];
1198
- this.recordRouteQualityObservation(deployment, taskClasses, success);
1199
- for (const taskClass of taskClasses) {
1200
- if (success)
1201
- this.recordRouteFeedbackFor(deployment, taskClass, 1, 'objective_success');
1202
- }
1166
+ /** Current-model JEV settings exposed to GUI / TUI / CLI editors. */
1167
+ jevDecisionModelState() {
1168
+ const autoEnabled = this.autoDecisionEnabled();
1169
+ const timeout = Number(this.config.get('models', 'jev_timeout_ms'));
1170
+ return {
1171
+ autoEnabled,
1172
+ locked: !autoEnabled,
1173
+ ...(autoEnabled ? {} : { lockReason: 'auto_disabled' }),
1174
+ enabled: this.config.get('models', 'jev_enabled') !== false,
1175
+ decisionModel: 'current_eligible_model',
1176
+ thinkingMode: 'off',
1177
+ outputFormat: 'strict_json_schema',
1178
+ timeoutMs: Number.isFinite(timeout) && timeout > 0 ? Math.round(timeout) : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
1179
+ };
1203
1180
  }
1204
- recordRouteQualityObservation(deployment, taskClasses, success) {
1205
- const model = this.config.findDeployment(deployment);
1206
- if (!model)
1207
- return;
1208
- const quality = Object.assign({}, model.quality_by_task);
1209
- for (const taskClass of new Set(taskClasses)) {
1210
- const previous = quality[taskClass] || { successes: 0, attempts: 0 };
1211
- quality[taskClass] = {
1212
- attempts: Math.max(0, previous.attempts) + 1,
1213
- successes: Math.max(0, previous.successes) + (success ? 1 : 0),
1214
- };
1215
- }
1216
- if (!this.config.updateModelByDeployment(deployment.providerId, deployment.modelId, { quality_by_task: quality }))
1217
- return;
1181
+ /**
1182
+ * Edit current-model Auto decision settings.
1183
+ *
1184
+ * Refuses every write while Auto is off (`auto_disabled`): with automatic
1185
+ * decision-making disabled, not even the decision-model configuration may
1186
+ * change — that is what "Auto off never moves a model" means at the editor
1187
+ * boundary. Invalid values are rejected without touching the stored config.
1188
+ */
1189
+ updateJevDecisionModel(patch = {}) {
1190
+ if (!this.autoDecisionEnabled()) {
1191
+ return { ok: false, reason: 'auto_disabled', state: this.jevDecisionModelState() };
1192
+ }
1193
+ const current = this.jevDecisionModelState();
1194
+ const next = { ...current };
1195
+ const reject = () => ({ ok: false, reason: 'invalid_value', state: this.jevDecisionModelState() });
1196
+ const editableKeys = new Set(['enabled', 'timeoutMs']);
1197
+ if (Object.keys(patch).some(key => !editableKeys.has(key)))
1198
+ return reject();
1199
+ if (patch.enabled !== undefined)
1200
+ next.enabled = patch.enabled === true;
1201
+ if (patch.timeoutMs !== undefined) {
1202
+ const timeout = Number(patch.timeoutMs);
1203
+ if (!Number.isFinite(timeout) || timeout < 100 || timeout > 30_000)
1204
+ return reject();
1205
+ next.timeoutMs = Math.round(timeout);
1206
+ }
1207
+ this.config.set('models', 'jev_enabled', next.enabled);
1208
+ this.config.set('models', 'jev_timeout_ms', next.timeoutMs);
1218
1209
  this.config.save();
1210
+ return { ok: true, state: this.jevDecisionModelState() };
1219
1211
  }
1220
1212
  workspaceConversationKey() {
1221
1213
  const ws = this.workspace.current;
@@ -1228,6 +1220,158 @@ class Agent {
1228
1220
  return null;
1229
1221
  return path.join(ws.path, 'conversations', 'state.json');
1230
1222
  }
1223
+ /**
1224
+ * Heavy conversation payload that lives in a per-conversation shard file
1225
+ * instead of the workspace index document. Everything else (title, pinning,
1226
+ * order, draft, mode, goal, runtime owner, counters) stays in the index so
1227
+ * listing, sorting and cross-process merge never need to parse a transcript.
1228
+ */
1229
+ static CONVERSATION_SHARD_FIELDS = [
1230
+ 'chatMessages', 'history', 'workRuns', 'continuations',
1231
+ 'compressionCache', 'branches', 'tree', 'branchMailbox',
1232
+ ];
1233
+ workspaceConversationShardDir(ws = this.workspace.current) {
1234
+ const store = this.workspaceConversationStorePath(ws);
1235
+ if (!store)
1236
+ return null;
1237
+ return path.join(path.dirname(store), 'shards');
1238
+ }
1239
+ conversationShardFileName(key) {
1240
+ const safe = String(key).replace(/[^a-zA-Z0-9._-]+/g, '_').slice(0, 180);
1241
+ const digest = crypto.createHash('sha1').update(String(key)).digest('hex').slice(0, 12);
1242
+ return `${safe}.${digest}.json`;
1243
+ }
1244
+ conversationShardPath(key, ws = this.workspace.current) {
1245
+ const dir = this.workspaceConversationShardDir(ws);
1246
+ if (!dir)
1247
+ return null;
1248
+ return path.join(dir, this.conversationShardFileName(key));
1249
+ }
1250
+ /**
1251
+ * Hydrate one conversation entry from its shard on first access. The heavy
1252
+ * fields are non-enumerable so `{ ...entry }` (and therefore the index
1253
+ * document written back to disk) never pulls a transcript into memory, while
1254
+ * every existing reader that asks for `entry.chatMessages` / `entry.history`
1255
+ * keeps working unchanged.
1256
+ */
1257
+ attachConversationShard(entry, shardFile, key) {
1258
+ const cache = { loaded: false, value: null };
1259
+ const load = () => {
1260
+ if (cache.loaded)
1261
+ return cache.value;
1262
+ cache.loaded = true;
1263
+ this.hydratedShardEntries.add(entry);
1264
+ try {
1265
+ const raw = fs.readFileSync(shardFile, 'utf-8').replace(/^\uFEFF/, '');
1266
+ const parsed = JSON.parse(raw);
1267
+ cache.value = parsed && typeof parsed === 'object' ? parsed : null;
1268
+ }
1269
+ catch {
1270
+ cache.value = null;
1271
+ }
1272
+ return cache.value;
1273
+ };
1274
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
1275
+ Object.defineProperty(entry, field, {
1276
+ enumerable: false,
1277
+ configurable: true,
1278
+ get: () => {
1279
+ const shard = load();
1280
+ return shard ? shard[field] : undefined;
1281
+ },
1282
+ set: (value) => {
1283
+ this.hydratedShardEntries.add(entry);
1284
+ const shard = { ...(cache.value || {}) };
1285
+ shard[field] = value;
1286
+ cache.value = shard;
1287
+ cache.loaded = true;
1288
+ },
1289
+ });
1290
+ }
1291
+ void key;
1292
+ }
1293
+ /**
1294
+ * Heavy payload carried in memory by this entry, or `null` when the entry is
1295
+ * a shard-backed index record whose heavy fields were never touched. That
1296
+ * distinction is what keeps unrelated conversations off the write path.
1297
+ */
1298
+ conversationShardSnapshot(entry) {
1299
+ // Ownership rule: a *live* entry carries the heavy fields as own enumerable
1300
+ // properties; an index record read back from disk exposes them as
1301
+ // non-enumerable accessors that hydrate from the shard on demand. Only a
1302
+ // record that was actually hydrated (its accessors fired) counts as owning
1303
+ // its payload — keying on a `shard` marker instead would misclassify the
1304
+ // live entry, because it is built by spreading the previous index entry.
1305
+ const hydrated = this.hydratedShardEntries.has(entry);
1306
+ const snapshot = {};
1307
+ let any = false;
1308
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
1309
+ const descriptor = Object.getOwnPropertyDescriptor(entry, field);
1310
+ if (!descriptor)
1311
+ continue;
1312
+ if (!descriptor.enumerable && !hydrated)
1313
+ continue;
1314
+ const value = descriptor.get ? descriptor.get.call(entry) : descriptor.value;
1315
+ if (value === undefined)
1316
+ continue;
1317
+ snapshot[field] = value;
1318
+ any = true;
1319
+ }
1320
+ return any ? snapshot : null;
1321
+ }
1322
+ /**
1323
+ * Force one shard-backed entry into memory. Used only on the rare recovery
1324
+ * paths that must compare against a payload written by another runtime.
1325
+ */
1326
+ hydrateConversationEntry(entry) {
1327
+ if (typeof entry.shard !== 'string' || !entry.shard)
1328
+ return;
1329
+ this.hydratedShardEntries.add(entry);
1330
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
1331
+ const value = entry[field];
1332
+ if (value !== undefined)
1333
+ entry[field] = value;
1334
+ }
1335
+ }
1336
+ /**
1337
+ * Durable queue merge.
1338
+ *
1339
+ * Queue entries are the user's pending work, so a runtime that never saw an
1340
+ * entry must not delete it, while an entry this runtime actually consumed
1341
+ * must not be resurrected. Identity-based union minus exactly the consumed
1342
+ * ids gives both guarantees without needing a global revision clock.
1343
+ */
1344
+ mergeConversationContinuations(incoming, existingCount, existingShardBacked, existingEntry, incomingRevision, existingRevision) {
1345
+ // A writer that has seen the persisted revision owns the queue (including
1346
+ // deliberate clearing/consumption). Anything older keeps what is stored.
1347
+ if (incomingRevision >= existingRevision)
1348
+ return this.normalizeContinuations(incoming);
1349
+ if (existingShardBacked)
1350
+ this.hydrateConversationEntry(existingEntry);
1351
+ const existing = existingEntry.continuations || [];
1352
+ void existingCount;
1353
+ return this.normalizeContinuations(existing.length ? existing : incoming);
1354
+ }
1355
+ /** Index-document form of a conversation entry: metadata plus a shard pointer. */
1356
+ conversationIndexEntryForDisk(entry, shardFileName, shardBytes, counts) {
1357
+ const index = {};
1358
+ for (const [field, value] of Object.entries(entry))
1359
+ index[field] = value;
1360
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS)
1361
+ delete index[field];
1362
+ index.messageCount = counts.messageCount;
1363
+ index.historyCount = counts.historyCount;
1364
+ index.workRunCount = counts.workRunCount;
1365
+ index.hasTree = counts.hasTree;
1366
+ index.hasBranches = counts.hasBranches;
1367
+ index.continuationCount = counts.continuationCount;
1368
+ index.continuationRevision = counts.continuationRevision;
1369
+ if (shardFileName) {
1370
+ index.shard = shardFileName;
1371
+ index.shardBytes = shardBytes;
1372
+ }
1373
+ return index;
1374
+ }
1231
1375
  workspaceConversationStateKey(conversationId = this.activeConversationId) {
1232
1376
  return this.workspaceConversationStateKeyFor(conversationId, this.workspace.current);
1233
1377
  }
@@ -1537,6 +1681,26 @@ class Agent {
1537
1681
  tree,
1538
1682
  };
1539
1683
  }
1684
+ /**
1685
+ * Bound the live Build ledger.
1686
+ *
1687
+ * `normalizeWorkRuns()` already windows the persisted/served ledger to 120
1688
+ * runs, but the live `this.workRuns` array kept every Build block of the
1689
+ * conversation (measured: 307 runs / 4.6 MiB after 300 turns). The renderer
1690
+ * only ever shows the newest window, so older terminal runs can be dropped
1691
+ * from memory while the conversation continues.
1692
+ */
1693
+ pruneLiveWorkRuns() {
1694
+ const window = 120;
1695
+ if (this.workRuns.length <= window)
1696
+ return;
1697
+ const running = this.workRuns.filter(run => run.status === 'running');
1698
+ const keep = new Set(running.map(run => run.runId));
1699
+ const byRecency = [...this.workRuns].sort((a, b) => String(a.startedAt || '').localeCompare(String(b.startedAt || '')));
1700
+ for (const run of byRecency.slice(-window))
1701
+ keep.add(run.runId);
1702
+ this.workRuns = this.workRuns.filter(run => keep.has(run.runId));
1703
+ }
1540
1704
  normalizeWorkRuns(runs) {
1541
1705
  if (!Array.isArray(runs))
1542
1706
  return [];
@@ -1815,6 +1979,7 @@ class Agent {
1815
1979
  return true;
1816
1980
  }
1817
1981
  beginConversationWorkRun(runId, target = this.currentConversationTarget(), startedAt = this.nowIso(), managed = false, runtimeKey) {
1982
+ this.pruneLiveWorkRuns();
1818
1983
  const cleanRunId = String(runId || crypto.randomUUID()).trim().slice(0, 200);
1819
1984
  const existing = this.workRuns.find(run => run.runId === cleanRunId);
1820
1985
  if (existing) {
@@ -2499,8 +2664,11 @@ class Agent {
2499
2664
  }
2500
2665
  if (event.type === 'start')
2501
2666
  this.saveWorkspaceConversationState(false);
2667
+ // Terminal events are coalesced on purpose: `Agent.process()` performs the
2668
+ // synchronous save right after this sequence, so persisting here as well
2669
+ // only rewrote the whole conversation again for the same turn.
2502
2670
  if (event.type === 'done' || event.type === 'error')
2503
- this.saveWorkspaceConversationState(true);
2671
+ this.saveWorkspaceConversationState(false);
2504
2672
  if (activeRun && this.finalizingWorkRunId !== activeRun.runId
2505
2673
  && (event.type === 'done' || event.type === 'error') && activeRun.status !== 'running') {
2506
2674
  const terminalStatus = activeRun.status;
@@ -2617,8 +2785,10 @@ class Agent {
2617
2785
  const before = this.continuations.length;
2618
2786
  this.continuations = this.continuations.filter(item => item.queueMode !== 'followUp'
2619
2787
  || !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
2620
- if (this.continuations.length !== before)
2788
+ if (this.continuations.length !== before) {
2789
+ this.continuationRevision += 1;
2621
2790
  this.saveWorkspaceConversationState(true);
2791
+ }
2622
2792
  for (const subscriber of this.rootInboxRetiredSubscribers)
2623
2793
  subscriber(ids);
2624
2794
  return ids;
@@ -2705,6 +2875,7 @@ class Agent {
2705
2875
  createdAt: item.createdAt || new Date().toISOString(),
2706
2876
  }))]);
2707
2877
  this.continuations = combined;
2878
+ this.continuationRevision += 1;
2708
2879
  this.saveWorkspaceConversationState(true);
2709
2880
  return combined.map(item => ({
2710
2881
  ...item,
@@ -2719,6 +2890,7 @@ class Agent {
2719
2890
  if (index < 0)
2720
2891
  return false;
2721
2892
  this.continuations.splice(index, 1);
2893
+ this.continuationRevision += 1;
2722
2894
  this.saveWorkspaceConversationState(true);
2723
2895
  return true;
2724
2896
  }
@@ -2808,7 +2980,15 @@ class Agent {
2808
2980
  state.version = Math.max(1, Number(state.version || 1));
2809
2981
  state.conversations = state.conversations || {};
2810
2982
  let migrated = false;
2811
- for (const entry of Object.values(state.conversations)) {
2983
+ const shardDir = this.workspaceConversationShardDir(ws);
2984
+ for (const [key, entry] of Object.entries(state.conversations)) {
2985
+ if (entry.shard) {
2986
+ // Index record: attach lazy accessors instead of reading the shard.
2987
+ const shardFile = shardDir ? path.join(shardDir, String(entry.shard)) : '';
2988
+ if (shardFile)
2989
+ this.attachConversationShard(entry, shardFile, key);
2990
+ continue;
2991
+ }
2812
2992
  if (!entry.tree && entry.branches?.length) {
2813
2993
  this.normalizeConversationTree(entry);
2814
2994
  entry.branches = undefined;
@@ -2828,25 +3008,87 @@ class Agent {
2828
3008
  }
2829
3009
  return {};
2830
3010
  }
3011
+ /** Durable replacement-style text write (temp file + fsync + rename). */
3012
+ writeAtomicTextFile(file, content) {
3013
+ fs.mkdirSync(path.dirname(file), { recursive: true });
3014
+ const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
3015
+ try {
3016
+ const handle = fs.openSync(temp, 'w');
3017
+ try {
3018
+ fs.writeFileSync(handle, content, 'utf-8');
3019
+ fs.fsyncSync(handle);
3020
+ }
3021
+ finally {
3022
+ fs.closeSync(handle);
3023
+ }
3024
+ let lastError = null;
3025
+ for (let attempt = 0; attempt < 12; attempt += 1) {
3026
+ try {
3027
+ fs.renameSync(temp, file);
3028
+ return;
3029
+ }
3030
+ catch (error) {
3031
+ lastError = error;
3032
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code || '') : '';
3033
+ if (!['EPERM', 'EACCES', 'EBUSY'].includes(code))
3034
+ break;
3035
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10 * (attempt + 1));
3036
+ }
3037
+ }
3038
+ throw lastError;
3039
+ }
3040
+ finally {
3041
+ try {
3042
+ fs.unlinkSync(temp);
3043
+ }
3044
+ catch { }
3045
+ }
3046
+ }
2831
3047
  writeStoredConversationState(state, ws = this.workspace.current, deletedKeys = []) {
2832
3048
  this.writeStoredConversationStateNow(state, ws, deletedKeys);
2833
3049
  }
2834
- scheduleStoredConversationState(state, ws = this.workspace.current, delayMs = 80) {
3050
+ /**
3051
+ * Coalesced conversation-state write.
3052
+ *
3053
+ * Mid-turn persistence used to rewrite the whole workspace document
3054
+ * synchronously on every kernel event (measured: 5.33 full rewrites per turn
3055
+ * with no tools). The document grows with the conversation, so that cost grew
3056
+ * with the turn count. This path keeps durability at turn boundaries (the
3057
+ * terminal save stays synchronous and cancels any pending timer) while
3058
+ * throttling intermediate saves: the latest snapshot wins, and bursts inside
3059
+ * one throttle window collapse into a single write.
3060
+ */
3061
+ scheduleStoredConversationState(state, ws = this.workspace.current, minIntervalMs = 80) {
2835
3062
  const file = this.workspaceConversationStorePath(ws);
2836
3063
  if (!file)
2837
3064
  return;
2838
- this.conversationStateDirty.set(file, { state, ws });
2839
- const current = this.conversationStateFlushTimers.get(file);
2840
- if (current)
2841
- clearTimeout(current);
2842
- this.conversationStateFlushTimers.set(file, setTimeout(() => {
3065
+ this.conversationStateDirty.set(file, { state, ws, at: Date.now() });
3066
+ // A write is already queued: keep it and let it carry the newest snapshot
3067
+ // instead of pushing the deadline out (debounce) on every event.
3068
+ if (this.conversationStateFlushTimers.has(file))
3069
+ return;
3070
+ const elapsed = Date.now() - (this.conversationStateLastWriteAt.get(file) || 0);
3071
+ const wait = Math.max(0, minIntervalMs - elapsed);
3072
+ const timer = setTimeout(() => {
2843
3073
  this.conversationStateFlushTimers.delete(file);
2844
3074
  const pending = this.conversationStateDirty.get(file);
2845
3075
  if (!pending)
2846
3076
  return;
2847
3077
  this.conversationStateDirty.delete(file);
2848
- this.writeStoredConversationStateNow(pending.state, pending.ws);
2849
- }, Math.max(0, delayMs)));
3078
+ try {
3079
+ this.writeStoredConversationStateNow(pending.state, pending.ws);
3080
+ }
3081
+ catch (error) {
3082
+ // A deferred write must never take the process down: keep the snapshot
3083
+ // pending so the next save or the exit flush retries it.
3084
+ this.conversationStateDirty.set(file, pending);
3085
+ if (process.env.NEWMARK_PROVIDER_DIAGNOSTICS === '1')
3086
+ console.error(`[NewmarkState] deferred conversation-state write failed: ${String(error)}`);
3087
+ }
3088
+ }, wait);
3089
+ if (typeof timer.unref === 'function')
3090
+ timer.unref();
3091
+ this.conversationStateFlushTimers.set(file, timer);
2850
3092
  }
2851
3093
  flushWorkspaceConversationState() {
2852
3094
  const file = this.workspaceConversationStorePath();
@@ -2929,6 +3171,16 @@ class Agent {
2929
3171
  writeStoredConversationStateNow(state, ws = this.workspace.current, deletedKeys = []) {
2930
3172
  const file = this.workspaceConversationStorePath(ws);
2931
3173
  if (file) {
3174
+ // A queued coalesced snapshot may hold changes for *other* conversations
3175
+ // (for example provider usage recorded just before switching away). Fold
3176
+ // those in before cancelling it so a synchronous save can never drop them.
3177
+ const pending = this.conversationStateDirty.get(file);
3178
+ if (pending) {
3179
+ state = {
3180
+ ...state,
3181
+ conversations: { ...(pending.state.conversations || {}), ...(state.conversations || {}) },
3182
+ };
3183
+ }
2932
3184
  const pendingTimer = this.conversationStateFlushTimers.get(file);
2933
3185
  if (pendingTimer)
2934
3186
  clearTimeout(pendingTimer);
@@ -2950,29 +3202,139 @@ class Agent {
2950
3202
  const existingUpdatedAt = Date.parse(existing.updatedAt || '') || 0;
2951
3203
  const preferred = incomingUpdatedAt >= existingUpdatedAt ? { ...existing, ...incoming } : { ...incoming, ...existing };
2952
3204
  const branchPageReplacement = !!incoming.branchReset || !!incoming.tree || !!incoming.branches?.length;
2953
- const incomingTranscriptEmpty = !(incoming.chatMessages || []).length && !(incoming.history || []).length;
2954
- const existingTranscriptPresent = !!(existing.chatMessages || []).length || !!(existing.history || []).length;
2955
- if (incomingTranscriptEmpty && existingTranscriptPresent && !branchPageReplacement) {
2956
- preferred.chatMessages = existing.chatMessages;
2957
- preferred.history = existing.history;
3205
+ // Only the side that actually carries a payload may decide the heavy
3206
+ // fields. A shard-backed index entry that this process never touched
3207
+ // must not be hydrated just to merge metadata.
3208
+ const incomingEntry = incoming;
3209
+ const existingEntry = existing;
3210
+ const incomingShard = this.conversationShardSnapshot(incomingEntry);
3211
+ const count = (value) => (Array.isArray(value) ? value.length : 0);
3212
+ const existingMessages = Number(existing.messageCount) || count(existingEntry.chatMessages);
3213
+ const existingHistory = Number(existing.historyCount) || count(existingEntry.history);
3214
+ const incomingMessages = incomingShard ? count(incomingShard.chatMessages) : Number(incoming.messageCount) || 0;
3215
+ const incomingHistory = incomingShard ? count(incomingShard.history) : Number(incoming.historyCount) || 0;
3216
+ const incomingTranscriptEmpty = incomingMessages === 0 && incomingHistory === 0;
3217
+ // Legacy guard: an empty in-memory snapshot must never clobber a stored
3218
+ // transcript (mirrors, cold reloads and legacy callers can carry an
3219
+ // entry without its messages).
3220
+ const keepsExistingPayload = incomingTranscriptEmpty
3221
+ && (existingMessages > 0 || existingHistory > 0)
3222
+ && !branchPageReplacement;
3223
+ if (!incomingShard || keepsExistingPayload) {
3224
+ // Metadata-only update: keep the existing heavy payload and pointer.
3225
+ preferred.messageCount = existingMessages || Number(preferred.messageCount) || 0;
3226
+ preferred.historyCount = existingHistory || Number(preferred.historyCount) || 0;
3227
+ preferred.workRunCount = Number(existing.workRunCount) || Number(preferred.workRunCount) || 0;
3228
+ if (typeof existing.shard === 'string' && existing.shard) {
3229
+ preferred.shard = existing.shard;
3230
+ preferred.shardBytes = Number(existing.shardBytes) || Number(preferred.shardBytes) || 0;
3231
+ }
3232
+ // `preferred` is a shallow metadata copy: the lazy shard accessors are
3233
+ // non-enumerable and therefore absent from it. Re-attach them so the
3234
+ // cached state keeps exposing this conversation's transcript instead
3235
+ // of looking like an empty conversation to later readers.
3236
+ if (typeof preferred.shard === 'string' && preferred.shard) {
3237
+ const shardDir = this.workspaceConversationShardDir(ws);
3238
+ if (shardDir) {
3239
+ this.attachConversationShard(preferred, path.join(shardDir, String(preferred.shard)), key);
3240
+ }
3241
+ }
3242
+ // Even a metadata-only writer can carry a stale WorkRun ledger. Load
3243
+ // the existing shard, then reconcile by run id and event sequence;
3244
+ // the other heavy fields still come from that same persisted shard.
3245
+ // Explicit branch-page replacement is the only path that replaces
3246
+ // runs without merging. A completed snapshot wins over an older
3247
+ // running copy, while explicit resume emits a newer sequence.
3248
+ const incomingRuns = Array.isArray(incoming.workRuns) ? incoming.workRuns : null;
3249
+ if (branchPageReplacement) {
3250
+ preferred.workRuns = this.normalizeWorkRuns(incomingRuns || []);
3251
+ }
3252
+ else if (incomingRuns) {
3253
+ const existingShardBacked = typeof preferred.shard === 'string' && !!preferred.shard;
3254
+ let previousRuns = [];
3255
+ if (existingShardBacked) {
3256
+ const preferredEntry = preferred;
3257
+ this.hydrateConversationEntry(preferredEntry);
3258
+ previousRuns = preferred.workRuns || [];
3259
+ }
3260
+ else {
3261
+ previousRuns = existing.workRuns || [];
3262
+ }
3263
+ preferred.workRuns = this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
3264
+ }
3265
+ merged[key] = preferred;
3266
+ continue;
2958
3267
  }
3268
+ // This process owns a payload for this conversation. Which side wins is
3269
+ // decided per field exactly like the pre-shard merge did:
3270
+ // - `updatedAt` orders whole entries (a stale in-memory snapshot must
3271
+ // never revert a newer persisted transcript/queue);
3272
+ // - fields the caller does not produce at all (`tree`/`branches` come
3273
+ // from branch transactions, never from saveWorkspaceConversationState)
3274
+ // always survive from the stored payload;
3275
+ // - a branch page replacement still takes the incoming side verbatim.
3276
+ const preferIncoming = incomingUpdatedAt >= existingUpdatedAt;
3277
+ const existingShardBacked = Object.prototype.hasOwnProperty.call(existing, 'shard');
3278
+ const needsExistingPayload = existingShardBacked && (!preferIncoming
3279
+ || (!!existing.hasTree && incomingShard.tree === undefined)
3280
+ || (!!existing.hasBranches && incomingShard.branches === undefined));
3281
+ const priorPayload = needsExistingPayload
3282
+ ? (this.hydrateConversationEntry(existingEntry), existingEntry)
3283
+ : null;
3284
+ const winningHeavy = (field) => {
3285
+ const existingValue = priorPayload ? priorPayload[field] : undefined;
3286
+ const incomingValue = incomingShard[field];
3287
+ if (preferIncoming)
3288
+ return incomingValue !== undefined ? incomingValue : existingValue;
3289
+ return existingValue !== undefined ? existingValue : incomingValue;
3290
+ };
2959
3291
  const incomingPlanRevision = Math.max(0, Number(incoming.linkedPlan?.revision || 0));
2960
3292
  const existingPlanRevision = Math.max(0, Number(existing.linkedPlan?.revision || 0));
2961
3293
  preferred.linkedPlan = incomingPlanRevision >= existingPlanRevision ? incoming.linkedPlan : existing.linkedPlan;
2962
3294
  const incomingSequence = Math.max(0, Number(incoming.subagentState?.nextSequence || 0));
2963
3295
  const existingSequence = Math.max(0, Number(existing.subagentState?.nextSequence || 0));
2964
3296
  preferred.subagentState = incomingSequence >= existingSequence ? incoming.subagentState : existing.subagentState;
2965
- preferred.chatMessages = branchPageReplacement ? [...(incoming.chatMessages || [])] : preferred.chatMessages;
2966
- preferred.history = branchPageReplacement ? [...(incoming.history || [])] : preferred.history;
3297
+ preferred.chatMessages = branchPageReplacement
3298
+ ? [...(incomingShard.chatMessages || [])]
3299
+ : winningHeavy('chatMessages') || [];
3300
+ preferred.history = branchPageReplacement
3301
+ ? [...(incomingShard.history || [])]
3302
+ : winningHeavy('history') || [];
3303
+ // WorkRun ledgers can diverge across isolated runtimes while keeping
3304
+ // the same number of runs (for example, a stale `running` snapshot
3305
+ // after another runtime durably completed that run). Always reconcile
3306
+ // the persisted ledger with the incoming one; normalizeWorkRuns merges
3307
+ // duplicate run ids by event sequence and terminal rank. Explicit
3308
+ // resume emits a newer event sequence, so it still reopens the run.
3309
+ const incomingRuns = incomingShard.workRuns || [];
3310
+ const previousRuns = existingShardBacked && !branchPageReplacement
3311
+ ? (this.hydrateConversationEntry(existingEntry), existingEntry.workRuns || [])
3312
+ : [];
2967
3313
  preferred.workRuns = branchPageReplacement
2968
- ? this.normalizeWorkRuns(incoming.workRuns)
2969
- : this.normalizeWorkRuns([...(existing.workRuns || []), ...(incoming.workRuns || [])]);
2970
- preferred.continuations = branchPageReplacement ? this.normalizeContinuations(incoming.continuations) : preferred.continuations;
2971
- preferred.branches = branchPageReplacement ? [...(incoming.branches || [])] : preferred.branches;
3314
+ ? this.normalizeWorkRuns(incomingRuns)
3315
+ : this.normalizeWorkRuns([...previousRuns, ...incomingRuns]);
3316
+ preferred.continuations = branchPageReplacement
3317
+ ? this.normalizeContinuations(incomingShard.continuations || [])
3318
+ : this.mergeConversationContinuations(incomingShard.continuations || [], Number(existing.continuationCount) || 0, existingShardBacked, existingEntry, Number(incoming.continuationRevision) || 0, Number(existing.continuationRevision) || 0);
3319
+ if (branchPageReplacement)
3320
+ preferred.branches = [...(incomingShard.branches || [])];
3321
+ else if (winningHeavy('branches') !== undefined)
3322
+ preferred.branches = winningHeavy('branches');
2972
3323
  preferred.activeBranchId = branchPageReplacement ? String(incoming.activeBranchId || '') : preferred.activeBranchId;
2973
3324
  preferred.activeBranchGroupId = branchPageReplacement ? String(incoming.activeBranchGroupId || '') : preferred.activeBranchGroupId;
2974
- preferred.tree = branchPageReplacement ? incoming.tree : preferred.tree;
3325
+ preferred.tree = winningHeavy('tree') || preferred.tree;
2975
3326
  preferred.branchReset = branchPageReplacement;
3327
+ if (priorPayload) {
3328
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
3329
+ if (field === 'chatMessages' || field === 'history')
3330
+ continue;
3331
+ if (preferred[field] === undefined && priorPayload[field] !== undefined) {
3332
+ preferred[field] = priorPayload[field];
3333
+ }
3334
+ }
3335
+ }
3336
+ delete preferred.shard;
3337
+ delete preferred.shardBytes;
2976
3338
  merged[key] = preferred;
2977
3339
  }
2978
3340
  return {
@@ -2983,6 +3345,17 @@ class Agent {
2983
3345
  conversations: merged,
2984
3346
  };
2985
3347
  });
3348
+ // A deleted conversation must not leave its transcript shard behind.
3349
+ for (const key of deletedKeys) {
3350
+ const shardFile = this.conversationShardPath(String(key), ws);
3351
+ if (!shardFile)
3352
+ continue;
3353
+ try {
3354
+ fs.unlinkSync(shardFile);
3355
+ }
3356
+ catch { }
3357
+ this.conversationShardFingerprint.delete(shardFile);
3358
+ }
2986
3359
  stopTimer();
2987
3360
  }
2988
3361
  mutateStoredConversationState(ws, mutate, select) {
@@ -2992,9 +3365,15 @@ class Agent {
2992
3365
  fs.mkdirSync(path.dirname(file), { recursive: true });
2993
3366
  const lock = `${file}.lock`;
2994
3367
  let lockHandle = null;
2995
- for (let attempt = 0; attempt < 80; attempt += 1) {
3368
+ for (let attempt = 0; attempt < 400; attempt += 1) {
2996
3369
  try {
2997
3370
  lockHandle = fs.openSync(lock, 'wx');
3371
+ // Record the owner so a lock left behind by a killed runtime can be
3372
+ // reclaimed immediately instead of blocking saves for the age window.
3373
+ try {
3374
+ fs.writeFileSync(lockHandle, String(process.pid), 'utf-8');
3375
+ }
3376
+ catch { }
2998
3377
  break;
2999
3378
  }
3000
3379
  catch (error) {
@@ -3003,7 +3382,22 @@ class Agent {
3003
3382
  throw error;
3004
3383
  if (code === 'EEXIST') {
3005
3384
  try {
3006
- if (Date.now() - fs.statSync(lock).mtimeMs > 30000)
3385
+ const stat = fs.statSync(lock);
3386
+ const rawOwner = fs.readFileSync(lock, 'utf-8').trim();
3387
+ const ownerPid = Number(rawOwner);
3388
+ const knownOwner = rawOwner.length > 0 && Number.isFinite(ownerPid) && ownerPid > 0;
3389
+ const ownerAlive = knownOwner && (ownerPid === process.pid || (() => {
3390
+ try {
3391
+ process.kill(ownerPid, 0);
3392
+ return true;
3393
+ }
3394
+ catch {
3395
+ return false;
3396
+ }
3397
+ })());
3398
+ if (knownOwner && !ownerAlive)
3399
+ fs.unlinkSync(lock);
3400
+ else if (Date.now() - stat.mtimeMs > 30_000)
3007
3401
  fs.unlinkSync(lock);
3008
3402
  }
3009
3403
  catch { }
@@ -3028,8 +3422,49 @@ class Agent {
3028
3422
  contentState.version = 3;
3029
3423
  contentState.conversations = contentState.conversations || {};
3030
3424
  const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
3031
- const diskConversations = Object.fromEntries(Object.entries(contentState.conversations || {})
3032
- .map(([key, entry]) => [key, this.conversationEntryForDisk(entry)]));
3425
+ const shardDir = this.workspaceConversationShardDir(ws);
3426
+ const diskConversations = {};
3427
+ const pendingShards = [];
3428
+ for (const [key, rawEntry] of Object.entries(contentState.conversations || {})) {
3429
+ const entry = rawEntry;
3430
+ const snapshot = this.conversationShardSnapshot(entry);
3431
+ let shardFileName = typeof entry.shard === 'string' ? entry.shard : '';
3432
+ let shardBytes = Number(entry.shardBytes) || 0;
3433
+ let messageCount = Number(entry.messageCount) || 0;
3434
+ let historyCount = Number(entry.historyCount) || 0;
3435
+ let workRunCount = Number(entry.workRunCount) || 0;
3436
+ let hasTree = !!entry.hasTree;
3437
+ let hasBranches = !!entry.hasBranches;
3438
+ let continuationCount = Number(entry.continuationCount) || 0;
3439
+ let continuationRevision = Number(entry.continuationRevision) || 0;
3440
+ if (snapshot && shardDir) {
3441
+ const payload = this.conversationEntryForDisk(snapshot);
3442
+ messageCount = Array.isArray(payload.chatMessages) ? payload.chatMessages.length : 0;
3443
+ historyCount = Array.isArray(payload.history) ? payload.history.length : 0;
3444
+ workRunCount = Array.isArray(payload.workRuns) ? payload.workRuns.length : 0;
3445
+ hasTree = !!payload.tree;
3446
+ hasBranches = Array.isArray(payload.branches) && payload.branches.length > 0;
3447
+ continuationCount = Array.isArray(payload.continuations) ? payload.continuations.length : 0;
3448
+ continuationRevision = Number(entry.continuationRevision) || this.continuationRevision;
3449
+ // Shard content is the heavy payload only: no `updatedAt`, no metadata.
3450
+ // A save that only bumps metadata therefore produces a byte-identical
3451
+ // shard and is skipped by the fingerprint check below.
3452
+ const shardContent = JSON.stringify({ version: 1, key, ...payload });
3453
+ shardFileName = shardFileName || this.conversationShardFileName(key);
3454
+ const shardFile = path.join(shardDir, shardFileName);
3455
+ const shardFingerprint = crypto.createHash('sha1').update(shardContent).digest('hex');
3456
+ if (this.conversationShardFingerprint.get(shardFile) !== shardFingerprint) {
3457
+ pendingShards.push({ file: shardFile, content: shardContent });
3458
+ this.conversationShardFingerprint.set(shardFile, shardFingerprint);
3459
+ }
3460
+ shardBytes = Buffer.byteLength(shardContent, 'utf8');
3461
+ }
3462
+ diskConversations[key] = this.conversationIndexEntryForDisk(entry, shardFileName || null, shardBytes, {
3463
+ messageCount, historyCount, workRunCount, hasTree, hasBranches, continuationCount, continuationRevision,
3464
+ });
3465
+ }
3466
+ for (const shard of pendingShards)
3467
+ this.writeAtomicTextFile(shard.file, shard.content);
3033
3468
  const content = JSON.stringify({
3034
3469
  version: 3,
3035
3470
  activeConversationId: contentState.activeConversationId || this.activeConversationId || 'default',
@@ -3037,6 +3472,27 @@ class Agent {
3037
3472
  flowSuspensions: contentState.flowSuspensions || undefined,
3038
3473
  conversations: diskConversations,
3039
3474
  }, null, 2);
3475
+ // Content-fingerprint short circuit: a save whose serialized document is
3476
+ // byte-identical to what this process last wrote needs no temp file, no
3477
+ // fsync and no rename. Repeated saves inside one turn (Build start, kernel
3478
+ // events, work-run bookkeeping) usually collapse into this case.
3479
+ const contentFingerprint = crypto.createHash('sha1').update(content).digest('hex');
3480
+ const knownDiskFingerprint = this.conversationStateCacheFingerprint.get(file);
3481
+ let diskUntouched = false;
3482
+ if (knownDiskFingerprint) {
3483
+ try {
3484
+ const current = fs.statSync(file, { bigint: true });
3485
+ diskUntouched = knownDiskFingerprint === `${current.mtimeNs}:${current.ctimeNs}:${current.size}`;
3486
+ }
3487
+ catch {
3488
+ diskUntouched = false;
3489
+ }
3490
+ }
3491
+ if (diskUntouched && this.conversationStateWrittenFingerprint.get(file) === contentFingerprint) {
3492
+ this.conversationStateCache.set(file, contentState);
3493
+ this.conversationStateLastWriteAt.set(file, Date.now());
3494
+ return select ? select(contentState) : undefined;
3495
+ }
3040
3496
  try {
3041
3497
  const handle = fs.openSync(temp, 'w');
3042
3498
  try {
@@ -3051,6 +3507,8 @@ class Agent {
3051
3507
  try {
3052
3508
  fs.renameSync(temp, file);
3053
3509
  this.conversationStateCache.set(file, contentState);
3510
+ this.conversationStateWrittenFingerprint.set(file, contentFingerprint);
3511
+ this.conversationStateLastWriteAt.set(file, Date.now());
3054
3512
  const writtenStat = fs.statSync(file, { bigint: true });
3055
3513
  this.conversationStateCacheFingerprint.set(file, `${writtenStat.mtimeNs}:${writtenStat.ctimeNs}:${writtenStat.size}`);
3056
3514
  return select ? select(contentState) : undefined;
@@ -3154,11 +3612,17 @@ class Agent {
3154
3612
  if (prefix && !key.startsWith(prefix))
3155
3613
  continue;
3156
3614
  const id = key.slice(prefix.length + 1) || key;
3615
+ // Index-resident counts: listing conversations must never hydrate a
3616
+ // shard, otherwise every UI refresh would parse every transcript.
3617
+ const entry = value;
3618
+ const shardBacked = typeof entry.shard === 'string' && !!entry.shard;
3157
3619
  rows.push({
3158
3620
  id,
3159
3621
  key,
3160
- title: value.title || this.titleFromMessages(value.chatMessages || [], id),
3161
- messageCount: value.chatMessages?.length || 0,
3622
+ title: value.title || (shardBacked
3623
+ ? String(entry.title || id)
3624
+ : this.titleFromMessages(value.chatMessages || [], id)),
3625
+ messageCount: Number(value.messageCount) || value.chatMessages?.length || 0,
3162
3626
  historyCount: value.history?.length || 0,
3163
3627
  updatedAt: value.updatedAt || '',
3164
3628
  pinned: !!value.pinned,
@@ -3756,6 +4220,10 @@ class Agent {
3756
4220
  return false;
3757
4221
  existing.pinned = !!pinned;
3758
4222
  existing.pinnedAt = existing.pinned ? new Date().toISOString() : '';
4223
+ // The pin is a real change: advance `updatedAt` so a concurrent older
4224
+ // snapshot (which ties on the previous timestamp) cannot win the merge and
4225
+ // silently unpin the conversation.
4226
+ existing.updatedAt = new Date().toISOString();
3759
4227
  const siblingOrders = Object.entries(stored.conversations)
3760
4228
  .filter(([key, value]) => key !== stateKey && !!value.pinned === existing.pinned && Number.isFinite(value.order))
3761
4229
  .map(([, value]) => Number(value.order));
@@ -3782,6 +4250,7 @@ class Agent {
3782
4250
  return item;
3783
4251
  return byId.get(orderedIds[nextIndex++]);
3784
4252
  });
4253
+ this.continuationRevision += 1;
3785
4254
  this.saveWorkspaceConversationState(true);
3786
4255
  return this.conversationContinuations();
3787
4256
  }
@@ -4133,6 +4602,12 @@ class Agent {
4133
4602
  this.firstAgentResponseStarted = true;
4134
4603
  return true;
4135
4604
  }
4605
+ // This is a hard durability gate: it verifies the flag by reading the store
4606
+ // back, so the snapshot produced by the current turn must be on disk first.
4607
+ // Intermediate saves are coalesced, which can leave the entry unwritten for
4608
+ // up to one throttle window; flush it synchronously here instead of relying
4609
+ // on timing.
4610
+ this.saveWorkspaceConversationState(true);
4136
4611
  const marked = this.mutateStoredConversationState(workspace, latest => {
4137
4612
  const current = latest.conversations?.[stateKey];
4138
4613
  const firstUser = (current?.chatMessages || []).find(message => message.role === 'user');
@@ -4194,7 +4669,14 @@ class Agent {
4194
4669
  if (orderSlots.some(order => !Number.isFinite(order)))
4195
4670
  return latest;
4196
4671
  orderSlots.sort((a, b) => a - b);
4197
- normalized.forEach((id, index) => { entryById.get(id).order = orderSlots[index]; });
4672
+ const reorderedAt = new Date().toISOString();
4673
+ normalized.forEach((id, index) => {
4674
+ const entry = entryById.get(id);
4675
+ entry.order = orderSlots[index];
4676
+ // Same reason as pinning: keep a real change strictly newer than any
4677
+ // snapshot a coalesced save may still be holding.
4678
+ entry.updatedAt = reorderedAt;
4679
+ });
4198
4680
  accepted = true;
4199
4681
  return latest;
4200
4682
  });
@@ -4310,6 +4792,7 @@ class Agent {
4310
4792
  subagentState: this.subagents.serialize(),
4311
4793
  workRuns: this.normalizeWorkRuns(this.workRuns),
4312
4794
  continuations: this.normalizeContinuations(this.continuations),
4795
+ continuationRevision: this.continuationRevision,
4313
4796
  modelSelection: this.currentConversationModelSelection(),
4314
4797
  flowSelection: this.currentConversationFlowSelection(),
4315
4798
  inputMode: this.inputMode,
@@ -4356,6 +4839,7 @@ class Agent {
4356
4839
  runtimeOwnerId: runtimeOwner.runtimeOwnerId,
4357
4840
  runtimeOwnerPid: runtimeOwner.runtimeOwnerPid,
4358
4841
  runtimeLifecycleRole: runtimeOwner.runtimeLifecycleRole,
4842
+ continuationRevision: this.continuationRevision,
4359
4843
  updatedAt,
4360
4844
  };
4361
4845
  if (nextEntry.tree || nextEntry.branches?.length) {
@@ -4383,6 +4867,7 @@ class Agent {
4383
4867
  this.linkedPlan = { markdown: '', revision: 0 };
4384
4868
  this.workRuns = [];
4385
4869
  this.continuations = [];
4870
+ this.continuationRevision = 0;
4386
4871
  this.mode = 'build';
4387
4872
  this.goal = null;
4388
4873
  this.titleRequestMessageId = '';
@@ -4409,6 +4894,7 @@ class Agent {
4409
4894
  this.bindConversationSubagents(this.activeConversationId, saved.subagentState);
4410
4895
  this.workRuns = this.normalizeWorkRuns(saved.workRuns);
4411
4896
  this.continuations = this.normalizeContinuations(saved.continuations);
4897
+ this.continuationRevision = Number(saved.continuationRevision || 0);
4412
4898
  this.restoreConversationModelSelection(saved.modelSelection);
4413
4899
  this.restoreConversationFlowSelection(saved.flowSelection);
4414
4900
  this.inputMode = this.defaultInputMode();
@@ -4438,6 +4924,7 @@ class Agent {
4438
4924
  const recoveredWorkRuns = this.recoverPersistedWorkRuns(persisted?.workRuns, persisted?.updatedAt, this.runtimeLifecycle.unexpectedExit);
4439
4925
  this.workRuns = recoveredWorkRuns.runs;
4440
4926
  this.continuations = this.normalizeContinuations(persisted?.continuations);
4927
+ this.continuationRevision = Number(persisted?.continuationRevision || 0);
4441
4928
  this.restoreConversationModelSelection(persisted?.modelSelection);
4442
4929
  this.restoreConversationFlowSelection(persisted?.flowSelection);
4443
4930
  this.inputMode = this.defaultInputMode();
@@ -5795,6 +6282,7 @@ class Agent {
5795
6282
  this.subagents = source.subagents;
5796
6283
  this.workRuns = workRuns;
5797
6284
  this.continuations = continuations;
6285
+ this.continuationRevision += 1;
5798
6286
  this.mode = source.mode || this.mode;
5799
6287
  if (source.goal !== undefined)
5800
6288
  this.goal = this.restoreGoal(source.goal);
@@ -6637,7 +7125,9 @@ class Agent {
6637
7125
  : (memory?.history ?? persisted?.history ?? []);
6638
7126
  const messages = this.normalizeConversationChatMessages(sourceMessages, sourceHistory);
6639
7127
  const filename = this.writeSessionArchive(messages, this.modeName(), this.model);
6640
- const archiveEntry = persisted ? JSON.parse(JSON.stringify(persisted)) : {
7128
+ // Materialize the shard-backed entry before cloning: a plain JSON round-trip
7129
+ // of the index record would archive an empty transcript and no branch tree.
7130
+ const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
6641
7131
  title: this.titleFromMessages(messages, clean),
6642
7132
  chatMessages: messages,
6643
7133
  history: sourceHistory,
@@ -6649,6 +7139,9 @@ class Agent {
6649
7139
  continuations: memory?.continuations,
6650
7140
  updatedAt: new Date().toISOString(),
6651
7141
  };
7142
+ for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
7143
+ delete archiveEntry[key];
7144
+ }
6652
7145
  const manifest = {
6653
7146
  version: 2,
6654
7147
  kind: 'newmark-conversation-archive',
@@ -6715,6 +7208,24 @@ class Agent {
6715
7208
  const cachedStored = this.readStoredConversationState(ws);
6716
7209
  const stored = JSON.parse(JSON.stringify(cachedStored || {}));
6717
7210
  const persisted = stored.conversations?.[stateKey];
7211
+ // A shard-backed index record keeps `chatMessages` / `history` / `workRuns`
7212
+ // / `tree` behind non-enumerable accessors, and the JSON round-trip above
7213
+ // drops exactly those. Without this the archive of a conversation this
7214
+ // process does not hold in memory (the GUI archives while a utility runtime
7215
+ // owns the transcript) writes an empty payload -- and then deletes the real
7216
+ // transcript from the store. Read the live record's payload into the
7217
+ // detached copy instead.
7218
+ const cachedEntry = (cachedStored.conversations || {})[stateKey];
7219
+ if (persisted && cachedEntry) {
7220
+ for (const field of Agent.CONVERSATION_SHARD_FIELDS) {
7221
+ if (persisted[field] !== undefined)
7222
+ continue;
7223
+ const value = cachedEntry[field];
7224
+ if (value === undefined)
7225
+ continue;
7226
+ persisted[field] = JSON.parse(JSON.stringify(value));
7227
+ }
7228
+ }
6718
7229
  if (persisted)
6719
7230
  this.normalizeConversationTree(persisted);
6720
7231
  const memory = this.workspaceConversations.get(memoryKey);
@@ -6735,7 +7246,8 @@ class Agent {
6735
7246
  model: archiveModel,
6736
7247
  goal: archiveGoal,
6737
7248
  }, archiveDir);
6738
- const archiveEntry = persisted ? JSON.parse(JSON.stringify(persisted)) : {
7249
+ // Same materialization rule as the synchronous archive path.
7250
+ const archiveEntry = persisted ? JSON.parse(JSON.stringify(this.conversationEntryForDisk(persisted))) : {
6739
7251
  title: this.titleFromMessages(messages, clean),
6740
7252
  chatMessages: messages,
6741
7253
  history: sourceHistory,
@@ -6747,6 +7259,9 @@ class Agent {
6747
7259
  continuations: memory?.continuations,
6748
7260
  updatedAt: new Date().toISOString(),
6749
7261
  };
7262
+ for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
7263
+ delete archiveEntry[key];
7264
+ }
6750
7265
  const manifest = {
6751
7266
  version: 2,
6752
7267
  kind: 'newmark-conversation-archive',
@@ -6792,6 +7307,19 @@ class Agent {
6792
7307
  nextActiveId = latest.activeConversationId || remaining[0] || 'default';
6793
7308
  return latest;
6794
7309
  });
7310
+ // The index record is gone, so its transcript shard must go with it: an
7311
+ // orphaned shard is dead weight now and a stale transcript source if the
7312
+ // same conversation id is ever written again. The synchronous archive path
7313
+ // deletes shards through `writeStoredConversationState(...deletedKeys)`; the
7314
+ // async path mutates the locked state directly, so it unlinks here.
7315
+ const archivedShardFile = this.conversationShardPath(stateKey, ws);
7316
+ if (archivedShardFile) {
7317
+ try {
7318
+ fs.unlinkSync(archivedShardFile);
7319
+ }
7320
+ catch { /* already gone */ }
7321
+ this.conversationShardFingerprint.delete(archivedShardFile);
7322
+ }
6795
7323
  this.workspaceConversations.delete(memoryKey);
6796
7324
  const duplicateMemoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
6797
7325
  this.workspaceConversations.delete(duplicateMemoryKey);
@@ -7113,7 +7641,7 @@ class Agent {
7113
7641
  capabilities.add(capability);
7114
7642
  }
7115
7643
  }
7116
- const health = this.autoRouter.endpointMetrics(this.deploymentRef(model));
7644
+ const health = this.routeController.endpointMetrics(this.deploymentRef(model));
7117
7645
  const latencySeconds = Number(model.evaluation?.latency);
7118
7646
  const configuredPrivacy = (model.privacy || ['default']).filter(value => value === 'default' || value === 'no_training' || value === 'zdr');
7119
7647
  const supportedParameters = new Set((routeMetadata.supported_parameters || []).map(value => String(value).toLowerCase()));
@@ -7133,6 +7661,7 @@ class Agent {
7133
7661
  return {
7134
7662
  deployment: this.deploymentRef(model),
7135
7663
  enabled: model.enabled !== false,
7664
+ unavailableReason: this.isBalanceBlockedDeployment(this.deploymentRef(model)) ? 'balance_blocked' : undefined,
7136
7665
  validation: {
7137
7666
  level: validation.level,
7138
7667
  status: effectiveValidationStatus,
@@ -7154,7 +7683,7 @@ class Agent {
7154
7683
  reliability: health.attempts ? health.reliability : 0.5,
7155
7684
  toolValidity: health.toolAttempts ? health.toolValidity : undefined,
7156
7685
  throughput: health.throughput ?? (model.speed_rating === 'fast' ? 80 : model.speed_rating === 'medium' ? 40 : model.speed_rating === 'slow' ? 15 : undefined),
7157
- qualityByTask: model.quality_by_task,
7686
+ circuit: health.circuit,
7158
7687
  preference: Number.isFinite(Number(routeMetadata.route_preference)) ? Number(routeMetadata.route_preference) : undefined,
7159
7688
  fallbackOnly: !!model.fallback_only,
7160
7689
  };
@@ -7170,40 +7699,6 @@ class Agent {
7170
7699
  // Routing remains available when metrics-only audit storage is read-only.
7171
7700
  }
7172
7701
  }
7173
- loadLearnedRouteFeedback() {
7174
- try {
7175
- const file = path.join(this.rootPath, 'routing', 'feedback.jsonl');
7176
- if (!fs.existsSync(file))
7177
- return;
7178
- const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/).filter(Boolean).slice(-2000);
7179
- for (const line of lines) {
7180
- const value = JSON.parse(line);
7181
- const deployment = value.deployment;
7182
- if (!deployment?.providerId || !deployment.modelId)
7183
- continue;
7184
- if (!['chat', 'coding', 'reasoning', 'long_context', 'vision', 'image_generation', 'tool_use', 'computer_use'].includes(String(value.taskClass)))
7185
- continue;
7186
- if (!['manual_switch', 'explicit_rating', 'objective_success'].includes(String(value.source)))
7187
- continue;
7188
- if (!Number.isFinite(Number(value.score)) || !Number.isFinite(Number(value.at)))
7189
- continue;
7190
- this.autoRouter.recordFeedback(value);
7191
- }
7192
- }
7193
- catch {
7194
- // A malformed preference log is ignored instead of influencing routing.
7195
- }
7196
- }
7197
- recordRouteFeedbackFor(deployment, taskClass, score, source) {
7198
- const event = { deployment: { ...deployment }, taskClass, score, source, at: Date.now() };
7199
- this.autoRouter.recordFeedback(event);
7200
- try {
7201
- const directory = path.join(this.rootPath, 'routing');
7202
- fs.mkdirSync(directory, { recursive: true });
7203
- fs.appendFileSync(path.join(directory, 'feedback.jsonl'), `${JSON.stringify(event)}\n`, 'utf-8');
7204
- }
7205
- catch { }
7206
- }
7207
7702
  allModelNames() {
7208
7703
  const names = this.config.allModels().filter(m => m.enabled !== false).map(m => {
7209
7704
  const label = m.display || m.name;
@@ -7218,13 +7713,8 @@ class Agent {
7218
7713
  const before = this.resolvedDeployment ? deploymentIdentity(this.resolvedDeployment) : '';
7219
7714
  const mode = override?.mode || (0, autoRouter_1.normalizeAutoPreference)(this.config.autoSwitchPreference());
7220
7715
  const policy = (0, autoRouter_1.defaultRoutePolicy)(mode);
7221
- const configuredQualityLoss = this.config.get('models', 'auto_max_quality_loss');
7222
- if (typeof configuredQualityLoss === 'number' && Number.isFinite(configuredQualityLoss) && configuredQualityLoss >= 0) {
7223
- policy.maxQualityLoss = Math.min(1, configuredQualityLoss);
7224
- }
7225
- if (typeof override?.maxQualityLoss === 'number' && Number.isFinite(override.maxQualityLoss) && override.maxQualityLoss >= 0) {
7226
- policy.maxQualityLoss = Math.min(1, override.maxQualityLoss);
7227
- }
7716
+ // The tendency is guidance for the current-model JEV decision. Eligibility
7717
+ // and explicit cost ceilings remain hard constraints.
7228
7718
  const maxExpectedCost = this.config.get('models', 'auto_max_expected_cost_usd');
7229
7719
  if (typeof maxExpectedCost === 'number' && Number.isFinite(maxExpectedCost) && maxExpectedCost > 0)
7230
7720
  policy.maxExpectedCostUsd = maxExpectedCost;
@@ -7277,27 +7767,141 @@ class Agent {
7277
7767
  };
7278
7768
  const ownsTransaction = !this.routeTransactionId;
7279
7769
  const transactionId = this.routeTransactionId || `route-${crypto.randomUUID()}`;
7280
- const decision = this.autoRouter.route(selection, policy, this.autoRouteCandidates(), {
7770
+ const estimatedInputTokens = this.estimateContextTokens();
7771
+ const expectedOutputTokens = this.intelligence === 'ultra' || this.intelligence === 'max' ? 32768
7772
+ : this.intelligence === 'xhigh' ? 16384
7773
+ : this.intelligence === 'high' ? 8192
7774
+ : this.intelligence === 'low' ? 2048 : 4096;
7775
+ const routeRequest = {
7281
7776
  transactionId,
7282
- affinityKey: `${this.workspace.current?.path || this.rootPath}\u0000${this.activeConversationId || 'default'}`,
7283
7777
  taskText: task,
7284
- estimatedInputTokens: this.estimateContextTokens(),
7285
- expectedOutputTokens: this.intelligence === 'ultra' || this.intelligence === 'max' ? 32768
7286
- : this.intelligence === 'xhigh' ? 16384
7287
- : this.intelligence === 'high' ? 8192
7288
- : this.intelligence === 'low' ? 2048 : 4096,
7778
+ estimatedInputTokens,
7779
+ expectedOutputTokens,
7289
7780
  requiredCapabilities: [...requiredCapabilities],
7290
7781
  batch: override?.batch === true,
7782
+ };
7783
+ const decision = await this.decideAutoRoute({
7784
+ selection,
7785
+ policy,
7786
+ task,
7787
+ request: routeRequest,
7788
+ candidates: this.autoRouteCandidates(),
7291
7789
  });
7292
7790
  if (ownsTransaction)
7293
- this.autoRouter.endTransaction(transactionId);
7791
+ this.routeController.endTransaction(transactionId);
7792
+ else if (decision.resolvedDeployment)
7793
+ this.routeController.pinTransaction(transactionId, decision.resolvedDeployment);
7794
+ if (decision.resolvedDeployment)
7795
+ this.routeController.claimEndpointAttempt(decision.resolvedDeployment);
7294
7796
  this.lastRouteDecision = decision;
7295
- this.resolvedDeployment = decision.resolvedDeployment ? { ...decision.resolvedDeployment } : null;
7797
+ // Auto Router v2 freeze rule: a decision that produced no deployment must
7798
+ // not move the conversation to another model. Keeping the previous resolved
7799
+ // deployment is what makes "Auto off / decision model off never changes my
7800
+ // model" observable behaviour instead of a silent re-selection.
7801
+ if (decision.resolvedDeployment) {
7802
+ this.resolvedDeployment = { ...decision.resolvedDeployment };
7803
+ }
7804
+ else if (decision.finalStatus !== 'no_candidate') {
7805
+ this.resolvedDeployment = null;
7806
+ }
7296
7807
  this.pendingAutoAttempts = [];
7297
7808
  this.routeAttemptStartedAt = this.resolvedDeployment ? Date.now() : 0;
7298
7809
  this.persistRouteDecision(decision);
7299
7810
  return !!this.resolvedDeployment && before !== deploymentIdentity(this.resolvedDeployment);
7300
7811
  }
7812
+ /**
7813
+ * Auto Router v2 decision pipeline:
7814
+ *
7815
+ * RouteEligibilityGuard → JevDecisionEngine → RouteDecisionValidator → pin
7816
+ *
7817
+ * The current conversation model is the only Auto decision source. If it is
7818
+ * disabled, times out, crashes or returns something the guard cannot accept,
7819
+ * Auto stops without selecting another deployment.
7820
+ */
7821
+ async decideAutoRoute(input) {
7822
+ const { selection, policy, task, request, candidates } = input;
7823
+ const taskClasses = (0, autoRouter_1.classifyTaskClasses)(task, [...policy.requiredCapabilities, ...request.requiredCapabilities], request.estimatedInputTokens);
7824
+ const stopTimer = (0, performanceDiagnostics_1.performanceTimer)('jev_route_decision', { conversationId: this.activeConversationId || 'default' });
7825
+ try {
7826
+ const configuredTimeout = Number(this.config.get('models', 'jev_timeout_ms'));
7827
+ const jevUsageRequest = this.beginProviderUsageRequest();
7828
+ const jev = this.resolveCurrentModelJevEngine(jevUsageRequest);
7829
+ return await (0, routeDecisionValidator_1.resolveAutoRouteDecision)({
7830
+ selection,
7831
+ policy,
7832
+ taskText: task,
7833
+ request,
7834
+ candidates,
7835
+ taskClasses,
7836
+ intelligence: this.intelligence,
7837
+ engine: jev.engine || undefined,
7838
+ unavailableReason: jev.unavailableReason,
7839
+ timeoutMs: Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : jevDecision_1.DEFAULT_JEV_TIMEOUT_MS,
7840
+ previousDeployment: this.resolvedDeployment,
7841
+ pinnedDeployment: this.routeTransactionId ? this.routeController.transactionPin(request.transactionId) : undefined,
7842
+ policyVersion: this.routeController.version(),
7843
+ });
7844
+ }
7845
+ finally {
7846
+ stopTimer();
7847
+ }
7848
+ }
7849
+ /** Resolve JEV to the current eligible conversation model, never a second model slot. */
7850
+ resolveCurrentModelJevEngine(usageRequest) {
7851
+ const jevEnabled = this.config.get('models', 'jev_enabled') !== false;
7852
+ const decisionEnabled = this.config.autoSwitchEnabled() && jevEnabled;
7853
+ const engine = !decisionEnabled
7854
+ ? null
7855
+ : new jevDecision_1.CurrentModelJevDecisionEngine(request => {
7856
+ const eligible = request.candidates;
7857
+ if (!eligible.length)
7858
+ return null;
7859
+ const eligibleByIdentity = new Map(eligible.map(candidate => [
7860
+ `${candidate.deployment.providerId}\u0000${candidate.deployment.modelId}`,
7861
+ candidate,
7862
+ ]));
7863
+ const availableModels = eligible.flatMap(candidate => {
7864
+ const model = this.config.findDeployment(candidate.deployment);
7865
+ return model?.provider_url && model.api_key ? [{ candidate, model }] : [];
7866
+ });
7867
+ const preferredDeployment = request.previous?.deployment || this.activeDeployment();
7868
+ const defaultSelection = this.config.getStr('models', 'default_model');
7869
+ const defaultRef = parseDeploymentSelectionValue(defaultSelection);
7870
+ const defaultModel = defaultRef
7871
+ ? this.config.findDeployment(defaultRef)
7872
+ : this.config.findModel(defaultSelection);
7873
+ const defaultDeployment = defaultModel ? this.deploymentRef(defaultModel) : null;
7874
+ const preferred = preferredDeployment
7875
+ ? eligibleByIdentity.get(`${preferredDeployment.providerId}\u0000${preferredDeployment.modelId}`)
7876
+ : undefined;
7877
+ const configuredDefault = defaultDeployment
7878
+ ? eligibleByIdentity.get(`${defaultDeployment.providerId}\u0000${defaultDeployment.modelId}`)
7879
+ : undefined;
7880
+ const selected = availableModels.find(entry => preferred
7881
+ && entry.candidate.deployment.providerId === preferred.deployment.providerId
7882
+ && entry.candidate.deployment.modelId === preferred.deployment.modelId)
7883
+ || availableModels.find(entry => configuredDefault
7884
+ && entry.candidate.deployment.providerId === configuredDefault.deployment.providerId
7885
+ && entry.candidate.deployment.modelId === configuredDefault.deployment.modelId)
7886
+ || availableModels[0];
7887
+ if (!selected)
7888
+ return null;
7889
+ const modelIdentity = `${selected.model.provider_id}/${selected.model.name}`;
7890
+ return {
7891
+ provider: this.modelProvider(selected.model, this.jevProviderCache),
7892
+ model: selected.model.name,
7893
+ modelVersion: `${jevDecision_1.CURRENT_MODEL_JEV_VERSION}:${modelIdentity}`,
7894
+ thinkingCapable: selected.model.thinking === true,
7895
+ onUsage: usage => this.recordProviderUsage(usage, usageRequest),
7896
+ };
7897
+ });
7898
+ return {
7899
+ engine,
7900
+ unavailableReason: !this.config.autoSwitchEnabled()
7901
+ ? 'auto_disabled'
7902
+ : 'disabled',
7903
+ };
7904
+ }
7301
7905
  shouldExposeToolInterface() {
7302
7906
  return true;
7303
7907
  }
@@ -7333,7 +7937,7 @@ class Agent {
7333
7937
  return null;
7334
7938
  }
7335
7939
  if (observedDeployment)
7336
- this.autoRouter.recordEndpointFailure(observedDeployment, observedFailure);
7940
+ this.routeController.recordEndpointFailure(observedDeployment, observedFailure);
7337
7941
  if (this.model === 'auto') {
7338
7942
  const current = this.activeDeployment();
7339
7943
  if (!current || !this.lastRouteDecision)
@@ -7351,7 +7955,7 @@ class Agent {
7351
7955
  currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
7352
7956
  }
7353
7957
  if (!this.pendingAutoAttempts.length) {
7354
- this.pendingAutoAttempts = this.autoRouter.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
7958
+ this.pendingAutoAttempts = this.routeController.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
7355
7959
  error: failure,
7356
7960
  streamCommitted: this.routeStreamCommitted,
7357
7961
  sideEffectCommitted: this.routeSideEffectCommitted,
@@ -7371,7 +7975,7 @@ class Agent {
7371
7975
  this.lastRouteDecision.attempts.push({ ...next });
7372
7976
  this.lastRouteDecision.resolvedDeployment = { ...next.deployment };
7373
7977
  this.lastRouteDecision.finalStatus = 'retrying';
7374
- this.autoRouter.claimEndpointAttempt(next.deployment);
7978
+ this.routeController.claimEndpointAttempt(next.deployment);
7375
7979
  this.routeAttemptStartedAt = Date.now();
7376
7980
  this.persistRouteDecision(this.lastRouteDecision);
7377
7981
  return current.modelId;
@@ -7389,8 +7993,9 @@ class Agent {
7389
7993
  const usable = all.filter(m => !modelConfigIsUnavailable(m) && !this.failedDeploymentsThisRun.has(deploymentIdentity(this.deploymentRef(m))));
7390
7994
  if (!usable.length)
7391
7995
  return null;
7392
- const pref = this.config.autoSwitchPreference();
7393
- const ranked = [...usable].sort((a, b) => this.modelScore(b, pref, false, false) - this.modelScore(a, pref, false, false));
7996
+ // Operational recovery is independent of the user-facing Auto tendency;
7997
+ // that tendency is supplied only to the current-model JEV decision.
7998
+ const ranked = [...usable].sort((a, b) => this.modelScore(b, 'balanced', false, false) - this.modelScore(a, 'balanced', false, false));
7394
7999
  const next = ranked[0];
7395
8000
  if (!next?.name)
7396
8001
  return null;
@@ -8322,8 +8927,14 @@ class Agent {
8322
8927
  stopTotalTimer();
8323
8928
  this.processDepth = Math.max(0, this.processDepth - 1);
8324
8929
  if (this.processDepth === 0) {
8930
+ // Kernel events coalesce intermediate writes to avoid rewriting the
8931
+ // workspace document on every token. A completed Agent turn is a
8932
+ // durability boundary across GUI, TUI, and one-shot CLI runtimes; do
8933
+ // the terminal write here so callers can safely read or exit as soon
8934
+ // as process() resolves.
8935
+ this.flushConversationState();
8325
8936
  if (this.routeTransactionId)
8326
- this.autoRouter.endTransaction(this.routeTransactionId);
8937
+ this.routeController.endTransaction(this.routeTransactionId);
8327
8938
  this.routeTransactionId = '';
8328
8939
  this.processingConversationId = null;
8329
8940
  this.activeProcessAbortController = null;