newmark-agent 0.6.3 → 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 (72) 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 +3046 -1038
  6. package/dist/conversation-utility-host.js +94 -7
  7. package/dist/core/agent.d.ts +149 -12
  8. package/dist/core/agent.js +843 -176
  9. package/dist/core/agentKernelRunner.d.ts +7 -1
  10. package/dist/core/agentKernelRunner.js +171 -22
  11. package/dist/core/autoRouter.d.ts +49 -44
  12. package/dist/core/autoRouter.js +117 -302
  13. package/dist/core/branchIdentity.d.ts +29 -0
  14. package/dist/core/branchIdentity.js +54 -0
  15. package/dist/core/config.js +27 -11
  16. package/dist/core/continuation/contracts.d.ts +1 -1
  17. package/dist/core/continuation/store.d.ts +119 -1
  18. package/dist/core/continuation/store.js +337 -19
  19. package/dist/core/conversationKernel.d.ts +62 -8
  20. package/dist/core/conversationKernel.js +361 -47
  21. package/dist/core/conversationStateDocument.d.ts +18 -0
  22. package/dist/core/conversationStateDocument.js +88 -0
  23. package/dist/core/conversationVisualBudget.d.ts +74 -0
  24. package/dist/core/conversationVisualBudget.js +153 -0
  25. package/dist/core/electronUtilityAgentClient.d.ts +3 -2
  26. package/dist/core/electronUtilityAgentClient.js +32 -8
  27. package/dist/core/electronUtilityRuntimePool.d.ts +28 -3
  28. package/dist/core/electronUtilityRuntimePool.js +93 -24
  29. package/dist/core/hostRuntimeHooks.d.ts +23 -0
  30. package/dist/core/hostRuntimeHooks.js +17 -0
  31. package/dist/core/jevDecision.d.ts +144 -0
  32. package/dist/core/jevDecision.js +226 -0
  33. package/dist/core/memoryProbe.d.ts +24 -0
  34. package/dist/core/memoryProbe.js +104 -0
  35. package/dist/core/mobilePairing.d.ts +7 -1
  36. package/dist/core/mobilePairing.js +9 -1
  37. package/dist/core/performanceDiagnostics.d.ts +1 -1
  38. package/dist/core/routeDecisionValidator.d.ts +86 -0
  39. package/dist/core/routeDecisionValidator.js +249 -0
  40. package/dist/core/routeEligibility.d.ts +52 -0
  41. package/dist/core/routeEligibility.js +108 -0
  42. package/dist/core/runtimeMemoryBudget.d.ts +6 -0
  43. package/dist/core/runtimeMemoryBudget.js +12 -0
  44. package/dist/core/utilityAgentProtocol.d.ts +8 -10
  45. package/dist/core/utilityHostToolRouter.js +2 -0
  46. package/dist/core/visualDownscale.d.ts +6 -0
  47. package/dist/core/visualDownscale.js +137 -0
  48. package/dist/core/wslAgentClient.d.ts +1 -2
  49. package/dist/core/wslAgentClient.js +0 -8
  50. package/dist/core/wslAgentProtocol.d.ts +1 -10
  51. package/dist/core/wslAgentRuntimePool.d.ts +1 -3
  52. package/dist/core/wslAgentRuntimePool.js +17 -22
  53. package/dist/llm/provider.d.ts +3 -1
  54. package/dist/llm/provider.js +53 -9
  55. package/dist/main.js +108 -31
  56. package/dist/preload.js +3 -1
  57. package/dist/server.js +30 -18
  58. package/dist/tools/computerUse.d.ts +4 -0
  59. package/dist/tools/computerUse.js +202 -4
  60. package/dist/tools/computerUsePowerShellHost.d.ts +6 -1
  61. package/dist/tools/computerUsePowerShellHost.js +105 -31
  62. package/dist/tools/index.js +4 -1
  63. package/dist/tui/src/adapters/core-runtime-adapter.js +13 -1
  64. package/dist/tui/src/i18n.js +12 -1
  65. package/dist/tui/src/render.js +9 -2
  66. package/dist/tui/src/settings-schema.js +19 -0
  67. package/dist/tui/src/state.js +69 -1
  68. package/dist/ui/index.html +994 -277
  69. package/dist/ui/lucide-sprite.svg +0 -8
  70. package/dist/wsl-agent-host.bundle.cjs +2851 -1013
  71. package/dist/wsl-agent-host.js +0 -3
  72. package/package.json +38 -9
@@ -58,6 +58,7 @@ const skills_1 = require("./skills");
58
58
  const flow_1 = require("./flow");
59
59
  const memoryLab_1 = require("./memoryLab");
60
60
  const agentKernelRunner_1 = require("./agentKernelRunner");
61
+ const branchIdentity_1 = require("./branchIdentity");
61
62
  const toolPolicy_1 = require("./toolPolicy");
62
63
  const conversationTarget_1 = require("./conversationTarget");
63
64
  const utilityHostToolBridge_1 = require("./utilityHostToolBridge");
@@ -67,6 +68,8 @@ const pngjs_1 = require("pngjs");
67
68
  const modelValidation_1 = require("./modelValidation");
68
69
  const modelValidationStore_1 = require("./modelValidationStore");
69
70
  const autoRouter_1 = require("./autoRouter");
71
+ const jevDecision_1 = require("./jevDecision");
72
+ const routeDecisionValidator_1 = require("./routeDecisionValidator");
70
73
  const agent_context_manager_1 = require("../context/services/agent-context-manager");
71
74
  const toolchain_1 = require("../toolchain");
72
75
  const agent_runtime_1 = require("../agent-runtime");
