@jskit-ai/assistant-core 0.1.239 → 0.1.241

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 (103) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +5 -5
  3. package/src/client/conversation/AssistantTranscript.vue +26 -29
  4. package/src/client/conversation/LongTextPreviewBlocks.vue +11 -0
  5. package/src/client/conversation/messageDelivery.js +8 -2
  6. package/src/client/conversation/useAssistantAttachments.js +1 -1
  7. package/src/server/conversation/agentRun.js +12 -198
  8. package/src/server/conversation/attachments.js +6 -5
  9. package/src/server/conversation/boundedToolLoop.js +160 -0
  10. package/src/server/conversation/claudeHistory.js +20 -16
  11. package/src/server/conversation/claudeProcess.js +2 -2
  12. package/src/server/conversation/claudeTurn.js +51 -40
  13. package/src/server/conversation/codexAuthPreflight.js +104 -0
  14. package/src/server/conversation/codexClient.js +11 -16
  15. package/src/server/conversation/codexConfiguration.js +112 -98
  16. package/src/server/conversation/codexConversationStore.js +189 -0
  17. package/src/server/conversation/codexConversations.js +47 -44
  18. package/src/server/conversation/codexDetachedTurn.js +12 -11
  19. package/src/server/conversation/codexEvents.js +95 -99
  20. package/src/server/conversation/codexGoalCommands.js +1 -1
  21. package/src/server/conversation/codexHelperExecution.js +3 -13
  22. package/src/server/conversation/codexHelperThreadLedger.js +7 -13
  23. package/src/server/conversation/codexHelperThreadRecovery.js +2 -16
  24. package/src/server/conversation/codexHelperThreads.js +5 -6
  25. package/src/server/conversation/codexHistoryAdapter.js +32 -26
  26. package/src/server/conversation/codexMessageCommands.js +22 -22
  27. package/src/server/conversation/codexMessageDelivery.js +12 -7
  28. package/src/server/conversation/codexModelCatalog.js +97 -0
  29. package/src/server/conversation/codexNotificationObserver.js +24 -40
  30. package/src/server/conversation/codexNotificationQueue.js +2 -3
  31. package/src/server/conversation/codexOutput.js +20 -27
  32. package/src/server/conversation/codexProcess.js +123 -127
  33. package/src/server/conversation/codexProcessLeader.js +3 -3
  34. package/src/server/conversation/codexProvider.js +31 -614
  35. package/src/server/conversation/codexProviderOwner.js +2 -2
  36. package/src/server/conversation/codexProviderSelection.js +38 -40
  37. package/src/server/conversation/codexRenewalCommands.js +13 -10
  38. package/src/server/conversation/codexRenewalThreads.js +412 -0
  39. package/src/server/conversation/codexRunJournal.js +24 -42
  40. package/src/server/conversation/codexRuntimeLifecycle.js +19 -15
  41. package/src/server/conversation/codexSessionCleanup.js +11 -10
  42. package/src/server/conversation/codexSessionReconciliation.js +6 -6
  43. package/src/server/conversation/codexThreadReadiness.js +19 -33
  44. package/src/server/conversation/codexThreadStatus.js +3 -3
  45. package/src/server/conversation/codexTurn.js +124 -297
  46. package/src/server/conversation/codexTurnRecovery.js +49 -28
  47. package/src/server/conversation/codexTurnState.js +34 -13
  48. package/src/server/conversation/commandWrapper.js +3 -0
  49. package/src/server/conversation/deliveryTiming.js +14 -5
  50. package/src/server/conversation/index.js +1 -0
  51. package/src/server/conversation/localExecution.js +13 -17
  52. package/src/server/conversation/normalize.js +8 -3
  53. package/src/server/conversation/openCodeClient.js +9 -10
  54. package/src/server/conversation/openCodeProcess.js +9 -5
  55. package/src/server/conversation/openCodeRuntime.js +10 -11
  56. package/src/server/conversation/openCodeTurn.js +43 -29
  57. package/src/server/conversation/providers/claudeDriver.js +10 -16
  58. package/src/server/conversation/providers/codexDriver.js +27 -43
  59. package/src/server/conversation/providers/opencodeDriver.js +17 -42
  60. package/src/server/conversation/providers/scoped.js +48 -4
  61. package/src/server/conversation/runtime.js +50 -49
  62. package/src/server/conversation/runtimeStateUpgrade.js +34 -22
  63. package/src/server/conversation/transcript.js +10 -10
  64. package/src/server/hosts/codexConfiguration.js +1 -0
  65. package/src/server/hosts/codexProvider.js +8 -6
  66. package/src/server/index.js +2 -1
  67. package/src/server/lib/assistantToolLoop.js +1 -153
  68. package/src/server/lib/serviceToolCatalog.js +17 -42
  69. package/src/shared/assistantResource.js +4 -2
  70. package/src/shared/conversation/attachments.js +4 -0
  71. package/src/shared/conversation/longTextBlocks.js +54 -19
  72. package/src/shared/conversation/numberedQuestionSugar.js +17 -10
  73. package/src/shared/conversation/pagination.js +4 -6
  74. package/src/shared/conversation/patches.js +1 -4
  75. package/src/shared/conversation/streaming.js +3 -11
  76. package/src/shared/conversation/turns.js +11 -0
  77. package/src/shared/index.js +1 -0
  78. package/test/assistantBoundedToolLoop.test.js +2 -2
  79. package/test/assistantResource.test.js +4 -4
  80. package/test/assistantScroll.browser.test.js +29 -6
  81. package/test/assistantSettingsResource.test.js +2 -2
  82. package/test/claudeConversationSession.test.js +20 -3
  83. package/test/claudeTurn.test.js +27 -7
  84. package/test/codexClient.test.js +20 -0
  85. package/test/codexConversationSession.test.js +5 -3
  86. package/test/codexConversationsCheckpoint.test.js +51 -0
  87. package/test/codexModelCatalog.test.js +1 -1
  88. package/test/codexProviderHome.test.js +51 -0
  89. package/test/codexProviderSelection.test.js +71 -0
  90. package/test/codexTurn.test.js +222 -6
  91. package/test/conversationAgentRun.test.js +2 -1
  92. package/test/conversationAttachments.test.js +27 -2
  93. package/test/conversationDeliveryTiming.test.js +21 -0
  94. package/test/conversationNormalize.test.js +11 -0
  95. package/test/fixtures/nestedListQuestionMessage.md +37 -0
  96. package/test/frameworkActions.test.js +2 -2
  97. package/test/longTextBlocks.test.js +102 -0
  98. package/test/messageDelivery.test.js +7 -1
  99. package/test/openCodeClient.test.js +20 -0
  100. package/test/openCodeRuntime.test.js +1 -1
  101. package/test/openCodeTurn.test.js +90 -0
  102. package/test/opencodeConversationSession.test.js +1 -1
  103. package/test/serviceToolCatalog.test.js +27 -0
