@jskit-ai/assistant-core 0.2.13 → 0.2.15

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 (48) hide show
  1. package/CHANGELOG.md +346 -0
  2. package/examples/conversation/package.json +5 -5
  3. package/locales/en.json +171 -6
  4. package/package.json +5 -5
  5. package/src/server/conversation/boundedToolLoop.js +4 -2
  6. package/src/server/conversation/claudeClient.js +15 -10
  7. package/src/server/conversation/claudeCommandRun.js +16 -6
  8. package/src/server/conversation/claudeNativeTurn.js +18 -6
  9. package/src/server/conversation/claudeProcess.js +20 -8
  10. package/src/server/conversation/claudeProcessStart.js +1 -1
  11. package/src/server/conversation/claudeSessionCleanup.js +6 -10
  12. package/src/server/conversation/claudeTurn.js +3 -3
  13. package/src/server/conversation/codexAccountReader.js +15 -6
  14. package/src/server/conversation/codexConversationStops.js +3 -3
  15. package/src/server/conversation/codexConversations.js +1 -1
  16. package/src/server/conversation/codexDetachedTurn.js +4 -2
  17. package/src/server/conversation/codexGoalCommands.js +3 -1
  18. package/src/server/conversation/codexHelperExecution.js +4 -4
  19. package/src/server/conversation/codexHelperThreadRecovery.js +2 -2
  20. package/src/server/conversation/codexHistoryAdapter.js +1 -1
  21. package/src/server/conversation/codexMessageCommands.js +4 -2
  22. package/src/server/conversation/codexModelCatalog.js +1 -1
  23. package/src/server/conversation/codexObservationControl.js +1 -1
  24. package/src/server/conversation/codexProcess.js +3 -1
  25. package/src/server/conversation/codexProvider.js +4 -2
  26. package/src/server/conversation/codexProviderAuth.js +17 -9
  27. package/src/server/conversation/codexProviderInvalidation.js +6 -2
  28. package/src/server/conversation/codexProviderThreads.js +4 -2
  29. package/src/server/conversation/codexProviderTurns.js +4 -2
  30. package/src/server/conversation/codexRenewalThreads.js +2 -2
  31. package/src/server/conversation/conversationTurnDelivery.js +3 -1
  32. package/src/server/conversation/conversationTurns.js +3 -1
  33. package/src/server/conversation/openCodeProcess.js +5 -9
  34. package/src/server/conversation/openCodeRuntime.js +3 -1
  35. package/src/server/conversation/openCodeTurn.js +3 -2
  36. package/src/server/conversation/providers/claudeDriver.js +12 -4
  37. package/src/server/conversation/providers/codex.js +8 -4
  38. package/src/server/conversation/providers/codexDriver.js +23 -10
  39. package/src/server/conversation/providers/codexDriverConnection.js +18 -6
  40. package/src/server/conversation/providers/codexDriverTurns.js +3 -1
  41. package/src/server/conversation/providers/opencode.js +3 -1
  42. package/src/server/conversation/providers/opencodeDriver.js +3 -1
  43. package/src/server/conversation/providers/opencodeStandalone.js +37 -13
  44. package/src/server/conversation/providers/scoped.js +3 -1
  45. package/src/server/conversation/systemPrompt.js +3 -1
  46. package/src/server/conversation/tools.js +6 -2
  47. package/src/shared/assistantSettingsResource.js +14 -2
  48. package/test/claudeLifecycle.test.js +60 -5
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -51,8 +51,8 @@
51
51
  "@ai-sdk/togetherai": "^3.0.49",
52
52
  "@ai-sdk/xai": "^4.0.58",
53
53
  "@jskit-ai/agent": "0.1.0",
54
- "@jskit-ai/resource-core": "0.1.196",
55
- "@jskit-ai/resource-crud-core": "0.1.196",
54
+ "@jskit-ai/resource-core": "0.1.197",
55
+ "@jskit-ai/resource-crud-core": "0.1.197",
56
56
  "@mdi/js": "^7.4.47",
57
57
  "@openrouter/ai-sdk-provider": "^3.0.0",
58
58
  "ai": "^7.0.152",
@@ -61,8 +61,8 @@
61
61
  "ws": "^8.20.1"
62
62
  },
63
63
  "peerDependencies": {
64
- "@jskit-ai/http-runtime": "0.2.5",
65
- "@jskit-ai/kernel": "0.2.5",
64
+ "@jskit-ai/http-runtime": "0.2.6",
65
+ "@jskit-ai/kernel": "0.2.6",
66
66
  "vue": "^3.5.13",
67
67
  "vuetify": "^4.0.0"
68
68
  },