@@ -177,7 +180,7 @@ let CORE_SYSTEM_PROMPT = `You are Newmark Agent, a powerful AI coding assistant
177
180
  - Be thorough and precise. Verify your work.
178
181
  - Use tools appropriately - don't just describe, do it.
179
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.
180
- - 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.
181
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.
182
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.
183
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.
@@ -274,6 +277,8 @@ class Agent {
274
277
  // Bounded caller-owned peer slots retain protocol compatibility learning
275
278
  // between mailbox jobs. They never retain the child Agent or go to disk.
276
279
  peerProviderCaches = new Map();
280
+ /** Reuses the provider connection for current-model JEV calls without sharing conversation build state. */
281
+ jevProviderCache = {};
277
282
  awaitingAgentKernelRuntime = false;
278
283
  pendingAgentKernelQueue = [];
279
284
  linkedPlanAccess;
@@ -293,7 +298,7 @@ class Agent {
293
298
  finalizingWorkRunId = '';
294
299
  agentRunService = null;
295
300
  agentRunByWorkRunId = new Map();
296
- autoRouter;
301
+ routeController;
297
302
  resolvedDeployment = null;
298
303
  fixedDeployment = null;
299
304
  preferredConversationModelSelection = null;
@@ -305,11 +310,21 @@ class Agent {
305
310
  lastRouteRetryDelayMs = 0;
306
311
  routeAttemptStartedAt = 0;
307
312
  providerBalanceBlockedUntilByDeployment = new Map();
308
- explicitlyRatedRoutes = new Map();
309
313
  conversationStateCache = new Map();
310
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;
311
325
  conversationStateFlushTimers = new Map();
312
326
  conversationStateDirty = new Map();
327
+ conversationStateLastWriteAt = new Map();
313
328
  systemPromptCache = null;
314
329
  toolDefinitionCache = new Map();
315
330
  modelValidationPromise = null;
@@ -380,8 +395,7 @@ class Agent {
380
395
  /* crash recovery is best-effort at startup */
381
396
  }
382
397
  }
383
- this.autoRouter = new autoRouter_1.AutoRouter({ policyVersion: 'newmark-auto-v1' });
384
- this.loadLearnedRouteFeedback();
398
+ this.routeController = new autoRouter_1.RouteExecutionController({ policyVersion: autoRouter_1.ROUTE_POLICY_VERSION });
385
399
  if (this.isSubagentRuntime || this.agentOnly) {
386
400
  this.config.set('workspace', 'auto_create_timestamp_workspace', false);
387
401
  }
@@ -856,7 +870,6 @@ class Agent {
856
870
  }
857
871
  return;
858
872
  }
859
- const previousAuto = this.model === 'auto' ? this.resolvedDeployment : null;
860
873
  const qualified = parseDeploymentSelectionValue(requested);
861
874
  const legacyQualified = requested.includes('/')
862
875
  ? this.config.allModels().filter(model => `${model.provider_id}/${model.name}` === requested || `${model.provider}/${model.name}` === requested)
@@ -870,11 +883,8 @@ class Agent {
870
883
  this.pendingAutoAttempts = [];
871
884
  if (current?.provider_id)
872
885
  this.config.set('models', 'auto_switch_anchor_provider', current.provider_id);
873
- if (previousAuto && current) {
874
- const taskClass = this.lastRouteDecision?.taskClasses[0] || 'chat';
875
- this.recordRouteFeedbackFor(previousAuto, taskClass, -1, 'manual_switch');
876
- this.recordRouteFeedbackFor(this.deploymentRef(current), taskClass, 1, 'manual_switch');
877
- }
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.
878
888
  this.lastRouteDecision = null;
879
889
  this.routeAttemptStartedAt = 0;
880
890
  this.routeStreamCommitted = false;
@@ -1020,6 +1030,22 @@ class Agent {
1020
1030
  currentWorkRunId() {
1021
1031
  return this.activeWorkRunId || this.finalizingWorkRunId || '';
1022
1032
  }
1033
+ /**
1034
+ * The branch node that owns the currently active Build. Provider session ids
1035
+ * and branch-scoped caches must use this identity instead of "the branch that
1036
+ * happens to be active", otherwise a concurrently running sibling branch
1037
+ * (branch-communication mode) or a page switched mid-Build would read another
1038
+ * branch's remote session and folded history.
1039
+ */
1040
+ activeWorkRunBranchId() {
1041
+ const runId = this.currentWorkRunId();
1042
+ const run = runId ? this.workRuns.find(item => String(item.runId || '') === runId) : undefined;
1043
+ return String(run?.branchNodeId || '') || this.currentBranchNodeId();
1044
+ }
1045
+ /** Full conversation identity of one branch node (shared by every branch kind). */
1046
+ branchConversationIdentity(branchNodeId) {
1047
+ return (0, branchIdentity_1.branchConversationIdentity)(this.activeConversationId || 'default', branchNodeId);
1048
+ }
1023
1049
  activeModelConfig() {
1024
1050
  const deployment = this.activeDeployment();
1025
1051
  return deployment ? this.config.findDeployment(deployment) : this.config.findModel(this.model);
@@ -1035,7 +1061,7 @@ class Agent {
1035
1061
  }
1036
1062
  resetAutoRoute() {
1037
1063
  if (this.routeTransactionId)
1038
- this.autoRouter.endTransaction(this.routeTransactionId);
1064
+ this.routeController.endTransaction(this.routeTransactionId);
1039
1065
  this.resolvedDeployment = null;
1040
1066
  this.lastRouteDecision = null;
1041
1067
  this.pendingAutoAttempts = [];
@@ -1078,7 +1104,7 @@ class Agent {
1078
1104
  const deployment = this.activeDeployment();
1079
1105
  if (!deployment)
1080
1106
  return;
1081
- this.autoRouter.recordEndpointSuccess(deployment, latencyMs, throughput);
1107
+ this.routeController.recordEndpointSuccess(deployment, latencyMs, throughput);
1082
1108
  if (this.lastRouteDecision) {
1083
1109
  const attempt = [...this.lastRouteDecision.attempts].reverse().find(item => deploymentIdentity(item.deployment) === deploymentIdentity(deployment));
1084
1110
  if (attempt) {
@@ -1097,7 +1123,7 @@ class Agent {
1097
1123
  const deployment = this.activeDeployment();
1098
1124
  if (!deployment)
1099
1125
  return;
1100
- this.autoRouter.recordToolOutcome(deployment, valid);
1126
+ this.routeController.recordToolOutcome(deployment, valid);
1101
1127
  }
1102
1128
  updateProviders(value) {
1103
1129
  const before = this.config.providers();
@@ -1118,7 +1144,7 @@ class Agent {
1118
1144
  if (!provider)
1119
1145
  continue;
1120
1146
  for (const model of provider.models || []) {
1121
- this.autoRouter.resetEndpointAfterConfigChange({
1147
+ this.routeController.resetEndpointAfterConfigChange({
1122
1148
  providerId: provider.id,
1123
1149
  modelId: model.name,
1124
1150
  logicalModelGroupId: model.logical_model_group_id || undefined,
@@ -1129,76 +1155,59 @@ class Agent {
1129
1155
  if (changed)
1130
1156
  this.resetAutoRoute();
1131
1157
  }
1132
- clearLearnedModelPreferences() {
1133
- this.autoRouter.clearLearnedPreferences();
1134
- const target = path.join(this.rootPath, 'routing', 'feedback.jsonl');
1135
- try {
1136
- fs.rmSync(target, { force: true });
1137
- }
1138
- catch { }
1139
- }
1140
- rateActiveAutoRoute(score, expectedRouteId = '') {
1141
- const normalizedScore = Number(score);
1142
- if (normalizedScore !== -1 && normalizedScore !== 1) {
1143
- return { ok: false, reason: 'invalid_score' };
1144
- }
1145
- const decision = this.lastRouteDecision;
1146
- const deployment = this.activeDeployment();
1147
- if (this.model !== 'auto'
1148
- || decision?.requestedSelection.kind !== 'auto'
1149
- || !decision.routeId
1150
- || !decision.resolvedDeployment
1151
- || !deployment) {
1152
- return { ok: false, reason: 'no_active_auto_route' };
1153
- }
1154
- const requestedRouteId = String(expectedRouteId || '').trim();
1155
- if (requestedRouteId && requestedRouteId !== decision.routeId) {
1156
- return { ok: false, routeId: decision.routeId, reason: 'stale_route' };
1157
- }
1158
- const previousScore = this.explicitlyRatedRoutes.get(decision.routeId);
1159
- if (previousScore !== undefined) {
1160
- return { ok: false, score: previousScore, routeId: decision.routeId, reason: 'already_rated' };
1161
- }
1162
- const taskClasses = [...new Set(decision.taskClasses)];
1163
- for (const taskClass of taskClasses) {
1164
- this.recordRouteFeedbackFor(deployment, taskClass, normalizedScore, 'explicit_rating');
1165
- }
1166
- this.recordRouteQualityObservation(deployment, taskClasses, normalizedScore > 0);
1167
- this.explicitlyRatedRoutes.set(decision.routeId, normalizedScore);
1168
- while (this.explicitlyRatedRoutes.size > 1_000) {
1169
- const oldest = this.explicitlyRatedRoutes.keys().next().value;
1170
- if (!oldest)
1171
- break;
1172
- this.explicitlyRatedRoutes.delete(oldest);
1173
- }
1174
- 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();
1175
1165
  }
1176
- recordObjectiveRouteResult(success) {
1177
- const deployment = this.activeDeployment();
1178
- if (!deployment || !this.lastRouteDecision)
1179
- return;
1180
- const taskClasses = [...new Set(this.lastRouteDecision.taskClasses)];
1181
- this.recordRouteQualityObservation(deployment, taskClasses, success);
1182
- for (const taskClass of taskClasses) {
1183
- if (success)
1184
- this.recordRouteFeedbackFor(deployment, taskClass, 1, 'objective_success');
1185
- }
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
+ };
1186
1180
  }
1187
- recordRouteQualityObservation(deployment, taskClasses, success) {
1188
- const model = this.config.findDeployment(deployment);
1189
- if (!model)
1190
- return;
1191
- const quality = Object.assign({}, model.quality_by_task);
1192
- for (const taskClass of new Set(taskClasses)) {
1193
- const previous = quality[taskClass] || { successes: 0, attempts: 0 };
1194
- quality[taskClass] = {
1195
- attempts: Math.max(0, previous.attempts) + 1,
1196
- successes: Math.max(0, previous.successes) + (success ? 1 : 0),
1197
- };
1198
- }
1199
- if (!this.config.updateModelByDeployment(deployment.providerId, deployment.modelId, { quality_by_task: quality }))
1200
- 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);
1201
1209
  this.config.save();
1210
+ return { ok: true, state: this.jevDecisionModelState() };
1202
1211
  }
1203
1212
  workspaceConversationKey() {
1204
1213
  const ws = this.workspace.current;
@@ -1211,6 +1220,158 @@ class Agent {
1211
1220
  return null;
1212
1221
  return path.join(ws.path, 'conversations', 'state.json');
1213
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
+ }
1214
1375
  workspaceConversationStateKey(conversationId = this.activeConversationId) {
1215
1376
  return this.workspaceConversationStateKeyFor(conversationId, this.workspace.current);
1216
1377
  }
@@ -1520,6 +1681,26 @@ class Agent {
1520
1681
  tree,
1521
1682
  };
1522
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
+ }
1523
1704
  normalizeWorkRuns(runs) {
1524
1705
  if (!Array.isArray(runs))
1525
1706
  return [];
@@ -1798,6 +1979,7 @@ class Agent {
1798
1979
  return true;
1799
1980
  }
1800
1981
  beginConversationWorkRun(runId, target = this.currentConversationTarget(), startedAt = this.nowIso(), managed = false, runtimeKey) {
1982
+ this.pruneLiveWorkRuns();
1801
1983
  const cleanRunId = String(runId || crypto.randomUUID()).trim().slice(0, 200);
1802
1984
  const existing = this.workRuns.find(run => run.runId === cleanRunId);
1803
1985
  if (existing) {
@@ -2482,8 +2664,11 @@ class Agent {
2482
2664
  }
2483
2665
  if (event.type === 'start')
2484
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.
2485
2670
  if (event.type === 'done' || event.type === 'error')
2486
- this.saveWorkspaceConversationState(true);
2671
+ this.saveWorkspaceConversationState(false);
2487
2672
  if (activeRun && this.finalizingWorkRunId !== activeRun.runId
2488
2673
  && (event.type === 'done' || event.type === 'error') && activeRun.status !== 'running') {
2489
2674
  const terminalStatus = activeRun.status;
@@ -2600,8 +2785,10 @@ class Agent {
2600
2785
  const before = this.continuations.length;
2601
2786
  this.continuations = this.continuations.filter(item => item.queueMode !== 'followUp'
2602
2787
  || !matches(item.content, item.hiddenUserInput === true, item.clientMessageId));
2603
- if (this.continuations.length !== before)
2788
+ if (this.continuations.length !== before) {
2789
+ this.continuationRevision += 1;
2604
2790
  this.saveWorkspaceConversationState(true);
2791
+ }
2605
2792
  for (const subscriber of this.rootInboxRetiredSubscribers)
2606
2793
  subscriber(ids);
2607
2794
  return ids;
@@ -2688,6 +2875,7 @@ class Agent {
2688
2875
  createdAt: item.createdAt || new Date().toISOString(),
2689
2876
  }))]);
2690
2877
  this.continuations = combined;
2878
+ this.continuationRevision += 1;
2691
2879
  this.saveWorkspaceConversationState(true);
2692
2880
  return combined.map(item => ({
2693
2881
  ...item,
@@ -2702,6 +2890,7 @@ class Agent {
2702
2890
  if (index < 0)
2703
2891
  return false;
2704
2892
  this.continuations.splice(index, 1);
2893
+ this.continuationRevision += 1;
2705
2894
  this.saveWorkspaceConversationState(true);
2706
2895
  return true;
2707
2896
  }
@@ -2791,7 +2980,15 @@ class Agent {
2791
2980
  state.version = Math.max(1, Number(state.version || 1));
2792
2981
  state.conversations = state.conversations || {};
2793
2982
  let migrated = false;
2794
- 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
+ }
2795
2992
  if (!entry.tree && entry.branches?.length) {
2796
2993
  this.normalizeConversationTree(entry);
2797
2994
  entry.branches = undefined;
@@ -2811,25 +3008,87 @@ class Agent {
2811
3008
  }
2812
3009
  return {};
2813
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
+ }
2814
3047
  writeStoredConversationState(state, ws = this.workspace.current, deletedKeys = []) {
2815
3048
  this.writeStoredConversationStateNow(state, ws, deletedKeys);
2816
3049
  }
2817
- 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) {
2818
3062
  const file = this.workspaceConversationStorePath(ws);
2819
3063
  if (!file)
2820
3064
  return;
2821
- this.conversationStateDirty.set(file, { state, ws });
2822
- const current = this.conversationStateFlushTimers.get(file);
2823
- if (current)
2824
- clearTimeout(current);
2825
- 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(() => {
2826
3073
  this.conversationStateFlushTimers.delete(file);
2827
3074
  const pending = this.conversationStateDirty.get(file);
2828
3075
  if (!pending)
2829
3076
  return;
2830
3077
  this.conversationStateDirty.delete(file);
2831
- this.writeStoredConversationStateNow(pending.state, pending.ws);
2832
- }, 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);
2833
3092
  }
2834
3093
  flushWorkspaceConversationState() {
2835
3094
  const file = this.workspaceConversationStorePath();
@@ -2912,6 +3171,16 @@ class Agent {
2912
3171
  writeStoredConversationStateNow(state, ws = this.workspace.current, deletedKeys = []) {
2913
3172
  const file = this.workspaceConversationStorePath(ws);
2914
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
+ }
2915
3184
  const pendingTimer = this.conversationStateFlushTimers.get(file);
2916
3185
  if (pendingTimer)
2917
3186
  clearTimeout(pendingTimer);
@@ -2933,29 +3202,139 @@ class Agent {
2933
3202
  const existingUpdatedAt = Date.parse(existing.updatedAt || '') || 0;
2934
3203
  const preferred = incomingUpdatedAt >= existingUpdatedAt ? { ...existing, ...incoming } : { ...incoming, ...existing };
2935
3204
  const branchPageReplacement = !!incoming.branchReset || !!incoming.tree || !!incoming.branches?.length;
2936
- const incomingTranscriptEmpty = !(incoming.chatMessages || []).length && !(incoming.history || []).length;
2937
- const existingTranscriptPresent = !!(existing.chatMessages || []).length || !!(existing.history || []).length;
2938
- if (incomingTranscriptEmpty && existingTranscriptPresent && !branchPageReplacement) {
2939
- preferred.chatMessages = existing.chatMessages;
2940
- 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;
2941
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
+ };
2942
3291
  const incomingPlanRevision = Math.max(0, Number(incoming.linkedPlan?.revision || 0));
2943
3292
  const existingPlanRevision = Math.max(0, Number(existing.linkedPlan?.revision || 0));
2944
3293
  preferred.linkedPlan = incomingPlanRevision >= existingPlanRevision ? incoming.linkedPlan : existing.linkedPlan;
2945
3294
  const incomingSequence = Math.max(0, Number(incoming.subagentState?.nextSequence || 0));
2946
3295
  const existingSequence = Math.max(0, Number(existing.subagentState?.nextSequence || 0));
2947
3296
  preferred.subagentState = incomingSequence >= existingSequence ? incoming.subagentState : existing.subagentState;
2948
- preferred.chatMessages = branchPageReplacement ? [...(incoming.chatMessages || [])] : preferred.chatMessages;
2949
- 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
+ : [];
2950
3313
  preferred.workRuns = branchPageReplacement
2951
- ? this.normalizeWorkRuns(incoming.workRuns)
2952
- : this.normalizeWorkRuns([...(existing.workRuns || []), ...(incoming.workRuns || [])]);
2953
- preferred.continuations = branchPageReplacement ? this.normalizeContinuations(incoming.continuations) : preferred.continuations;
2954
- 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');
2955
3323
  preferred.activeBranchId = branchPageReplacement ? String(incoming.activeBranchId || '') : preferred.activeBranchId;
2956
3324
  preferred.activeBranchGroupId = branchPageReplacement ? String(incoming.activeBranchGroupId || '') : preferred.activeBranchGroupId;
2957
- preferred.tree = branchPageReplacement ? incoming.tree : preferred.tree;
3325
+ preferred.tree = winningHeavy('tree') || preferred.tree;
2958
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;
2959
3338
  merged[key] = preferred;
2960
3339
  }
2961
3340
  return {
@@ -2966,6 +3345,17 @@ class Agent {
2966
3345
  conversations: merged,
2967
3346
  };
2968
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
+ }
2969
3359
  stopTimer();
2970
3360
  }
2971
3361
  mutateStoredConversationState(ws, mutate, select) {
@@ -2975,9 +3365,15 @@ class Agent {
2975
3365
  fs.mkdirSync(path.dirname(file), { recursive: true });
2976
3366
  const lock = `${file}.lock`;
2977
3367
  let lockHandle = null;
2978
- for (let attempt = 0; attempt < 80; attempt += 1) {
3368
+ for (let attempt = 0; attempt < 400; attempt += 1) {
2979
3369
  try {
2980
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 { }
2981
3377
  break;
2982
3378
  }
2983
3379
  catch (error) {
@@ -2986,7 +3382,22 @@ class Agent {
2986
3382
  throw error;
2987
3383
  if (code === 'EEXIST') {
2988
3384
  try {
2989
- 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)
2990
3401
  fs.unlinkSync(lock);
2991
3402
  }
2992
3403
  catch { }
@@ -3011,8 +3422,49 @@ class Agent {
3011
3422
  contentState.version = 3;
3012
3423
  contentState.conversations = contentState.conversations || {};
3013
3424
  const temp = `${file}.${process.pid}.${crypto.randomUUID()}.tmp`;
3014
- const diskConversations = Object.fromEntries(Object.entries(contentState.conversations || {})
3015
- .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);
3016
3468
  const content = JSON.stringify({
3017
3469
  version: 3,
3018
3470
  activeConversationId: contentState.activeConversationId || this.activeConversationId || 'default',
@@ -3020,6 +3472,27 @@ class Agent {
3020
3472
  flowSuspensions: contentState.flowSuspensions || undefined,
3021
3473
  conversations: diskConversations,
3022
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
+ }
3023
3496
  try {
3024
3497
  const handle = fs.openSync(temp, 'w');
3025
3498
  try {
@@ -3034,6 +3507,8 @@ class Agent {
3034
3507
  try {
3035
3508
  fs.renameSync(temp, file);
3036
3509
  this.conversationStateCache.set(file, contentState);
3510
+ this.conversationStateWrittenFingerprint.set(file, contentFingerprint);
3511
+ this.conversationStateLastWriteAt.set(file, Date.now());
3037
3512
  const writtenStat = fs.statSync(file, { bigint: true });
3038
3513
  this.conversationStateCacheFingerprint.set(file, `${writtenStat.mtimeNs}:${writtenStat.ctimeNs}:${writtenStat.size}`);
3039
3514
  return select ? select(contentState) : undefined;
@@ -3137,11 +3612,17 @@ class Agent {
3137
3612
  if (prefix && !key.startsWith(prefix))
3138
3613
  continue;
3139
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;
3140
3619
  rows.push({
3141
3620
  id,
3142
3621
  key,
3143
- title: value.title || this.titleFromMessages(value.chatMessages || [], id),
3144
- 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,
3145
3626
  historyCount: value.history?.length || 0,
3146
3627
  updatedAt: value.updatedAt || '',
3147
3628
  pinned: !!value.pinned,
@@ -3615,6 +4096,10 @@ class Agent {
3615
4096
  && ((targetGuideNodeId && group.pageGuideIds?.[parentNodeId] === targetGuideNodeId)
3616
4097
  || (!targetGuideNodeId && targetMessageId && group.pageMessageIds?.[parentNodeId] === targetMessageId))));
3617
4098
  const branchId = crypto.randomUUID();
4099
+ // dev-0.6.4: 新分支(用户分页编辑与实验性分支交流共用这一事务)必须拿到
4100
+ // 全新的全对话 id。复用节点 id 会让两个分支共享 provider 会话序列、
4101
+ // 压缩历史与本地缓存,正是「跨分支缓存」必须禁止的情形。
4102
+ (0, branchIdentity_1.assertFreshBranchIdentity)(clean || this.activeConversationId || 'default', branchId, Object.keys(tree.nodes));
3618
4103
  const branch = this.treeNodeFromEntry(branchId, parentNodeId, index, text, {
3619
4104
  ...entry,
3620
4105
  chatMessages: sourceNode.chatMessages.slice(0, index),
@@ -3735,6 +4220,10 @@ class Agent {
3735
4220
  return false;
3736
4221
  existing.pinned = !!pinned;
3737
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();
3738
4227
  const siblingOrders = Object.entries(stored.conversations)
3739
4228
  .filter(([key, value]) => key !== stateKey && !!value.pinned === existing.pinned && Number.isFinite(value.order))
3740
4229
  .map(([, value]) => Number(value.order));
@@ -3761,6 +4250,7 @@ class Agent {
3761
4250
  return item;
3762
4251
  return byId.get(orderedIds[nextIndex++]);
3763
4252
  });
4253
+ this.continuationRevision += 1;
3764
4254
  this.saveWorkspaceConversationState(true);
3765
4255
  return this.conversationContinuations();
3766
4256
  }
@@ -4112,6 +4602,12 @@ class Agent {
4112
4602
  this.firstAgentResponseStarted = true;
4113
4603
  return true;
4114
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);
4115
4611
  const marked = this.mutateStoredConversationState(workspace, latest => {
4116
4612
  const current = latest.conversations?.[stateKey];
4117
4613
  const firstUser = (current?.chatMessages || []).find(message => message.role === 'user');
@@ -4173,7 +4669,14 @@ class Agent {
4173
4669
  if (orderSlots.some(order => !Number.isFinite(order)))
4174
4670
  return latest;
4175
4671
  orderSlots.sort((a, b) => a - b);
4176
- 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
+ });
4177
4680
  accepted = true;
4178
4681
  return latest;
4179
4682
  });
@@ -4289,6 +4792,7 @@ class Agent {
4289
4792
  subagentState: this.subagents.serialize(),
4290
4793
  workRuns: this.normalizeWorkRuns(this.workRuns),
4291
4794
  continuations: this.normalizeContinuations(this.continuations),
4795
+ continuationRevision: this.continuationRevision,
4292
4796
  modelSelection: this.currentConversationModelSelection(),
4293
4797
  flowSelection: this.currentConversationFlowSelection(),
4294
4798
  inputMode: this.inputMode,
@@ -4317,7 +4821,7 @@ class Agent {
4317
4821
  chatMessages: [...this.chatMessages],
4318
4822
  history: [...this.history],
4319
4823
  providerUsage: this.conversationProviderUsage(),
4320
- compressionCache: [...this.compressionCache],
4824
+ compressionCache: this.mergedCompressionCacheForPersist(stored.conversations[stateKey]),
4321
4825
  branchMailbox: [...this.branchMailbox],
4322
4826
  branchCommunication: this.branchCommunicationEnabled,
4323
4827
  plan: this.normalizeConversationPlan(this.conversationPlan),
@@ -4335,6 +4839,7 @@ class Agent {
4335
4839
  runtimeOwnerId: runtimeOwner.runtimeOwnerId,
4336
4840
  runtimeOwnerPid: runtimeOwner.runtimeOwnerPid,
4337
4841
  runtimeLifecycleRole: runtimeOwner.runtimeLifecycleRole,
4842
+ continuationRevision: this.continuationRevision,
4338
4843
  updatedAt,
4339
4844
  };
4340
4845
  if (nextEntry.tree || nextEntry.branches?.length) {
@@ -4362,6 +4867,7 @@ class Agent {
4362
4867
  this.linkedPlan = { markdown: '', revision: 0 };
4363
4868
  this.workRuns = [];
4364
4869
  this.continuations = [];
4870
+ this.continuationRevision = 0;
4365
4871
  this.mode = 'build';
4366
4872
  this.goal = null;
4367
4873
  this.titleRequestMessageId = '';
@@ -4377,7 +4883,7 @@ class Agent {
4377
4883
  if (saved) {
4378
4884
  this.restoreProviderUsage(saved.providerUsage);
4379
4885
  this.history = [...saved.history];
4380
- this.compressionCache = saved.compressionCache ? saved.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] })) : [];
4886
+ this.compressionCache = this.compressionCacheForBranch(saved.compressionCache);
4381
4887
  this.nextCompressionCacheId = Math.max(1, ...this.compressionCache.map(entry => Number(entry.id.replace(/^ctx-cache-/, '')) || 0)) + 1;
4382
4888
  this.branchMailbox = (saved.branchMailbox || []).map(message => ({ ...message }));
4383
4889
  this.nextBranchMessageSequence = Math.max(1, ...this.branchMailbox.map(message => Number(message.sequence) || 0)) + 1;
@@ -4388,6 +4894,7 @@ class Agent {
4388
4894
  this.bindConversationSubagents(this.activeConversationId, saved.subagentState);
4389
4895
  this.workRuns = this.normalizeWorkRuns(saved.workRuns);
4390
4896
  this.continuations = this.normalizeContinuations(saved.continuations);
4897
+ this.continuationRevision = Number(saved.continuationRevision || 0);
4391
4898
  this.restoreConversationModelSelection(saved.modelSelection);
4392
4899
  this.restoreConversationFlowSelection(saved.flowSelection);
4393
4900
  this.inputMode = this.defaultInputMode();
@@ -4406,7 +4913,7 @@ class Agent {
4406
4913
  this.restoreProviderUsage(persisted?.providerUsage);
4407
4914
  const repairedHistory = this.repairDanglingToolCalls(persisted?.history ? [...persisted.history] : []);
4408
4915
  this.history = repairedHistory.messages;
4409
- this.compressionCache = persisted?.compressionCache ? persisted.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] })) : [];
4916
+ this.compressionCache = this.compressionCacheForBranch(persisted?.compressionCache);
4410
4917
  this.nextCompressionCacheId = Math.max(1, ...this.compressionCache.map(entry => Number(entry.id.replace(/^ctx-cache-/, '')) || 0)) + 1;
4411
4918
  this.branchMailbox = (persisted?.branchMailbox || []).map(message => ({ ...message }));
4412
4919
  this.nextBranchMessageSequence = Math.max(1, ...this.branchMailbox.map(message => Number(message.sequence) || 0)) + 1;
@@ -4417,6 +4924,7 @@ class Agent {
4417
4924
  const recoveredWorkRuns = this.recoverPersistedWorkRuns(persisted?.workRuns, persisted?.updatedAt, this.runtimeLifecycle.unexpectedExit);
4418
4925
  this.workRuns = recoveredWorkRuns.runs;
4419
4926
  this.continuations = this.normalizeContinuations(persisted?.continuations);
4927
+ this.continuationRevision = Number(persisted?.continuationRevision || 0);
4420
4928
  this.restoreConversationModelSelection(persisted?.modelSelection);
4421
4929
  this.restoreConversationFlowSelection(persisted?.flowSelection);
4422
4930
  this.inputMode = this.defaultInputMode();
@@ -5774,6 +6282,7 @@ class Agent {
5774
6282
  this.subagents = source.subagents;
5775
6283
  this.workRuns = workRuns;
5776
6284
  this.continuations = continuations;
6285
+ this.continuationRevision += 1;
5777
6286
  this.mode = source.mode || this.mode;
5778
6287
  if (source.goal !== undefined)
5779
6288
  this.goal = this.restoreGoal(source.goal);
@@ -6616,7 +7125,9 @@ class Agent {
6616
7125
  : (memory?.history ?? persisted?.history ?? []);
6617
7126
  const messages = this.normalizeConversationChatMessages(sourceMessages, sourceHistory);
6618
7127
  const filename = this.writeSessionArchive(messages, this.modeName(), this.model);
6619
- 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))) : {
6620
7131
  title: this.titleFromMessages(messages, clean),
6621
7132
  chatMessages: messages,
6622
7133
  history: sourceHistory,
@@ -6628,6 +7139,9 @@ class Agent {
6628
7139
  continuations: memory?.continuations,
6629
7140
  updatedAt: new Date().toISOString(),
6630
7141
  };
7142
+ for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
7143
+ delete archiveEntry[key];
7144
+ }
6631
7145
  const manifest = {
6632
7146
  version: 2,
6633
7147
  kind: 'newmark-conversation-archive',
@@ -6694,6 +7208,24 @@ class Agent {
6694
7208
  const cachedStored = this.readStoredConversationState(ws);
6695
7209
  const stored = JSON.parse(JSON.stringify(cachedStored || {}));
6696
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
+ }
6697
7229
  if (persisted)
6698
7230
  this.normalizeConversationTree(persisted);
6699
7231
  const memory = this.workspaceConversations.get(memoryKey);
@@ -6714,7 +7246,8 @@ class Agent {
6714
7246
  model: archiveModel,
6715
7247
  goal: archiveGoal,
6716
7248
  }, archiveDir);
6717
- 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))) : {
6718
7251
  title: this.titleFromMessages(messages, clean),
6719
7252
  chatMessages: messages,
6720
7253
  history: sourceHistory,
@@ -6726,6 +7259,9 @@ class Agent {
6726
7259
  continuations: memory?.continuations,
6727
7260
  updatedAt: new Date().toISOString(),
6728
7261
  };
7262
+ for (const key of ['shard', 'shardBytes', 'messageCount', 'historyCount', 'workRunCount', 'hasTree', 'hasBranches']) {
7263
+ delete archiveEntry[key];
7264
+ }
6729
7265
  const manifest = {
6730
7266
  version: 2,
6731
7267
  kind: 'newmark-conversation-archive',
@@ -6771,6 +7307,19 @@ class Agent {
6771
7307
  nextActiveId = latest.activeConversationId || remaining[0] || 'default';
6772
7308
  return latest;
6773
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
+ }
6774
7323
  this.workspaceConversations.delete(memoryKey);
6775
7324
  const duplicateMemoryKey = `${ws.isInternal ? 'internal' : 'external'}:${path.resolve(ws.path)}::conversation:${clean}`;
6776
7325
  this.workspaceConversations.delete(duplicateMemoryKey);
@@ -7092,7 +7641,7 @@ class Agent {
7092
7641
  capabilities.add(capability);
7093
7642
  }
7094
7643
  }
7095
- const health = this.autoRouter.endpointMetrics(this.deploymentRef(model));
7644
+ const health = this.routeController.endpointMetrics(this.deploymentRef(model));
7096
7645
  const latencySeconds = Number(model.evaluation?.latency);
7097
7646
  const configuredPrivacy = (model.privacy || ['default']).filter(value => value === 'default' || value === 'no_training' || value === 'zdr');
7098
7647
  const supportedParameters = new Set((routeMetadata.supported_parameters || []).map(value => String(value).toLowerCase()));
@@ -7112,6 +7661,7 @@ class Agent {
7112
7661
  return {
7113
7662
  deployment: this.deploymentRef(model),
7114
7663
  enabled: model.enabled !== false,
7664
+ unavailableReason: this.isBalanceBlockedDeployment(this.deploymentRef(model)) ? 'balance_blocked' : undefined,
7115
7665
  validation: {
7116
7666
  level: validation.level,
7117
7667
  status: effectiveValidationStatus,
@@ -7133,7 +7683,7 @@ class Agent {
7133
7683
  reliability: health.attempts ? health.reliability : 0.5,
7134
7684
  toolValidity: health.toolAttempts ? health.toolValidity : undefined,
7135
7685
  throughput: health.throughput ?? (model.speed_rating === 'fast' ? 80 : model.speed_rating === 'medium' ? 40 : model.speed_rating === 'slow' ? 15 : undefined),
7136
- qualityByTask: model.quality_by_task,
7686
+ circuit: health.circuit,
7137
7687
  preference: Number.isFinite(Number(routeMetadata.route_preference)) ? Number(routeMetadata.route_preference) : undefined,
7138
7688
  fallbackOnly: !!model.fallback_only,
7139
7689
  };
@@ -7149,40 +7699,6 @@ class Agent {
7149
7699
  // Routing remains available when metrics-only audit storage is read-only.
7150
7700
  }
7151
7701
  }
7152
- loadLearnedRouteFeedback() {
7153
- try {
7154
- const file = path.join(this.rootPath, 'routing', 'feedback.jsonl');
7155
- if (!fs.existsSync(file))
7156
- return;
7157
- const lines = fs.readFileSync(file, 'utf-8').split(/\r?\n/).filter(Boolean).slice(-2000);
7158
- for (const line of lines) {
7159
- const value = JSON.parse(line);
7160
- const deployment = value.deployment;
7161
- if (!deployment?.providerId || !deployment.modelId)
7162
- continue;
7163
- if (!['chat', 'coding', 'reasoning', 'long_context', 'vision', 'image_generation', 'tool_use', 'computer_use'].includes(String(value.taskClass)))
7164
- continue;
7165
- if (!['manual_switch', 'explicit_rating', 'objective_success'].includes(String(value.source)))
7166
- continue;
7167
- if (!Number.isFinite(Number(value.score)) || !Number.isFinite(Number(value.at)))
7168
- continue;
7169
- this.autoRouter.recordFeedback(value);
7170
- }
7171
- }
7172
- catch {
7173
- // A malformed preference log is ignored instead of influencing routing.
7174
- }
7175
- }
7176
- recordRouteFeedbackFor(deployment, taskClass, score, source) {
7177
- const event = { deployment: { ...deployment }, taskClass, score, source, at: Date.now() };
7178
- this.autoRouter.recordFeedback(event);
7179
- try {
7180
- const directory = path.join(this.rootPath, 'routing');
7181
- fs.mkdirSync(directory, { recursive: true });
7182
- fs.appendFileSync(path.join(directory, 'feedback.jsonl'), `${JSON.stringify(event)}\n`, 'utf-8');
7183
- }
7184
- catch { }
7185
- }
7186
7702
  allModelNames() {
7187
7703
  const names = this.config.allModels().filter(m => m.enabled !== false).map(m => {
7188
7704
  const label = m.display || m.name;
@@ -7197,13 +7713,8 @@ class Agent {
7197
7713
  const before = this.resolvedDeployment ? deploymentIdentity(this.resolvedDeployment) : '';
7198
7714
  const mode = override?.mode || (0, autoRouter_1.normalizeAutoPreference)(this.config.autoSwitchPreference());
7199
7715
  const policy = (0, autoRouter_1.defaultRoutePolicy)(mode);
7200
- const configuredQualityLoss = this.config.get('models', 'auto_max_quality_loss');
7201
- if (typeof configuredQualityLoss === 'number' && Number.isFinite(configuredQualityLoss) && configuredQualityLoss >= 0) {
7202
- policy.maxQualityLoss = Math.min(1, configuredQualityLoss);
7203
- }
7204
- if (typeof override?.maxQualityLoss === 'number' && Number.isFinite(override.maxQualityLoss) && override.maxQualityLoss >= 0) {
7205
- policy.maxQualityLoss = Math.min(1, override.maxQualityLoss);
7206
- }
7716
+ // The tendency is guidance for the current-model JEV decision. Eligibility
7717
+ // and explicit cost ceilings remain hard constraints.
7207
7718
  const maxExpectedCost = this.config.get('models', 'auto_max_expected_cost_usd');
7208
7719
  if (typeof maxExpectedCost === 'number' && Number.isFinite(maxExpectedCost) && maxExpectedCost > 0)
7209
7720
  policy.maxExpectedCostUsd = maxExpectedCost;
@@ -7256,27 +7767,141 @@ class Agent {
7256
7767
  };
7257
7768
  const ownsTransaction = !this.routeTransactionId;
7258
7769
  const transactionId = this.routeTransactionId || `route-${crypto.randomUUID()}`;
7259
- 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 = {
7260
7776
  transactionId,
7261
- affinityKey: `${this.workspace.current?.path || this.rootPath}\u0000${this.activeConversationId || 'default'}`,
7262
7777
  taskText: task,
7263
- estimatedInputTokens: this.estimateContextTokens(),
7264
- expectedOutputTokens: this.intelligence === 'ultra' || this.intelligence === 'max' ? 32768
7265
- : this.intelligence === 'xhigh' ? 16384
7266
- : this.intelligence === 'high' ? 8192
7267
- : this.intelligence === 'low' ? 2048 : 4096,
7778
+ estimatedInputTokens,
7779
+ expectedOutputTokens,
7268
7780
  requiredCapabilities: [...requiredCapabilities],
7269
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(),
7270
7789
  });
7271
7790
  if (ownsTransaction)
7272
- 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);
7273
7796
  this.lastRouteDecision = decision;
7274
- 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
+ }
7275
7807
  this.pendingAutoAttempts = [];
7276
7808
  this.routeAttemptStartedAt = this.resolvedDeployment ? Date.now() : 0;
7277
7809
  this.persistRouteDecision(decision);
7278
7810
  return !!this.resolvedDeployment && before !== deploymentIdentity(this.resolvedDeployment);
7279
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
+ }
7280
7905
  shouldExposeToolInterface() {
7281
7906
  return true;
7282
7907
  }
@@ -7312,7 +7937,7 @@ class Agent {
7312
7937
  return null;
7313
7938
  }
7314
7939
  if (observedDeployment)
7315
- this.autoRouter.recordEndpointFailure(observedDeployment, observedFailure);
7940
+ this.routeController.recordEndpointFailure(observedDeployment, observedFailure);
7316
7941
  if (this.model === 'auto') {
7317
7942
  const current = this.activeDeployment();
7318
7943
  if (!current || !this.lastRouteDecision)
@@ -7330,7 +7955,7 @@ class Agent {
7330
7955
  currentAttempt.durationMs = Math.max(0, Date.now() - this.routeAttemptStartedAt);
7331
7956
  }
7332
7957
  if (!this.pendingAutoAttempts.length) {
7333
- this.pendingAutoAttempts = this.autoRouter.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
7958
+ this.pendingAutoAttempts = this.routeController.planAttempts(this.lastRouteDecision, this.autoRouteCandidates(), {
7334
7959
  error: failure,
7335
7960
  streamCommitted: this.routeStreamCommitted,
7336
7961
  sideEffectCommitted: this.routeSideEffectCommitted,
@@ -7350,7 +7975,7 @@ class Agent {
7350
7975
  this.lastRouteDecision.attempts.push({ ...next });
7351
7976
  this.lastRouteDecision.resolvedDeployment = { ...next.deployment };
7352
7977
  this.lastRouteDecision.finalStatus = 'retrying';
7353
- this.autoRouter.claimEndpointAttempt(next.deployment);
7978
+ this.routeController.claimEndpointAttempt(next.deployment);
7354
7979
  this.routeAttemptStartedAt = Date.now();
7355
7980
  this.persistRouteDecision(this.lastRouteDecision);
7356
7981
  return current.modelId;
@@ -7368,8 +7993,9 @@ class Agent {
7368
7993
  const usable = all.filter(m => !modelConfigIsUnavailable(m) && !this.failedDeploymentsThisRun.has(deploymentIdentity(this.deploymentRef(m))));
7369
7994
  if (!usable.length)
7370
7995
  return null;
7371
- const pref = this.config.autoSwitchPreference();
7372
- 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));
7373
7999
  const next = ranked[0];
7374
8000
  if (!next?.name)
7375
8001
  return null;
@@ -8301,8 +8927,14 @@ class Agent {
8301
8927
  stopTotalTimer();
8302
8928
  this.processDepth = Math.max(0, this.processDepth - 1);
8303
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();
8304
8936
  if (this.routeTransactionId)
8305
- this.autoRouter.endTransaction(this.routeTransactionId);
8937
+ this.routeController.endTransaction(this.routeTransactionId);
8306
8938
  this.routeTransactionId = '';
8307
8939
  this.processingConversationId = null;
8308
8940
  this.activeProcessAbortController = null;
@@ -9795,7 +10427,41 @@ class Agent {
9795
10427
  compressionArchiveScopeKey() {
9796
10428
  if (this.isSubagentRuntime || !this.config.getBool('context', 'compression_archive_enabled'))
9797
10429
  return null;
9798
- return this.workspaceConversationKey();
10430
+ const base = this.workspaceConversationKey();
10431
+ if (!base)
10432
+ return null;
10433
+ // dev-0.6.4: 折叠历史必须按分支节点隔离——用户分页产生的分支与实验性分支
10434
+ // 交流产生的分支共用这一条身份规则,兄弟分支不得互相读取压缩归档。
10435
+ return (0, branchIdentity_1.branchConversationIdentity)(base, this.currentBranchNodeId());
10436
+ }
10437
+ /**
10438
+ * The hot folded-history cache belongs to one branch node. Legacy entries
10439
+ * (created before branch scoping) belong to the root node, which is where
10440
+ * they were produced.
10441
+ */
10442
+ compressionCacheForBranch(entries) {
10443
+ const branch = String(this.currentBranchNodeId() || '');
10444
+ const root = String(this.conversationTree()?.rootNodeId || '');
10445
+ return (Array.isArray(entries) ? entries : []).flatMap(entry => {
10446
+ const owner = String(entry.branchNodeId || root || '');
10447
+ if (branch && owner && owner !== branch)
10448
+ return [];
10449
+ if (branch && !owner)
10450
+ return [];
10451
+ return [{ ...entry, branchNodeId: owner || branch || undefined, messages: [...entry.messages] }];
10452
+ });
10453
+ }
10454
+ /**
10455
+ * Persist the union of every branch's folded history: the live in-memory
10456
+ * cache only holds the active branch, and the stored entry holds the rest.
10457
+ */
10458
+ mergedCompressionCacheForPersist(storedEntry) {
10459
+ const branch = String(this.currentBranchNodeId() || '');
10460
+ const others = (storedEntry?.compressionCache || []).filter(entry => {
10461
+ const owner = String(entry.branchNodeId || '');
10462
+ return !!owner && owner !== branch;
10463
+ });
10464
+ return [...others, ...this.compressionCache.map(entry => ({ ...entry, messages: [...entry.messages] }))];
9799
10465
  }
9800
10466
  coldCompressionEntries() {
9801
10467
  const scopeKey = this.compressionArchiveScopeKey();
@@ -9872,6 +10538,7 @@ class Agent {
9872
10538
  foldedChars,
9873
10539
  model,
9874
10540
  fallback,
10541
+ branchNodeId: this.currentBranchNodeId() || undefined,
9875
10542
  });
9876
10543
  this.nextCompressionCacheId += 1;
9877
10544
  const maxEntries = Math.max(0, Math.floor(this.config.getNum('context', 'compression_cache_max') || 8));