@@ -1,7 +1,9 @@
1
1
  import { setTimeout as delay } from "node:timers/promises";
2
2
  import { openCodeAssistantMessageText as assistantMessageText } from "./openCodeClient.js";
3
+ import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
4
+ import { normalizeNullableText } from "./normalize.js";
3
5
 
4
- const text = value => String(value ?? "").trim();
6
+ const text = normalizeNullableText;
5
7
  export const OPENCODE_INTERRUPT_TIMEOUT_MS = 5_000;
6
8
  const OPENCODE_EVENT_READY_TIMEOUT_MS = 120_000;
7
9
 
@@ -24,10 +26,7 @@ export function openCodeStructuredOutput(value = "") {
24
26
  return match ? match[1].trim() : original;
25
27
  }
26
28
 
27
-
28
- function record(value = null) {
29
- return value && typeof value === "object" && !Array.isArray(value) ? value : {};
30
- }
29
+ const record = value => isRecord(value) ? value : {};
31
30
 
32
31
  function openCodeEventSummary(event = {}) {
33
32
  const payload = record(event.data);
@@ -140,12 +139,11 @@ export async function inspectOpenCodeMessageAdmission(client, conversationId, in
140
139
  do {
141
140
  if (wait) signal.throwIfAborted();
142
141
  try {
143
- // Do not create a missing native session or submit another prompt. Absence
144
- // from bounded history is uncertainty, not proof of non-admission.
142
+ // Do not create a missing native session or submit another prompt.
145
143
  const readSignal = wait
146
144
  ? AbortSignal.any([signal, AbortSignal.timeout(OPENCODE_INTERRUPT_TIMEOUT_MS)])
147
145
  : signal;
148
- const messages = await client.messages(conversationId, { limit: 100, order: "desc" }, { signal: readSignal });
146
+ const messages = await readOpenCodeMessagesUntil(client, conversationId, hasMessage(inputMessageId), { signal: readSignal });
149
147
  if (wait) signal.throwIfAborted();
150
148
  const accepted = openCodeMessageRows(messages).some((message) =>
151
149
  message.type === "user" && text(message.id) === inputMessageId);
@@ -178,7 +176,9 @@ export async function steerOpenCodeTurn(client, conversationId, turn, input, { s
178
176
  }
179
177
  }
180
178
 
181
- export async function dispatchOpenCodeTurn(client, conversationId, turn, input, options = {}) {
179
+ // options.client and options.conversationId are read at each use, so callers can
180
+ // pass getters that follow a replaced server.
181
+ export async function dispatchOpenCodeTurn(turn, input, options = {}) {
182
182
  const { events, beforeDispatch, readiness, promptTimeoutMs } = options;
183
183
  turn.promptAttempted = false;
184
184
  if (events) await events.waitUntilReady(readiness);
@@ -188,20 +188,43 @@ export async function dispatchOpenCodeTurn(client, conversationId, turn, input,
188
188
  if (options.authorizeAttachments) {
189
189
  signal.throwIfAborted();
190
190
  if (input.attachments?.length) {
191
- await (options.client ?? client).allowConversationAttachments(options.conversationId ?? conversationId, input.attachments);
191
+ await options.client.allowConversationAttachments(options.conversationId, input.attachments);
192
192
  }
193
193
  signal.throwIfAborted();
194
194
  }
195
195
  turn.promptAttempted = true;
196
196
  turn.mayBeRunning = true;
197
- return (options.client ?? client).prompt(options.conversationId ?? conversationId, {
197
+ return options.client.prompt(options.conversationId, {
198
198
  ...input,
199
199
  delivery: input.delivery === "steer" ? "steer" : "queue",
200
200
  resume: true
201
201
  }, { signal: promptTimeoutMs ? AbortSignal.any([signal, AbortSignal.timeout(promptTimeoutMs)]) : signal });
202
202
  }
203
203
 
204
- // Native automatic compaction and its continuation belong to the admitted turn.
204
+ const OPENCODE_MESSAGE_PAGE_SIZE = 100;
205
+
206
+ // Native history pages run newest first. A long tool-using turn can outgrow one
207
+ // page, so continue with older pages until `found` sees the rows it needs (or
208
+ // history ends). The result keeps the client's `{ data }` message shape.
209
+ export async function readOpenCodeMessagesUntil(client, conversationId, found, { signal } = {}) {
210
+ let rows = [];
211
+ let before = "";
212
+ const cursors = new Set();
213
+ for (;;) {
214
+ const page = await client.messages(conversationId, {
215
+ limit: OPENCODE_MESSAGE_PAGE_SIZE, ...(before ? { before } : {})
216
+ }, { signal });
217
+ signal?.throwIfAborted();
218
+ rows = openCodeMessageRows([...openCodeMessageRows(page), ...rows]);
219
+ if (found(rows) || !page?.nextCursor) return { data: rows };
220
+ if (cursors.has(page.nextCursor)) throw new Error("OpenCode repeated a native history cursor.");
221
+ cursors.add(page.nextCursor);
222
+ before = page.nextCursor;
223
+ }
224
+ }
225
+
226
+ const hasMessage = id => rows => rows.some(message => text(message?.id) === text(id));
227
+
205
228
  export function openCodeMessageRows(value = null) {
206
229
  const rows = Array.isArray(value) ? value : Array.isArray(value?.data) ? value.data : [];
207
230
  return rows
@@ -258,6 +281,7 @@ export async function projectOpenCodeConversationMessages(messages, expectedId,
258
281
  return { failure: result.error, providerApiFailure: text(result.message?.error?.name) === "APIError" };
259
282
  }
260
283
 
284
+ // Native automatic compaction and its continuation belong to the admitted turn.
261
285
  export function openCodeRowsForInput(value = null, inputMessageId = "") {
262
286
  const rows = openCodeMessageRows(value);
263
287
  const index = rows.findIndex((message) => text(message?.id) === text(inputMessageId));
@@ -342,11 +366,8 @@ export async function waitForOpenCodeMessages(client, conversationId = "", input
342
366
  while (true) {
343
367
  signal?.throwIfAborted();
344
368
  const expectedInputMessageId = text(resolveInputMessageId());
345
- const messages = await client.messages(conversationId, {
346
- limit: 100,
347
- order: "desc"
348
- }, { signal });
349
- signal?.throwIfAborted();
369
+ const messages = await readOpenCodeMessagesUntil(client, conversationId,
370
+ expectedInputMessageId ? hasMessage(expectedInputMessageId) : () => true, { signal });
350
371
  if (expectedInputMessageId !== text(resolveInputMessageId())) continue;
351
372
  if (typeof onMessages === "function") {
352
373
  await onMessages(messages, expectedInputMessageId);
@@ -516,10 +537,9 @@ export async function runOpenCodeConversationTurn(client, conversationId, input
516
537
  }
517
538
  let existingMessages;
518
539
  if (requireFreshHistory) {
519
- existingMessages = openCodeMessageRows(await client.messages(
520
- conversationId,
521
- { limit: 100, order: "desc" }
522
- ));
540
+ existingMessages = openCodeMessageRows(await readOpenCodeMessagesUntil(client, conversationId, rows => rows.some(
541
+ message => message?.type === "user" && text(message.id) !== inputMessageId
542
+ )));
523
543
  const unrelatedUserMessage = existingMessages.find((message) => (
524
544
  message?.type === "user" && text(message.id) !== inputMessageId
525
545
  ));
@@ -534,10 +554,7 @@ export async function runOpenCodeConversationTurn(client, conversationId, input
534
554
  // Hosts may build the approved prompt lazily, after the original identity and
535
555
  // freshness checks. The bounded turn keeps its separate receipt-history read.
536
556
  const { prompt, agent, model, timeoutMs = 180_000 } = input;
537
- let messages = await client.messages(conversationId, {
538
- limit: 100,
539
- order: "desc"
540
- });
557
+ let messages = await readOpenCodeMessagesUntil(client, conversationId, hasMessage(inputMessageId));
541
558
  let result = openCodeMessageResultForInput(messages, inputMessageId, { readError });
542
559
  let admitted = null;
543
560
  if (!result) {
@@ -567,10 +584,7 @@ export async function runOpenCodeConversationTurn(client, conversationId, input
567
584
  if (!inputAccepted && admitted) {
568
585
  try {
569
586
  inputAccepted = Boolean(openCodeMessageResultForInput(
570
- await client.messages(conversationId, {
571
- limit: 100,
572
- order: "desc"
573
- }),
587
+ await readOpenCodeMessagesUntil(client, conversationId, hasMessage(inputMessageId)),
574
588
  inputMessageId, { readError }
575
589
  ));
576
590
  } catch {
@@ -1,4 +1,5 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
+ import { jsonHash as hash } from "../normalize.js";
2
3
  import { homedir } from "node:os";
3
4
  import path from "node:path";
4
5
  import { realpath } from "node:fs/promises";
@@ -10,8 +11,8 @@ import { createClaudeConversationOwner, claudeNativeMessageId } from "../claudeT
10
11
  import { CLAUDE_APPLICATION_TOOL_SERVER } from "../claudeTools.js";
11
12
  import { createLocalConversationExecution } from "../localExecution.js";
12
13
  import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
14
+ import { SCOPED_CONVERSATION_CAPABILITIES, scopedConversationInput, unsupportedScopedConversation } from "./scoped.js";
13
15
 
14
- const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
16
  const nativeInput = input => input.content?.length ? [...(input.text ? [{ type: "text", text: input.text }] : []),
16
17
  ...input.content.map(part => part.type === "text" ? part : { type: "image",
17
18
  source: { type: "base64", media_type: part.mediaType, data: part.image.toString("base64") } })] : input.text;
@@ -170,6 +171,8 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
170
171
  if (event.type === "message") await current.onMessage({ ...event.message, transient: !event.message.complete });
171
172
  else if (event.type === "message-complete") await current.onEvent(event);
172
173
  else if ((event.type === "admitted" && current.goal) || event.type === "settled") {
174
+ // Claude has the authored /goal command; it now owns the goal state.
175
+ if (event.type === "admitted" && current.goal.action !== "pause") await standalone.releaseRetainedGoal(entry);
173
176
  await current.onEvent({ type: "goal", goal: claudeGoalValue(await entry.goals.read(), entry.id) });
174
177
  } else if (event.type === "phase") await current.onEvent({ type: "phase", phase: ["compacting", "background"].includes(event.phase) ? event.phase : "working" });
175
178
  }
@@ -362,18 +365,9 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
362
365
  const control = { current: null, disposed: false, cleanupFailure: null };
363
366
  const commandOptions = { acquire, reportFailure, entry,
364
367
  maximumOutput, maximumToolCalls: limits.maxToolCalls ?? 32, timeoutMs: limits.admissionTimeoutMs };
365
- const scopedInput = (input = {}) => {
366
- if (input.conversationId && String(input.conversationId).trim() !== scoped.conversationId) {
367
- throw new TypeError("This operation belongs to a different scoped conversation.");
368
- }
369
- return { ...input, conversationId: scoped.conversationId };
370
- };
371
- const unsupported = () => { throw Object.assign(new Error("This original scoped conversation has no canonical message or goal receipt."), {
372
- code: "conversation_unsupported"
373
- }); };
368
+ const scopedInput = scopedConversationInput(scoped?.conversationId);
374
369
  return Object.freeze({
375
- ...(scoped ? { capabilities: Object.freeze({ streaming: false, instructions: false, configuration: false,
376
- goals: false, goalBudgets: false, goalCommands: Object.freeze({}), attachments: false, tools: false, nativeTools: false }),
370
+ ...(scoped ? { capabilities: SCOPED_CONVERSATION_CAPABILITIES,
377
371
  async sendNative({ input, context }) {
378
372
  input = scopedInput(input);
379
373
  return owner.startTurn(await acquire(context, input.conversationId, { operation: "start", input }), input);
@@ -391,7 +385,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
391
385
  publishNative: event => conversation.publish({ ...event, goalChanged: event.reason === "claude-goal" })
392
386
  } : {}),
393
387
  async readGoal({ configuration, context }) {
394
- if (scoped) return unsupported();
388
+ if (scoped) return unsupportedScopedConversation();
395
389
  if (supplied) {
396
390
  await acquire(context);
397
391
  const completeResult = supplied.completeResult?.(context);
@@ -405,7 +399,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
405
399
  return claudeGoalValue(await entry.goals.read(), entry.id);
406
400
  },
407
401
  async updateGoal({ configuration, context, input, representation, interrupt, send }) {
408
- if (scoped) return unsupported();
402
+ if (scoped) return unsupportedScopedConversation();
409
403
  if (supplied) {
410
404
  await acquire(context);
411
405
  const completeResult = supplied.completeResult?.(context);
@@ -434,7 +428,7 @@ export function createClaudeConversationDriver({ connections, host = {}, limits
434
428
  return claudeGoalValue(await entry.goals.read(), entry.id);
435
429
  },
436
430
  async inspectAdmission({ context, representation, verifyAccount = false, ...input }) {
437
- if (scoped) return unsupported();
431
+ if (scoped) return unsupportedScopedConversation();
438
432
  if (supplied) {
439
433
  await acquire(context);
440
434
  const result = await owner.inspectAdmission(await acquire(context), input);
@@ -1,8 +1,8 @@
1
- import { createHash } from "node:crypto";
1
+ import { jsonHash as hash } from "../normalize.js";
2
2
  import { homedir, tmpdir } from "node:os";
3
3
  import path from "node:path";
4
4
  import { open, realpath } from "node:fs/promises";
5
- import { createCodexAppServerRuntime } from "../codexProcess.js";
5
+ import { CODEX_APP_SERVER_DESKTOP_BUS_ENV_NAMES, codexCommandHookDefinition, createCodexAppServerRuntime } from "../codexProcess.js";
6
6
  import { CodexAppServerAgentProvider, codexLocalImageInput, ensureCodexAppServerThread, codexApplicationToolConfiguration,
7
7
  assertCodexApplicationToolSchemaIdentity, inspectCodexAppServerMessageAdmission, retireCodexConversationHistory } from "../codexProvider.js";
8
8
  import { createCodexAppServerProviderOwner } from "../codexProviderOwner.js";
@@ -11,11 +11,11 @@ import { nativeAiProvider, nativeAiModel } from "../../../shared/nativeProviders
11
11
  import { createCodexAppServerRunOwner, codexAppServerTurnState, codexAppServerFrozenTurnInterruptResponse } from "../codexTurn.js";
12
12
  import { createLocalConversationExecution } from "../localExecution.js";
13
13
  import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
14
+ import { createScopedConversationHandle } from "./scoped.js";
14
15
  import { codexCommandHookCommand } from "../commandWrapper.js";
15
16
  import { isCompletedEnvelopeTurn } from "../transcript.js";
16
17
  import { createInertCodexConversationBinding } from "../runtimeStateUpgrade.js";
17
18
 
18
- const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
19
19
  const goalValue = goal => goal ? {
20
20
  id: hash([goal.threadId, goal.createdAt, goal.objective]), objective: goal.objective, status: goal.status,
21
21
  tokenBudget: goal.tokenBudget ?? null, tokensUsed: goal.tokensUsed, timeUsedSeconds: goal.timeUsedSeconds,
@@ -237,34 +237,18 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
237
237
  const { conversationId, context: openingContext } = supplied.scoped;
238
238
  const { runOwner: owner } = supplied;
239
239
  const sessionId = conversation.sessionId;
240
- const inputFor = (input = {}) => {
241
- if (input.conversationId && String(input.conversationId).trim() !== conversationId) {
242
- throw new TypeError("This operation belongs to a different scoped conversation.");
243
- }
244
- return { ...input, conversationId };
245
- };
246
- const unsupported = () => { throw Object.assign(new Error("This original scoped conversation has no canonical message or goal receipt."), {
247
- code: "conversation_unsupported"
248
- }); };
249
- return Object.freeze({
250
- capabilities: Object.freeze({ streaming: false, instructions: false, configuration: false,
251
- goals: false, goalBudgets: false, goalCommands: Object.freeze({}), attachments: false, tools: false, nativeTools: false }),
252
- // These are the original scoped commands, including their native
253
- // start dedup, watcher/deadline and Stop/delete proof. No main journal
254
- // or additional command queue is introduced for this representation.
255
- sendNative: ({ input, context }) => owner.startConversationTurn(sessionId, inputFor(input), context),
256
- readNative: ({ input, context }) => owner.readConversation(sessionId, inputFor(input), context),
257
- waitNative: ({ input, context }) => owner.waitForConversationTurn(sessionId, inputFor(input), context),
258
- cancel: ({ input, context }) => owner.stopConversation(sessionId, inputFor(input), context),
259
- readGoal: unsupported, updateGoal: unsupported, inspectAdmission: unsupported,
260
- async dispose(options) {
261
- if (options) return owner.deleteConversation(sessionId, inputFor(options.input), options.context);
262
- // Runtime shutdown retains the parent's cleanup receipt. Explicit
263
- // scoped deletion above owns native deletion and its proof.
264
- const result = await disposeNative({ sessionId, namespace: conversation.namespace, native: supplied, options: openingContext });
265
- if (result?.ok === false) throw Object.assign(new Error(result.error || "Scoped cleanup could not be confirmed."), result);
266
- return result;
267
- }
240
+ // These are the original scoped commands, including their native
241
+ // start dedup, watcher/deadline and Stop/delete proof. No main journal
242
+ // or additional command queue is introduced for this representation.
243
+ return createScopedConversationHandle({ conversationId,
244
+ sendNative: (input, context) => owner.startConversationTurn(sessionId, input, context),
245
+ readNative: (input, context) => owner.readConversation(sessionId, input, context),
246
+ waitNative: (input, context) => owner.waitForConversationTurn(sessionId, input, context),
247
+ cancel: (input, context) => owner.stopConversation(sessionId, input, context),
248
+ deleteConversation: (input, context) => owner.deleteConversation(sessionId, input, context),
249
+ // Runtime shutdown retains the parent's cleanup receipt. Explicit
250
+ // scoped deletion above owns native deletion and its proof.
251
+ disposeOpening: () => disposeNative({ sessionId, namespace: conversation.namespace, native: supplied, options: openingContext })
268
252
  });
269
253
  }
270
254
  if (!supplied && (!binding || typeof binding.threadId !== "string" || binding.workdir !== await realpath(workdir) || binding.configRoot !== configRoot)) {
@@ -644,7 +628,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
644
628
  throw new Error("Codex did not install the host's required command wrapper.");
645
629
  }
646
630
  toolConfiguration.features.hooks = true;
647
- toolConfiguration.hooks.PreToolUse = [{ matcher: "^Bash$", hooks: [{ type: "command", command: hookCommand, timeout: 30 }] }];
631
+ toolConfiguration.hooks.PreToolUse = codexCommandHookDefinition(hookCommand);
648
632
  toolConfiguration.hooks.state[owned[0].key] = { enabled: true, trusted_hash: owned[0].currentHash };
649
633
  }
650
634
  if (nativeTools) {
@@ -653,7 +637,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
653
637
  toolConfiguration.include_permissions_instructions = true;
654
638
  toolConfiguration.tools.update_plan.enabled = true;
655
639
  toolConfiguration.shell_environment_policy.set = Object.fromEntries(Object.entries(env).filter(([name, value]) =>
656
- typeof value === "string" && !["DBUS_SESSION_BUS_ADDRESS", "DBUS_STARTER_ADDRESS", "DBUS_STARTER_BUS_TYPE"].includes(name)));
640
+ typeof value === "string" && !CODEX_APP_SERVER_DESKTOP_BUS_ENV_NAMES.has(name)));
657
641
  toolConfiguration.web_search = selected.config?.web_search || "disabled";
658
642
  }
659
643
  const params = { developerInstructions: configuration.systemPrompt,
@@ -933,7 +917,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
933
917
  createdAt: goal?.createdAt, objective: input.action === "set" ? input.objective : goal?.objective };
934
918
  }
935
919
  result = await owner.updateGoal(sessionId, command, {
936
- context: nativeContext, signal: context.signal, prepareConversation: () => goalContext(configuration, context, undefined, { prepareThread: true })
920
+ context: nativeContext, signal: context?.signal, prepareConversation: () => goalContext(configuration, context, undefined, { prepareThread: true })
937
921
  });
938
922
  if (result.ok !== false) binding.goal = result.goal || null;
939
923
  } finally { admission.release(); }
@@ -957,7 +941,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
957
941
  if ((goalValue(goal)?.id || null) !== (input.expectedGoalId || null)) throw new Error("The native goal changed. Read it again before changing it.");
958
942
  const result = await owner.updateGoal(sessionId, { ...input, threadId: binding.threadId,
959
943
  createdAt: goal?.createdAt, objective: input.action === "set" ? input.objective : goal?.objective }, {
960
- context: nativeContext, signal: context.signal,
944
+ context: nativeContext, signal: context?.signal,
961
945
  prepareConversation: () => goalContext(configuration, context, signal, { prepareThread: true, tools })
962
946
  });
963
947
  if (result.ok === false) throw new Error(result.error);
@@ -1048,6 +1032,10 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
1048
1032
  let executingProvider;
1049
1033
  const toolCalls = new Set();
1050
1034
  const toolRegistrations = new Map();
1035
+ // This run still owns the original native provider's current turn.
1036
+ const ownsTurn = (provider, threadId, turnId) => current === active && !disposed && !signal.aborted &&
1037
+ Boolean(provider) && provider === executingProvider && provider === native &&
1038
+ threadId === binding.threadId && turnId === active.turnId;
1051
1039
  function toolHandler(provider, threadId, isRegistered = () => true) {
1052
1040
  return async ({ method, params }) => {
1053
1041
  if (method !== "item/tool/call" || !tools || params?.threadId !== (threadId || binding.threadId) || params.namespace ||
@@ -1080,9 +1068,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
1080
1068
  try {
1081
1069
  result = await tools.execute({ id: params.callId, name: params.tool, arguments: JSON.stringify(params.arguments) });
1082
1070
  } catch (error) {
1083
- if (!supplied && owned && current === active && !disposed && !signal.aborted &&
1084
- provider === executingProvider && provider === native &&
1085
- params.threadId === binding.threadId && params.turnId === active.turnId) active.toolFailure ||= error;
1071
+ if (!supplied && owned && ownsTurn(provider, params.threadId, params.turnId)) active.toolFailure ||= error;
1086
1072
  throw error;
1087
1073
  }
1088
1074
  return { success: result.ok, contentItems: [{ type: "inputText", text: JSON.stringify(result) }] };
@@ -1187,9 +1173,8 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
1187
1173
  // A direct Stop checkpoint can throw before its native completion
1188
1174
  // reaches the queue. Keep the same admitted tool failure with the
1189
1175
  // original observation-stop owner until its pending recovery joins.
1190
- if (!supplied && error === active.toolFailure && current === active && !disposed && !signal.aborted &&
1191
- executingProvider && executingProvider === native && binding.threadId === failureThreadId &&
1192
- active.turnId === failureTurnId && error.code !== "codex_runtime_invalidated") {
1176
+ if (!supplied && error === active.toolFailure && ownsTurn(executingProvider, failureThreadId, failureTurnId) &&
1177
+ error.code !== "codex_runtime_invalidated") {
1193
1178
  toolFailureRecovery = executingProvider.failObservation(error);
1194
1179
  }
1195
1180
  }
@@ -1200,8 +1185,7 @@ export function createCodexConversationDriver({ connections, host = {}, limits =
1200
1185
  if (!supplied) await providerOwner.pendingRecovery(providerKey, executingProvider);
1201
1186
  if (toolFailureRecovery) {
1202
1187
  await toolFailureRecovery;
1203
- if (problem === active.toolFailureCleanup && current === active && !disposed && !signal.aborted &&
1204
- executingProvider === native && binding.threadId === failureThreadId && active.turnId === failureTurnId &&
1188
+ if (problem === active.toolFailureCleanup && ownsTurn(executingProvider, failureThreadId, failureTurnId) &&
1205
1189
  !observationError && binding.observationLoss?.stopped === true) delete problem.cleanupFailed;
1206
1190
  }
1207
1191
  } catch (error) { problem = error; }
@@ -1,4 +1,5 @@
1
- import { createHash, randomUUID } from "node:crypto";
1
+ import { randomUUID } from "node:crypto";
2
+ import { jsonHash as hash } from "../normalize.js";
2
3
  import { mkdir, realpath, rm } from "node:fs/promises";
3
4
  import path from "node:path";
4
5
  import { isDeepStrictEqual } from "node:util";
@@ -7,11 +8,11 @@ import { createOpenCodeConversationServer, openCodeConversationAgent } from "../
7
8
  import { createOpenCodeSharedRuntime, ensureOpenCodeSession, openCodeServerForDirectory } from "../openCodeRuntime.js";
8
9
  import { openCodeAssistantMessageText, retireOpenCodeConversationHistory } from "../openCodeClient.js";
9
10
  import { inspectOpenCodeMessageAdmission, observeOpenCodeEvents, openCodeRowsForInput,
10
- openCodeDetachedPrompt, openCodeStructuredOutput } from "../openCodeTurn.js";
11
+ openCodeDetachedPrompt, openCodeStructuredOutput, readOpenCodeMessagesUntil } from "../openCodeTurn.js";
11
12
  import { createOpenCodeToolBridge } from "../openCodeTools.js";
12
13
  import { validateConversationConfiguration, validateConnectionModel } from "../configuration.js";
14
+ import { createScopedConversationHandle } from "./scoped.js";
13
15
 
14
- const hash = value => createHash("sha256").update(JSON.stringify(value)).digest("hex");
15
16
  const nativeMessageId = id => `msg_jskit_${hash(id).slice(0, 40)}`;
16
17
 
17
18
  // Original standalone native-use validation and authored admission, shared by
@@ -28,7 +29,11 @@ async function executeOpenCodeApplicationTool({ active, turn, target, sessionId,
28
29
  const observed = AbortSignal.any([signal, turn.abortController.signal]);
29
30
  await turn.admission?.promise;
30
31
  observed.throwIfAborted();
31
- const messages = await target.server.client.messages(sessionId, { limit: 100 }, { signal: observed });
32
+ // Read back to an admitted input that precedes the native tool use.
33
+ const messages = await readOpenCodeMessagesUntil(target.server.client, sessionId, rows => {
34
+ const index = rows.findIndex(row => row.id === input.messageId);
35
+ return index >= 0 && rows.slice(0, index).some(row => active.messageIds.has(row.id));
36
+ }, { signal: observed });
32
37
  let message;
33
38
  let owner;
34
39
  for (const [nativeId, authored] of active.messageIds) {
@@ -159,44 +164,14 @@ export function createOpenCodeConversationDriver({ connections, host = {}, limit
159
164
  const supplied = conversation.native;
160
165
  const { owner, preparation } = supplied;
161
166
  const { conversationId, context: openingContext } = supplied.scoped;
162
- const inputFor = (input = {}) => {
163
- if (input.conversationId && String(input.conversationId).trim() !== conversationId) {
164
- throw new TypeError("This operation belongs to a different scoped conversation.");
165
- }
166
- return { ...input, conversationId };
167
- };
168
- const unsupported = () => { throw Object.assign(new Error("This original scoped conversation has no canonical message or goal receipt."), {
169
- code: "conversation_unsupported"
170
- }); };
171
- return Object.freeze({
172
- capabilities: Object.freeze({ streaming: false, instructions: false, configuration: false,
173
- goals: false, goalBudgets: false, goalCommands: Object.freeze({}), attachments: false, tools: false, nativeTools: false }),
174
- async sendNative({ input, context }) {
175
- return owner.runPreparedConversationTurn(await preparation.turn(inputFor(input), context));
176
- },
177
- async readNative({ input, context }) {
178
- const request = inputFor(input);
179
- return owner.readPreparedConversation(await preparation.existing(request, context), request);
180
- },
181
- async waitNative({ input, context }) {
182
- const request = inputFor(input);
183
- return owner.waitPreparedConversationTurn(await preparation.existing(request, context), request);
184
- },
185
- async cancel({ input, context }) {
186
- const request = inputFor(input);
187
- return owner.stopPreparedConversation(await preparation.existing(request, context), request);
188
- },
189
- readGoal: unsupported, updateGoal: unsupported, inspectAdmission: unsupported,
190
- async dispose(options) {
191
- if (options) {
192
- const request = inputFor(options.input);
193
- const prepared = await preparation.existing(request, options.context, { operation: "delete" });
194
- return owner.deletePreparedConversation(prepared, request);
195
- }
196
- const result = await disposeNative({ native: supplied, options: openingContext });
197
- if (result?.ok === false) throw Object.assign(new Error(result.error || "Scoped cleanup could not be confirmed."), result);
198
- return result;
199
- }
167
+ return createScopedConversationHandle({ conversationId,
168
+ sendNative: async (input, context) => owner.runPreparedConversationTurn(await preparation.turn(input, context)),
169
+ readNative: async (input, context) => owner.readPreparedConversation(await preparation.existing(input, context), input),
170
+ waitNative: async (input, context) => owner.waitPreparedConversationTurn(await preparation.existing(input, context), input),
171
+ cancel: async (input, context) => owner.stopPreparedConversation(await preparation.existing(input, context), input),
172
+ deleteConversation: async (input, context) => owner.deletePreparedConversation(
173
+ await preparation.existing(input, context, { operation: "delete" }), input),
174
+ disposeOpening: () => disposeNative({ native: supplied, options: openingContext })
200
175
  });
201
176
  }
202
177
  if (conversation?.native) {
@@ -1,4 +1,4 @@
1
- import { normalizeText } from "../normalize.js";
1
+ import { normalizeNullableText } from "../normalize.js";
2
2
  import { createConversationChangeover } from "../continuity.js";
3
3
 
4
4
  // The host retains profile authority and durable parent receipts. This original
@@ -7,11 +7,11 @@ export async function runScopedConversationTurn({ operations, scope = {}, input
7
7
  executionProfile: profileSnapshot } = {}) {
8
8
  const request = { ...input, ephemeral: true, message: input.prompt || input.message };
9
9
  options.signal?.throwIfAborted();
10
- let conversationId = normalizeText(input.conversationId || input.threadId);
10
+ let conversationId = normalizeNullableText(input.conversationId || input.threadId);
11
11
  if (!conversationId) {
12
12
  const created = await operations.createEphemeralConversation(scope, request, options);
13
13
  if (created?.ok === false) return created;
14
- conversationId = normalizeText(created.conversationId);
14
+ conversationId = normalizeNullableText(created.conversationId);
15
15
  if (!conversationId) throw new Error("The helper did not return its conversation identity.");
16
16
  }
17
17
  // Await ownership before starting: Stop or a failed parent write must not
@@ -20,7 +20,7 @@ export async function runScopedConversationTurn({ operations, scope = {}, input
20
20
  options.signal?.throwIfAborted();
21
21
  const started = await operations.startEphemeralConversationTurn(scope, { ...request, conversationId }, options);
22
22
  if (started?.ok === false) return { ...started, threadId: conversationId };
23
- const runId = normalizeText(started.runId);
23
+ const runId = normalizeNullableText(started.runId);
24
24
  await options.onEvent?.({ type: "turn", threadId: conversationId, turnId: runId, status: started.status });
25
25
  if (options.signal?.aborted) {
26
26
  const stopped = await operations.stopEphemeralConversation(scope, { ...request, conversationId, runId }, options);
@@ -56,6 +56,50 @@ export async function runScopedConversationTurn({ operations, scope = {}, input
56
56
  error: result.error || "The helper stopped before completing its answer." } : {}) };
57
57
  }
58
58
 
59
+ export const SCOPED_CONVERSATION_CAPABILITIES = Object.freeze({ streaming: false, instructions: false, configuration: false,
60
+ goals: false, goalBudgets: false, goalCommands: Object.freeze({}), attachments: false, tools: false, nativeTools: false });
61
+
62
+ /** Bind an operation's input to this scoped conversation, refusing another one. */
63
+ export function scopedConversationInput(conversationId) {
64
+ return (input = {}) => {
65
+ if (input.conversationId && String(input.conversationId).trim() !== conversationId) {
66
+ throw new TypeError("This operation belongs to a different scoped conversation.");
67
+ }
68
+ return { ...input, conversationId };
69
+ };
70
+ }
71
+
72
+ export function unsupportedScopedConversation() {
73
+ throw Object.assign(new Error("This original scoped conversation has no canonical message or goal receipt."), {
74
+ code: "conversation_unsupported"
75
+ });
76
+ }
77
+
78
+ /**
79
+ * Driver handle for an original scoped conversation. Each engine supplies its
80
+ * native owner calls; every call receives input bound to `conversationId`.
81
+ * `disposeOpening` releases the opening without deleting the native conversation.
82
+ */
83
+ export function createScopedConversationHandle({ conversationId, sendNative, readNative, waitNative, cancel,
84
+ deleteConversation, disposeOpening }) {
85
+ const inputFor = scopedConversationInput(conversationId);
86
+ return Object.freeze({
87
+ capabilities: SCOPED_CONVERSATION_CAPABILITIES,
88
+ sendNative: async ({ input, context }) => sendNative(inputFor(input), context),
89
+ readNative: async ({ input, context }) => readNative(inputFor(input), context),
90
+ waitNative: async ({ input, context }) => waitNative(inputFor(input), context),
91
+ cancel: async ({ input, context }) => cancel(inputFor(input), context),
92
+ readGoal: unsupportedScopedConversation, updateGoal: unsupportedScopedConversation,
93
+ inspectAdmission: unsupportedScopedConversation,
94
+ async dispose(options) {
95
+ if (options) return deleteConversation(inputFor(options.input), options.context);
96
+ const result = await disposeOpening();
97
+ if (result?.ok === false) throw Object.assign(new Error(result.error || "Scoped cleanup could not be confirmed."), result);
98
+ return result;
99
+ }
100
+ });
101
+ }
102
+
59
103
  const messageWriters = {
60
104
  assistant: "writeConversationAssistantMessage",
61
105
  commentary: "writeConversationCommentaryMessage",