@@ -59,8 +59,10 @@ function boundedResponseSchema(outputSchema, descriptors) {
59
59
 
60
60
  /** Original bounded response/operation order, with one shared reply/tool protocol. */
61
61
  async function runBoundedAssistantToolLoop({ prompt, signal, policy, complete, outputSchema, limitError,
62
- invalidResponseError = new Error("The assistant returned an invalid response."),
63
- failureError = new Error("The assistant could not complete this request."), toolCatalog, toolContext,
62
+ invalidResponseError = Object.assign(new Error("The assistant returned an invalid response."), { messageCode: "assistant.turn.invalidResponse", messageParams: {} }),
63
+ failureError = Object.assign(new Error("The assistant could not complete this request."), {
64
+ messageCode: "assistant.turn.requestIncomplete", messageParams: {}
65
+ }), toolCatalog, toolContext,
64
66
  completedEnvelope = false, settle }) {
65
67
  if (typeof completedEnvelope !== "boolean") throw new TypeError("Completed envelope mode must be a boolean.");
66
68
  if (completedEnvelope && (policy.maximumResponses !== COMPLETED_ENVELOPE_RESPONSES || typeof settle !== "function")) {
@@ -3,8 +3,9 @@ import { TextDecoder } from "node:util";
3
3
 
4
4
  const CLAUDE_JSON_MAX_FRAME_BYTES = 16 * 1024 * 1024;
5
5
 
6
- function claudeProtocolError(message, code = "assistant_claude_protocol_error") {
7
- return Object.assign(new Error(message), { code });
6
+ // messageCode names the sentence, since one protocol code covers several.
7
+ function claudeProtocolError(message, { code = "assistant_claude_protocol_error", params, messageCode } = {}) {
8
+ return Object.assign(new Error(message), { code, ...(params ? { params } : {}), ...(messageCode ? { messageCode, messageParams: {} } : {}) });
8
9
  }
9
10
 
10
11
  // Geometric growth keeps fragmented frames linear with bounded memory. Decode complete
@@ -116,7 +117,7 @@ function createClaudeJsonClient({
116
117
  }
117
118
  const next = writes.then(() => new Promise((resolve, reject) => {
118
119
  if (failure || closed || stream.destroyed) {
119
- reject(failure || claudeProtocolError("Claude connection is closed."));
120
+ reject(failure || claudeProtocolError("Claude connection is closed.", { messageCode: "assistant.claude.connectionClosed" }));
120
121
  return;
121
122
  }
122
123
  // A write callback, unlike write()'s boolean return, respects backpressure
@@ -128,7 +129,7 @@ function createClaudeJsonClient({
128
129
  }
129
130
 
130
131
  async function request(request, options = {}) {
131
- if (failure || closed) throw failure || claudeProtocolError("Claude connection is closed.");
132
+ if (failure || closed) throw failure || claudeProtocolError("Claude connection is closed.", { messageCode: "assistant.claude.connectionClosed" });
132
133
  const requestId = randomUUID();
133
134
  const { onProgress = reportNoProgress } = options;
134
135
  function progress(stage) {
@@ -139,7 +140,9 @@ function createClaudeJsonClient({
139
140
  const response = new Promise((resolve, reject) => {
140
141
  timer = setTimeout(() => {
141
142
  requests.delete(requestId);
142
- reject(claudeProtocolError(`Claude ${request.subtype} timed out.`, "assistant_claude_control_timeout"));
143
+ reject(claudeProtocolError(`Claude ${request.subtype} timed out.`, {
144
+ code: "assistant_claude_control_timeout", params: { request: request.subtype }
145
+ }));
143
146
  }, options.timeoutMs ?? timeoutMs);
144
147
  requests.set(requestId, { resolve, reject, received: () => progress("response_received") });
145
148
  });
@@ -175,7 +178,7 @@ function createClaudeJsonClient({
175
178
  // Waiting here does not block the reader from answering interrupt controls.
176
179
  await precedingEvents;
177
180
  controller.signal.throwIfAborted();
178
- if (!onControlRequest) throw claudeProtocolError("This control request requires the native Claude Code terminal.");
181
+ if (!onControlRequest) throw claudeProtocolError("This control request requires the native Claude Code terminal.", { messageCode: "assistant.claude.controlNeedsTerminal" });
179
182
  const value = await onControlRequest(frame.request, { signal: controller.signal });
180
183
  response = { subtype: "success", request_id: id, response: value || {} };
181
184
  } catch (error) {
@@ -223,14 +226,16 @@ function createClaudeJsonClient({
223
226
  }
224
227
  }
225
228
  await events;
226
- if (!closed) throw claudeProtocolError("Claude event connection ended unexpectedly.", "assistant_claude_observation_lost");
229
+ if (!closed) throw claudeProtocolError("Claude event connection ended unexpectedly.", {
230
+ code: "assistant_claude_observation_lost", messageCode: "assistant.claude.eventConnectionEnded"
231
+ });
227
232
  } catch (error) {
228
233
  if (!closed) {
229
234
  fail(error);
230
235
  await onFailure(failure);
231
236
  }
232
237
  } finally {
233
- fail(failure || claudeProtocolError("Claude connection is closed."));
238
+ fail(failure || claudeProtocolError("Claude connection is closed.", { messageCode: "assistant.claude.connectionClosed" }));
234
239
  }
235
240
  })();
236
241
  // Callers can await completion; the background observer must never leak an
@@ -262,7 +267,7 @@ function createClaudeJsonClient({
262
267
  // Older CLIs treat this control as a plain Stop, cancelling background
263
268
  // workers. Require the native capability before writing either frame.
264
269
  if (sendNow && !supportsSendNow) throw Object.assign(
265
- claudeProtocolError("This Claude version does not support targeted Send now."), { delivery: "not-sent" }
270
+ claudeProtocolError("This Claude version does not support targeted Send now.", { messageCode: "assistant.claude.sendNowUnsupported" }), { delivery: "not-sent" }
266
271
  );
267
272
  await write({ type: "user", uuid: messageId, session_id: sessionId,
268
273
  parent_tool_use_id: null, message: { role: "user", content: message } });
@@ -272,7 +277,7 @@ function createClaudeJsonClient({
272
277
  /** Closes the connection; pending requests fail. */
273
278
  function close() {
274
279
  closed = true;
275
- fail(claudeProtocolError("Claude connection was closed."));
280
+ fail(claudeProtocolError("Claude connection was closed.", { messageCode: "assistant.claude.connectionWasClosed" }));
276
281
  }
277
282
 
278
283
  return Object.freeze({
@@ -41,7 +41,9 @@ export function createClaudeCommandRun({ bound, preparation, send, interrupt })
41
41
  const active = control.current;
42
42
  const applicationCommand = active && (active.tools || active.finishIfCurrent) ? { active, accept: command.accept } : null;
43
43
  if (applicationCommand) {
44
- if (entry.disposed || entry.command && entry.command !== active) throw new Error("This Claude conversation is closed or already working.");
44
+ if (entry.disposed || entry.command && entry.command !== active) throw Object.assign(new Error("This Claude conversation is closed or already working."), {
45
+ messageCode: "assistant.claude.closedOrWorking", messageParams: {}
46
+ });
45
47
  if (active.entry && active.entry !== entry) throw new Error("Claude's application tools belong to another native conversation.");
46
48
  active.entry = entry;
47
49
  entry.command = active;
@@ -61,12 +63,16 @@ export function createClaudeCommandRun({ bound, preparation, send, interrupt })
61
63
 
62
64
  function steer(control, command, options) {
63
65
  if (!bound) return dispatch(control, command, options).then(delivered => ({ value: delivered.value }));
64
- if (!control.current?.steer || !control.current.entry.nativeTurn.read().active) throw new Error("Claude has no active turn to steer.");
66
+ if (!control.current?.steer || !control.current.entry.nativeTurn.read().active) throw Object.assign(new Error("Claude has no active turn to steer."), {
67
+ messageCode: "assistant.claude.noTurnToSteer", messageParams: {}
68
+ });
65
69
  return control.current.steer(command);
66
70
  }
67
71
 
68
72
  async function run(control, command, { acquire, reportFailure, entry: selectedEntry, maximumOutput, maximumToolCalls = 32, timeoutMs } = {}) {
69
- if (control.disposed || control.current) throw new Error("This Claude conversation is closed or already working.");
73
+ if (control.disposed || control.current) throw Object.assign(new Error("This Claude conversation is closed or already working."), {
74
+ messageCode: "assistant.claude.closedOrWorking", messageParams: {}
75
+ });
70
76
  const { configuration, context, input, tools, signal, beforeDispatch, accept, onMessage, onEvent, onNativeToolUse } = command;
71
77
  const completion = Promise.withResolvers();
72
78
  completion.promise.catch(() => {});
@@ -108,7 +114,9 @@ export function createClaudeCommandRun({ bound, preparation, send, interrupt })
108
114
  try {
109
115
  if (bound) {
110
116
  entry = selectedEntry;
111
- if (entry.disposed || entry.command) throw new Error("This Claude conversation is closed or already working.");
117
+ if (entry.disposed || entry.command) throw Object.assign(new Error("This Claude conversation is closed or already working."), {
118
+ messageCode: "assistant.claude.closedOrWorking", messageParams: {}
119
+ });
112
120
  active.entry = entry;
113
121
  entry.command = active;
114
122
  entry.completion = completion;
@@ -138,12 +146,14 @@ export function createClaudeCommandRun({ bound, preparation, send, interrupt })
138
146
  problem = dispatchFailure || failure || problem;
139
147
  if (!problem && settling?.completion && (bound ? result.status !== "completed" : !result.ok)) {
140
148
  // readable-check: allow patched-error - data: status is the native turn's state, not an HTTP status
141
- problem = Object.assign(new Error(result.error || (bound ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), { status: result.status });
149
+ problem = Object.assign(new Error(result.error || (bound ? "Claude interrupted this answer." : `Claude turn ${result.status}.`)), {
150
+ status: result.status, ...(bound ? { messageCode: "assistant.claude.answerInterrupted", messageParams: {} } : {})
151
+ });
142
152
  }
143
153
  break;
144
154
  }
145
155
  } catch (error) { problem = error; }
146
- ready.reject(problem || new Error("Claude work ended."));
156
+ ready.reject(problem || Object.assign(new Error("Claude work ended."), { messageCode: "assistant.claude.workEnded", messageParams: {} }));
147
157
  signal.removeEventListener("abort", abort);
148
158
  if (bound) {
149
159
  try {
@@ -3,8 +3,9 @@ import { claudeMessageBlocks } from "./claudeHistory.js";
3
3
  import { stopClaudeCodeProcess } from "./claudeProcess.js";
4
4
  import { createClaudeConversationAdapter } from "./providers/claude.js";
5
5
 
6
- export function claudeTurnFailure(message, { code = "assistant_claude_turn_failed" } = {}) {
7
- return Object.assign(new Error(message), { code });
6
+ // messageCode names the sentence, since one turn-failure code covers several.
7
+ export function claudeTurnFailure(message, { code = "assistant_claude_turn_failed", messageCode } = {}) {
8
+ return Object.assign(new Error(message), { code, ...(messageCode ? { messageCode, messageParams: {} } : {}) });
8
9
  }
9
10
 
10
11
  // Input for a turn that already finished is the caller's refusal; input that
@@ -109,7 +110,8 @@ function retainStoppedClaudeMessages(messages, pendingAssistantMessages) {
109
110
  * The owner's control of one native process, as a native turn uses it.
110
111
  * @typedef {import("./providers/claude.js").ClaudeProcessControl & {
111
112
  * stopExecution?: (executionId: string, options?: object) => Promise<object>,
112
- * releaseExecution?: () => Promise<unknown>
113
+ * releaseExecution?: () => Promise<unknown>,
114
+ * ownerClosing?: () => boolean
113
115
  * }} ClaudeTurnProcess
114
116
  */
115
117
 
@@ -275,6 +277,16 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
275
277
  if (active) entry.completion?.resolve({ status: "interrupted", error: reason || "Work stopped." });
276
278
  return;
277
279
  }
280
+ if (entry.turn?.active && process.ownerClosing()) {
281
+ // At shutdown the turn's end belongs to the next server, as after a
282
+ // crash: its saved record keeps the execution and the active turn, so
283
+ // that server's recovery proves this exit, interrupts the turn and runs
284
+ // the host's checkpoint. Run here, that checkpoint (a Git snapshot, say)
285
+ // would hold the host's bounded shutdown for as long as it takes.
286
+ resolveCompletion({ ...readResult(), status: "interrupted", error: reason });
287
+ if (entry.startupCleanup === startup) entry.startupCleanup = null;
288
+ return entry.exitProof;
289
+ }
278
290
  if (entry.turn?.active) {
279
291
  try { await updateState("interrupted", reason); }
280
292
  catch (error) {
@@ -358,7 +370,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
358
370
 
359
371
  function checkOutput(value, role = "assistant") {
360
372
  if (value.length > (role === "thinking" ? 4 * 1024 * 1024 : maximumOutput)) {
361
- throw claudeTurnFailure("Claude output exceeded its size limit.");
373
+ throw claudeTurnFailure("Claude output exceeded its size limit.", { messageCode: "assistant.claude.outputTooLarge" });
362
374
  }
363
375
  }
364
376
 
@@ -600,7 +612,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
600
612
  }
601
613
 
602
614
  function begin({ id, maxOutputCharacters = 4 * 1024 * 1024 } = {}) {
603
- if (active) throw claudeTurnFailure("This Claude turn is still working.");
615
+ if (active) throw claudeTurnFailure("This Claude turn is still working.", { messageCode: "assistant.claude.turnStillWorking" });
604
616
  if (!id || !Number.isSafeInteger(maxOutputCharacters) || maxOutputCharacters < 1) throw new TypeError("Invalid Claude turn configuration.");
605
617
  generation++;
606
618
  clearTurnView();
@@ -615,7 +627,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
615
627
  }
616
628
 
617
629
  function reset() {
618
- if (active || inFlight.size || tasks.size) throw claudeTurnFailure("Stop Claude before resetting its turn view.");
630
+ if (active || inFlight.size || tasks.size) throw claudeTurnFailure("Stop Claude before resetting its turn view.", { messageCode: "assistant.claude.stopBeforeReset" });
619
631
  clearTurnView();
620
632
  }
621
633
 
@@ -134,7 +134,9 @@ export function claudeConnectionIdentity(configRoot, providerId, baseUrl, apiKey
134
134
  export function claudeModelConfiguration({ providerId, model: modelId }, connection) {
135
135
  const provider = nativeAiProvider(providerId);
136
136
  const definition = provider?.models.find(({ id }) => id === modelId);
137
- if (provider && (!definition || !connection?.apiKey)) throw new Error("Choose a connected Claude Code model.");
137
+ if (provider && (!definition || !connection?.apiKey)) throw Object.assign(new Error("Choose a connected Claude Code model."), {
138
+ messageCode: "assistant.claude.chooseConnectedModel", messageParams: {}
139
+ });
138
140
  const model = definition?.contextWindow >= 1000000 ? `${definition.id}[1m]` : modelId;
139
141
  const externalModel = provider ? model : "";
140
142
  // Reset every provider override when returning to the native subscription.
@@ -328,7 +330,9 @@ export function createClaudeAccountQueries({
328
330
  processes = noOwnedProcesses,
329
331
  isClosing = neverClosing
330
332
  }) {
331
- const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
333
+ const error = (message, { messageCode }) => Object.assign(new Error(message), {
334
+ code: "claude_account_query_failed", messageCode, messageParams: {}
335
+ });
332
336
  const accountProcessStops = new Set();
333
337
  const accountReads = new Set();
334
338
  let catalog;
@@ -339,13 +343,17 @@ export function createClaudeAccountQueries({
339
343
  let revision = 0;
340
344
 
341
345
  function assertCurrent(expectedRevision) {
342
- if (expectedRevision !== revision) throw error("Claude account query was cancelled for reconnection.");
343
- if (isClosing()) throw error("Claude is reconnecting.");
346
+ if (expectedRevision !== revision) {
347
+ throw error("Claude account query was cancelled for reconnection.", { messageCode: "assistant.claude.accountQueryCancelled" });
348
+ }
349
+ if (isClosing()) throw error("Claude is reconnecting.", { messageCode: "assistant.claude.reconnecting" });
344
350
  }
345
351
 
346
352
  async function stopAccountProcess(native) {
347
353
  accountProcessStops.add(native);
348
- if (!(await native.stop()).scopeEmpty) throw error("Claude account query process cleanup could not be confirmed.");
354
+ if (!(await native.stop()).scopeEmpty) throw error("Claude account query process cleanup could not be confirmed.", {
355
+ messageCode: "assistant.claude.accountQueryCleanupUnconfirmed"
356
+ });
349
357
  accountProcessStops.delete(native);
350
358
  }
351
359
 
@@ -406,7 +414,9 @@ export function createClaudeAccountQueries({
406
414
  const { value, command } = await readAccountProcess(identity, expectedRevision, "catalogue",
407
415
  async (native) => ({ value: await native.initialization, command: native.command }));
408
416
  const answered = await catalogueRevision(command);
409
- if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
417
+ if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.", {
418
+ messageCode: "assistant.claude.accountChangedRefreshModels"
419
+ });
410
420
  assertCurrent(expectedRevision);
411
421
  catalog = { identity, command, revision: answered, value };
412
422
  return value;
@@ -480,7 +490,9 @@ export function createClaudeAccountQueries({
480
490
  revision++;
481
491
  // Dropped at once: a read started before this cannot store its answer.
482
492
  catalog = null;
483
- for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
493
+ for (const controller of accountReads) {
494
+ controller.abort(error("Claude account query was cancelled for reconnection.", { messageCode: "assistant.claude.accountQueryCancelled" }));
495
+ }
484
496
  await Promise.allSettled([...catalogStarts.values(), ...usageReads]);
485
497
  // The account changed: the next request for an account this owner used reads again.
486
498
  for (const identity of usageAccounts) planUsageReads.delete(identity);
@@ -588,7 +600,7 @@ export async function createClaudeCodeProcess({
588
600
  // execution receipt after startup instead of waiting forever for pipe EOF.
589
601
  if (native.exited) void native.exited.then(processExit => {
590
602
  if (!stopped && !stopping) stream.destroy(Object.assign(new Error("Claude exited unexpectedly."), {
591
- code: "assistant_claude_observation_lost", processExit
603
+ code: "assistant_claude_observation_lost", messageCode: "assistant.claude.exitedUnexpectedly", messageParams: {}, processExit
592
604
  }));
593
605
  }, error => {
594
606
  if (!stopped && !stopping) stream.destroy(error);
@@ -97,7 +97,7 @@ export function createClaudeProcessStart({
97
97
  if (current && frame.type === "assistant" && !frame.parent_tool_use_id) {
98
98
  for (const tool of frame.message?.content || []) if (tool.type === "tool_use" && tool.id && tool.name?.startsWith("mcp__application__")) {
99
99
  if (current.nativeToolCount >= current.maximumToolCalls && !current.nativeTools.has(tool.id)) {
100
- throw new Error("Claude exceeded the application tool-call limit.");
100
+ throw Object.assign(new Error("Claude exceeded the application tool-call limit."), { messageCode: "assistant.claude.toolCallLimit", messageParams: {} });
101
101
  }
102
102
  if (!current.nativeTools.has(tool.id)) {
103
103
  current.nativeToolCount++;
@@ -36,24 +36,21 @@ export function createClaudeSessionCleanup({
36
36
  return closing || closingSessions.has(contextKey);
37
37
  }
38
38
 
39
+ // Reconciliation retires the work an earlier server left, as a session's
40
+ // close does: it reopens only saved custody, then stops each of the
41
+ // session's entries whose execution is unproven. Idle saved conversations
42
+ // stay closed and unwritten.
39
43
  async function reconcileSessions(sessions, options = {}, application) {
40
44
  const results = [];
41
45
  for (const session of sessions) {
42
46
  const ctx = await application.readContext({ ...options, session, sessionId: session.sessionId });
43
- await recover(await restoreSessionEntries(ctx, { acquire }));
47
+ await recoverSavedCustody(ctx);
48
+ await recover([...entries.values()].filter((entry) => entry.context.key === ctx.key));
44
49
  results.push({ ok: true, sessionId: session.sessionId, resumed: false });
45
50
  }
46
51
  return { ok: true, results, failed: [], sessionCount: results.length };
47
52
  }
48
53
 
49
- async function restoreSessionEntries(context, { acquire }) {
50
- let saved = store.readSessionConversations(context);
51
- // readable-check: allow method-probe - data: a store lists a session's conversations synchronously or with a promise
52
- if (saved && typeof saved.then === "function") saved = await saved;
53
- for (const conversationId of saved.ids) await acquire(saved.context, conversationId);
54
- return [...entries.values()].filter((entry) => entry.context.key === context.key);
55
- }
56
-
57
54
  // A session's close stops what this server runs for it, then reopens only
58
55
  // saved custody an earlier server left. Once the owner is closing for an
59
56
  // account change or shutdown it reopens nothing.
@@ -233,7 +230,6 @@ export function createClaudeSessionCleanup({
233
230
  closingSessions,
234
231
  isClosing,
235
232
  reconcileSessions,
236
- restoreSessionEntries,
237
233
  closeSession,
238
234
  releaseSession,
239
235
  closeProject,
@@ -318,8 +318,8 @@ export function createClaudeConversationOwner({
318
318
  if (!saved) return {};
319
319
  try { return JSON.parse(saved); }
320
320
  catch {
321
- throw createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`,
322
- { code: "conversation_claude_saved_state_unreadable" });
321
+ throw Object.assign(createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`,
322
+ { code: "conversation_claude_saved_state_unreadable" }), { params: { conversationId } });
323
323
  }
324
324
  }
325
325
 
@@ -368,6 +368,7 @@ export function createClaudeConversationOwner({
368
368
  process: {
369
369
  stopExecution: process.stopExecution,
370
370
  ...(bound ? { releaseExecution: () => store.releaseExecution(entry) } : {}),
371
+ ownerClosing: () => cleanup.closing,
371
372
  getProcess: () => entry.stopping ? null : entry.process,
372
373
  isActive: () => isTurnActive(entry),
373
374
  startProcess: input => processStart.startProcess(entry, input),
@@ -697,7 +698,6 @@ export function createClaudeConversationOwner({
697
698
  hasActiveConversation,
698
699
  retireConversationHistory,
699
700
  reconcileSessions: cleanup.reconcileSessions,
700
- restoreSessionEntries: cleanup.restoreSessionEntries,
701
701
  closeSession: cleanup.closeSession,
702
702
  releaseSession: cleanup.releaseSession,
703
703
  closeProject: cleanup.closeProject,
@@ -16,11 +16,15 @@ export function createCodexAccountReader({
16
16
  identityNamespace = "codex-account-v1",
17
17
  secretNamespace = "codex-auth-secret-v1"
18
18
  } = {}) {
19
- function codexAppServerHelperAuthError(code = "", message = "") {
19
+ // messageCode names the sentence, since one code covers several.
20
+ function codexAppServerHelperAuthError(code = "", message = "", { messageCode } = {}) {
20
21
  const error = new Error(
21
22
  normalizeNullableText(message) || "Codex helper authentication is unavailable. Reconnect Codex and retry."
22
23
  );
23
24
  error.code = normalizeNullableText(code) || `${errorPrefix}codex_helper_auth_unavailable`;
25
+ if (!normalizeNullableText(message)) error.messageCode = "assistant.codex.helperAuthUnavailable";
26
+ else if (messageCode) error.messageCode = messageCode;
27
+ if (error.messageCode) error.messageParams = {};
24
28
  return error;
25
29
  }
26
30
 
@@ -61,7 +65,8 @@ export function createCodexAccountReader({
61
65
  if (!fileStat.isFile() || fileStat.size > CODEX_APP_SERVER_SELECTED_AUTH_MAX_BYTES) {
62
66
  throw codexAppServerHelperAuthError(
63
67
  `${errorPrefix}codex_helper_auth_invalid`,
64
- "The selected Codex authentication record is invalid. Reconnect Codex and retry."
68
+ "The selected Codex authentication record is invalid. Reconnect Codex and retry.",
69
+ { messageCode: "assistant.codex.authRecordInvalid" }
65
70
  );
66
71
  }
67
72
  const buffer = Buffer.alloc(CODEX_APP_SERVER_SELECTED_AUTH_MAX_BYTES + 1);
@@ -76,7 +81,8 @@ export function createCodexAccountReader({
76
81
  if (offset > CODEX_APP_SERVER_SELECTED_AUTH_MAX_BYTES) {
77
82
  throw codexAppServerHelperAuthError(
78
83
  `${errorPrefix}codex_helper_auth_invalid`,
79
- "The selected Codex authentication record is too large. Reconnect Codex and retry."
84
+ "The selected Codex authentication record is too large. Reconnect Codex and retry.",
85
+ { messageCode: "assistant.codex.authRecordTooLarge" }
80
86
  );
81
87
  }
82
88
  const parsed = JSON.parse(buffer.subarray(0, offset).toString("utf8"));
@@ -91,7 +97,8 @@ export function createCodexAccountReader({
91
97
  }
92
98
  throw codexAppServerHelperAuthError(
93
99
  `${errorPrefix}codex_helper_auth_unavailable`,
94
- "The selected Codex authentication record could not be read. Reconnect Codex and retry."
100
+ "The selected Codex authentication record could not be read. Reconnect Codex and retry.",
101
+ { messageCode: "assistant.codex.authRecordUnreadable" }
95
102
  );
96
103
  } finally {
97
104
  await handle?.close().catch(() => null);
@@ -103,7 +110,8 @@ export function createCodexAccountReader({
103
110
  if (!toolHomeSource || !path.isAbsolute(toolHomeSource)) {
104
111
  throw codexAppServerHelperAuthError(
105
112
  `${errorPrefix}codex_helper_auth_unavailable`,
106
- "The selected Codex account is unavailable. Reconnect Codex and retry."
113
+ "The selected Codex account is unavailable. Reconnect Codex and retry.",
114
+ { messageCode: "assistant.codex.accountUnavailable" }
107
115
  );
108
116
  }
109
117
  const auth = await readBoundedCodexAuthJson(path.join(toolHomeSource, ".codex", "auth.json"));
@@ -148,7 +156,8 @@ export function createCodexAccountReader({
148
156
  }
149
157
  throw codexAppServerHelperAuthError(
150
158
  `${errorPrefix}codex_helper_auth_invalid`,
151
- "The selected Codex authentication mode is unsupported. Reconnect Codex and retry."
159
+ "The selected Codex authentication mode is unsupported. Reconnect Codex and retry.",
160
+ { messageCode: "assistant.codex.authModeUnsupported" }
152
161
  );
153
162
  }
154
163
 
@@ -109,7 +109,7 @@ export function createCodexConversationStops({
109
109
  if (conversationState) {
110
110
  conversationState.status = "interrupted";
111
111
  if (conversationState.goal?.status === "active") conversationState.goal = { ...conversationState.goal, status: "paused" };
112
- conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
112
+ conversationState.watcher?.failNow(Object.assign(new Error("Temporary AI turn was stopped."), { messageCode: "assistant.codex.temporaryTurnStopped", messageParams: {} }));
113
113
  conversationState.watcher = null;
114
114
  holdWhileActive(conversationState);
115
115
  }
@@ -143,7 +143,7 @@ export function createCodexConversationStops({
143
143
  });
144
144
  if (result.ok !== false && conversationState) {
145
145
  conversationState.status = "interrupted";
146
- conversationState.watcher?.failNow(new Error("Temporary AI turn was stopped."));
146
+ conversationState.watcher?.failNow(Object.assign(new Error("Temporary AI turn was stopped."), { messageCode: "assistant.codex.temporaryTurnStopped", messageParams: {} }));
147
147
  conversationState.watcher = null;
148
148
  holdWhileActive(conversationState);
149
149
  }
@@ -212,7 +212,7 @@ export function createCodexConversationStops({
212
212
  if (result.ok !== false && conversation) {
213
213
  const { conversationId, sessionKey, conversations } = conversation;
214
214
  const state = conversations?.get(conversationId);
215
- state?.watcher?.failNow(new Error("Temporary AI conversation was closed."));
215
+ state?.watcher?.failNow(Object.assign(new Error("Temporary AI conversation was closed."), { messageCode: "assistant.codex.temporaryConversationClosed", messageParams: {} }));
216
216
  if (state) forgetObservation(state);
217
217
  conversations?.delete(conversationId);
218
218
  if (conversations?.size === 0) {
@@ -250,7 +250,7 @@ export function createCodexConversationCommands({
250
250
  const thread = await context.provider.readThreadStatus(conversationId);
251
251
  if (codexAppServerThreadRawValue(thread).historyMode !== "paginated") {
252
252
  throw Object.assign(new Error("This conversation uses an unsupported Codex history format. Start a fresh conversation."), {
253
- code: `${errorPrefix}codex_history_unsupported`
253
+ code: `${errorPrefix}codex_history_unsupported`, messageCode: "assistant.codex.historyFormatStartFresh", messageParams: {}
254
254
  });
255
255
  }
256
256
  const turns = [];
@@ -217,7 +217,7 @@ function createCodexAppServerDetachedTurnWatcher(provider, threadId = "", {
217
217
  rejectWaiter = reject;
218
218
  if (timeoutMs > 0) {
219
219
  timeout = setTimeout(() => {
220
- fail(new Error("Timed out waiting for Codex app-server response."));
220
+ fail(Object.assign(new Error("Timed out waiting for Codex app-server response."), { messageCode: "assistant.codex.responseTimeout", messageParams: {} }));
221
221
  }, timeoutMs);
222
222
  } else {
223
223
  // Interactive repair turns may take longer than bounded helper jobs.
@@ -226,7 +226,9 @@ function createCodexAppServerDetachedTurnWatcher(provider, threadId = "", {
226
226
  connectionCheck = setInterval(() => {
227
227
  if (provider.isAvailable() === false ||
228
228
  provider.currentConnectionGeneration() !== generation) {
229
- fail(new Error("Connection to Codex was lost while the temporary AI was working."));
229
+ fail(Object.assign(new Error("Connection to Codex was lost while the temporary AI was working."), {
230
+ messageCode: "assistant.codex.temporaryConnectionLost", messageParams: {}
231
+ }));
230
232
  }
231
233
  }, 1000);
232
234
  connectionCheck.unref();
@@ -113,7 +113,9 @@ export function createCodexGoalCommands({
113
113
  if (recovered.reason === "error") throw new Error(recovered.error);
114
114
  await context.runtime.store.mutateSession(sessionId, async () => {
115
115
  const run = await readCodexAppServerAgentRunForSession(context.runtime.store, sessionId);
116
- if (run.active || run.providerThreadId !== threadId) throw new Error("The assistant changed. Refresh before resuming.");
116
+ if (run.active || run.providerThreadId !== threadId) throw Object.assign(new Error("The assistant changed. Refresh before resuming."), {
117
+ messageCode: "assistant.codex.assistantChangedRefresh", messageParams: {}
118
+ });
117
119
  await context.runtime.store.writeAgentRunEvent(sessionId, CODEX_APP_SERVER_AGENT_RUN_ID, {
118
120
  event: { kind: "codex-observation-explicit-resume" },
119
121
  patch: { providerStatus: "interrupted", error: "" }
@@ -47,15 +47,15 @@ export async function assertCodexAppServerHelperAccountIdentity(provider, expect
47
47
  return;
48
48
  }
49
49
  if (!/^sha256:[a-f0-9]{64}$/u.test(expected) || !provider) {
50
- throw codexAppServerHelperOwnershipError(
50
+ throw Object.assign(codexAppServerHelperOwnershipError(
51
51
  "The requested low-cost assistant account identity is invalid. Refresh the task and retry."
52
- );
52
+ ), { messageCode: "assistant.codex.helperAccountIdentityInvalid", messageParams: {} });
53
53
  }
54
54
  const actual = normalizeText((await provider.currentRuntimeInfo())?.accountIdentitySignature);
55
55
  if (actual !== expected) {
56
- throw codexAppServerHelperOwnershipError(
56
+ throw Object.assign(codexAppServerHelperOwnershipError(
57
57
  "The selected Codex account changed before low-cost assistant work could run. Retry the task with the current account."
58
- );
58
+ ), { messageCode: "assistant.codex.helperAccountChangedBeforeRun", messageParams: {} });
59
59
  }
60
60
  }
61
61
 
@@ -66,10 +66,10 @@ export function createCodexHelperThreadRecovery(lifecycle, {
66
66
  const accountChanged = record.identity.runtime.accountIdentitySignature !==
67
67
  normalizeText(currentRuntime.accountIdentitySignature);
68
68
  if (accountChanged && !cleanupRequired) {
69
- throw lifecycle.ownershipError(
69
+ throw Object.assign(lifecycle.ownershipError(
70
70
  "Reconnect the original Codex account to resume or delete this helper thread.",
71
71
  { retryable: false, sessionId: record.sessionId, threadId: record.threadId }
72
- );
72
+ ), { messageCode: "assistant.codex.helperThreadReconnectAccount", messageParams: {} });
73
73
  }
74
74
  if (accountChanged || contextChanged) {
75
75
  // Context drift cannot reuse the previous provider configuration.
@@ -467,7 +467,7 @@ async function prepareCodexHistory(params, client, { baseUrl, modelProviderId, .
467
467
  if (modelProvider !== "openai") return params;
468
468
  const { account } = await client.request("account/read", { refreshToken: false }, options);
469
469
  if (!["chatgpt", "apiKey"].includes(account?.type)) {
470
- throw new Error("Reconnect the OpenAI account before using this model.");
470
+ throw Object.assign(new Error("Reconnect the OpenAI account before using this model."), { messageCode: "assistant.codex.reconnectOpenAi", messageParams: {} });
471
471
  }
472
472
  return { ...params, config: { ...params.config,
473
473
  openai_base_url: `${baseUrl}/${account.type}`
@@ -83,7 +83,7 @@ export function createCodexMessageCommands({
83
83
  if (turn.status === "observation_lost") {
84
84
  if (turn.active) {
85
85
  throw Object.assign(new Error("Codex observation was lost and a stop is not yet verified. Retry after the service has stopped."), {
86
- code: `${errorPrefix}codex_observation_lost`
86
+ code: `${errorPrefix}codex_observation_lost`, messageCode: "assistant.codex.observationLostStopUnverified", messageParams: {}
87
87
  });
88
88
  }
89
89
  const recovered = await output.submitAssistantResult(sessionId, turn.threadId, turn.turnId, { recoverFromProvider: true });
@@ -207,7 +207,9 @@ export function createCodexMessageCommands({
207
207
  workdir: source.workdir ?? workdir
208
208
  });
209
209
  if (source.requirePaginatedHistory && thread.thread.raw?.historyMode !== "paginated") {
210
- throw new Error("Codex did not create the required paginated history. Update Codex before starting this conversation.");
210
+ throw Object.assign(new Error("Codex did not create the required paginated history. Update Codex before starting this conversation."), {
211
+ messageCode: "assistant.codex.paginatedHistoryMissing", messageParams: {}
212
+ });
211
213
  }
212
214
  debugLog("appServerPrompt.stage", {
213
215
  durationMs: Date.now() - stageStartedAt, messageId, sessionId, stage: "thread"
@@ -38,7 +38,7 @@ export function createCodexAppServerModelCatalogCache({
38
38
  return async function readCodexAppServerModelCatalog(provider, { signal = null } = {}) {
39
39
  // readable-check: allow method-probe - safety invariant: a provider without live model discovery is refused with codex_model_catalog_unavailable
40
40
  if (typeof provider.listModels !== "function") {
41
- const error = new Error("Codex live model discovery is unavailable.");
41
+ const error = Object.assign(new Error("Codex live model discovery is unavailable."), { messageCode: "assistant.codex.modelDiscoveryUnavailable", messageParams: {} });
42
42
  error.code = `${errorPrefix}codex_model_catalog_unavailable`;
43
43
  throw error;
44
44
  }
@@ -38,7 +38,7 @@ export function createCodexObservationControl({
38
38
  function assertCodexAppServerThreadCanResume(run, threadId) {
39
39
  if (run?.providerThreadId === threadId && run.providerStatus === "observation_lost") {
40
40
  throw Object.assign(new Error(run.error || "Codex is stopped. Use Resume or Send to continue."), {
41
- code: `${errorPrefix}codex_observation_lost`
41
+ code: `${errorPrefix}codex_observation_lost`, messageCode: "assistant.codex.stoppedUseResume", messageParams: {}
42
42
  });
43
43
  }
44
44
  }