@jskit-ai/assistant-core 0.2.6 → 0.2.8

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 (214) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/package.json +18 -7
  3. package/src/client/browser.js +21 -0
  4. package/src/client/conversation/AssistantAttachmentQueue.vue +25 -108
  5. package/src/client/conversation/AssistantComposerActions.vue +1 -1
  6. package/src/client/conversation/AssistantConversationElement.vue +14 -52
  7. package/src/client/conversation/AssistantGoalControl.vue +18 -12
  8. package/src/client/conversation/AssistantPromptInput.vue +6 -91
  9. package/src/client/conversation/AssistantTranscript.css +505 -0
  10. package/src/client/conversation/AssistantTranscript.vue +24 -1197
  11. package/src/client/conversation/attachmentFiles.js +158 -0
  12. package/src/client/conversation/attachmentQueueItems.js +121 -0
  13. package/src/client/conversation/attachmentUploadRows.js +265 -0
  14. package/src/client/conversation/conversationDisplay.js +54 -0
  15. package/src/client/conversation/messageDelivery.js +13 -9
  16. package/src/client/conversation/retainedConversation.js +14 -3
  17. package/src/client/conversation/submitText.js +13 -2
  18. package/src/client/conversation/transcriptEntries.js +225 -0
  19. package/src/client/conversation/useAssistantAttachments.js +86 -388
  20. package/src/client/conversation/useAssistantQuestions.js +16 -3
  21. package/src/client/conversation/useAssistantSuggestions.js +16 -2
  22. package/src/client/conversation/useScrollToBottom.js +17 -19
  23. package/src/client/conversation/useTextareaAutoGrow.js +115 -0
  24. package/src/client/conversation/useTranscriptScrollFollow.js +525 -0
  25. package/src/client/lib/assistantApi.js +192 -161
  26. package/src/client/types.js +110 -0
  27. package/src/server/conversation/attachments.js +21 -6
  28. package/src/server/conversation/boundedToolLoop.js +1 -0
  29. package/src/server/conversation/claudeClient.js +71 -20
  30. package/src/server/conversation/claudeCommandRun.js +191 -0
  31. package/src/server/conversation/claudeHistory.js +7 -0
  32. package/src/server/conversation/claudeNativeTurn.js +708 -0
  33. package/src/server/conversation/claudeProcess.js +186 -112
  34. package/src/server/conversation/claudeProcessStart.js +214 -0
  35. package/src/server/conversation/claudePromptDelivery.js +183 -0
  36. package/src/server/conversation/claudeSessionCleanup.js +248 -0
  37. package/src/server/conversation/claudeTools.js +15 -3
  38. package/src/server/conversation/claudeTurn.js +212 -1388
  39. package/src/server/conversation/codexAccountReader.js +168 -0
  40. package/src/server/conversation/codexAuthPreflight.js +5 -4
  41. package/src/server/conversation/codexClient.js +18 -6
  42. package/src/server/conversation/codexConfiguration.js +8 -844
  43. package/src/server/conversation/codexConversationStops.js +377 -0
  44. package/src/server/conversation/codexConversationStore.js +119 -85
  45. package/src/server/conversation/codexConversationTurns.js +378 -0
  46. package/src/server/conversation/codexConversations.js +134 -866
  47. package/src/server/conversation/codexDetachedConversation.js +203 -0
  48. package/src/server/conversation/codexDetachedTurn.js +145 -104
  49. package/src/server/conversation/codexEvents.js +5 -200
  50. package/src/server/conversation/codexGoalCommands.js +14 -4
  51. package/src/server/conversation/codexHelperExecution.js +57 -57
  52. package/src/server/conversation/codexHelperThreadLedger.js +30 -288
  53. package/src/server/conversation/codexHelperThreadRecord.js +304 -0
  54. package/src/server/conversation/codexHelperThreadRecovery.js +54 -76
  55. package/src/server/conversation/codexHelperThreads.js +94 -93
  56. package/src/server/conversation/codexHistoryAdapter.js +13 -8
  57. package/src/server/conversation/codexIsolation.js +672 -0
  58. package/src/server/conversation/codexLiveItems.js +379 -0
  59. package/src/server/conversation/codexMessageCommands.js +68 -286
  60. package/src/server/conversation/codexMessageDelivery.js +46 -37
  61. package/src/server/conversation/codexModelCatalog.js +86 -7
  62. package/src/server/conversation/codexNotificationObserver.js +81 -88
  63. package/src/server/conversation/codexNotificationQueue.js +21 -10
  64. package/src/server/conversation/codexObservationControl.js +105 -89
  65. package/src/server/conversation/codexOutput.js +84 -419
  66. package/src/server/conversation/codexProcess.js +29 -1053
  67. package/src/server/conversation/codexProcessLeader.js +1 -0
  68. package/src/server/conversation/codexPromptDispatch.js +241 -0
  69. package/src/server/conversation/codexProvider.js +147 -1807
  70. package/src/server/conversation/codexProviderAuth.js +348 -0
  71. package/src/server/conversation/codexProviderInvalidation.js +371 -0
  72. package/src/server/conversation/codexProviderInventories.js +437 -0
  73. package/src/server/conversation/codexProviderOwner.js +149 -957
  74. package/src/server/conversation/codexProviderRelease.js +465 -0
  75. package/src/server/conversation/codexProviderSelection.js +26 -15
  76. package/src/server/conversation/codexProviderThreads.js +698 -0
  77. package/src/server/conversation/codexProviderTurns.js +453 -0
  78. package/src/server/conversation/codexReasoning.js +32 -23
  79. package/src/server/conversation/codexRenewalCommands.js +18 -6
  80. package/src/server/conversation/codexRenewalThreads.js +7 -22
  81. package/src/server/conversation/codexResultSettlement.js +70 -88
  82. package/src/server/conversation/codexRunJournal.js +91 -97
  83. package/src/server/conversation/codexRuntime.js +426 -0
  84. package/src/server/conversation/codexRuntimeInventory.js +113 -0
  85. package/src/server/conversation/codexRuntimeLifecycle.js +39 -13
  86. package/src/server/conversation/codexRuntimeStart.js +431 -0
  87. package/src/server/conversation/codexRuntimeStop.js +324 -0
  88. package/src/server/conversation/codexSessionCleanup.js +25 -10
  89. package/src/server/conversation/codexSessionReconciliation.js +21 -7
  90. package/src/server/conversation/codexThreadReadiness.js +49 -52
  91. package/src/server/conversation/codexThreadSnapshot.js +210 -0
  92. package/src/server/conversation/codexThreadStatus.js +48 -62
  93. package/src/server/conversation/codexTurn.js +171 -55
  94. package/src/server/conversation/codexTurnActivity.js +210 -0
  95. package/src/server/conversation/codexTurnControl.js +44 -48
  96. package/src/server/conversation/codexTurnEndings.js +610 -0
  97. package/src/server/conversation/codexTurnRecovery.js +97 -848
  98. package/src/server/conversation/codexTurnState.js +138 -111
  99. package/src/server/conversation/completedResponses.js +227 -0
  100. package/src/server/conversation/configuration.js +2 -2
  101. package/src/server/conversation/continuity.js +43 -6
  102. package/src/server/conversation/conversationDelivery.js +394 -0
  103. package/src/server/conversation/conversationEntries.js +365 -0
  104. package/src/server/conversation/conversationEvents.js +192 -0
  105. package/src/server/conversation/conversationGoals.js +187 -0
  106. package/src/server/conversation/conversationHandles.js +237 -0
  107. package/src/server/conversation/conversationReads.js +206 -0
  108. package/src/server/conversation/conversationRefusals.js +58 -0
  109. package/src/server/conversation/conversationReplacement.js +254 -0
  110. package/src/server/conversation/conversationSending.js +273 -0
  111. package/src/server/conversation/conversationStopping.js +121 -0
  112. package/src/server/conversation/conversationTurnDelivery.js +413 -0
  113. package/src/server/conversation/conversationTurns.js +511 -0
  114. package/src/server/conversation/deliveryTiming.js +25 -3
  115. package/src/server/conversation/fileStorage.js +11 -6
  116. package/src/server/conversation/hookBridge.js +14 -3
  117. package/src/server/conversation/index.js +1 -1
  118. package/src/server/conversation/localExecution.js +82 -625
  119. package/src/server/conversation/nativeCommandCallbacks.js +10 -0
  120. package/src/server/conversation/nativeConversationLifecycle.js +379 -0
  121. package/src/server/conversation/nativeHistoryExport.js +27 -18
  122. package/src/server/conversation/normalize.js +9 -0
  123. package/src/server/conversation/openCodeClient.js +295 -366
  124. package/src/server/conversation/openCodeNativeHistory.js +141 -0
  125. package/src/server/conversation/openCodeNativeSession.js +79 -0
  126. package/src/server/conversation/openCodeProcess.js +109 -53
  127. package/src/server/conversation/openCodeProcessOwner.js +565 -0
  128. package/src/server/conversation/openCodeRuntime.js +111 -1814
  129. package/src/server/conversation/openCodeSessionTurns.js +547 -0
  130. package/src/server/conversation/openCodeTemporaryConversations.js +394 -0
  131. package/src/server/conversation/openCodeTools.js +28 -14
  132. package/src/server/conversation/openCodeTurn.js +80 -74
  133. package/src/server/conversation/openCodeTurnMonitor.js +388 -0
  134. package/src/server/conversation/processIdentity.js +582 -0
  135. package/src/server/conversation/provider.js +21 -6
  136. package/src/server/conversation/providers/api.js +268 -107
  137. package/src/server/conversation/providers/claude.js +29 -6
  138. package/src/server/conversation/providers/claudeDriver.js +325 -250
  139. package/src/server/conversation/providers/codex.js +69 -33
  140. package/src/server/conversation/providers/codexDriver.js +503 -1081
  141. package/src/server/conversation/providers/codexDriverConnection.js +331 -0
  142. package/src/server/conversation/providers/codexDriverTurns.js +464 -0
  143. package/src/server/conversation/providers/opencode.js +30 -4
  144. package/src/server/conversation/providers/opencodeDriver.js +321 -659
  145. package/src/server/conversation/providers/opencodeStandalone.js +475 -0
  146. package/src/server/conversation/providers/scoped.js +64 -19
  147. package/src/server/conversation/runtime.js +171 -2863
  148. package/src/server/conversation/storage.js +91 -50
  149. package/src/server/conversation/streams.js +15 -2
  150. package/src/server/conversation/structuredOutput.js +7 -11
  151. package/src/server/conversation/systemPrompt.js +13 -1
  152. package/src/server/conversation/tools.js +47 -15
  153. package/src/server/conversation/transcript.js +49 -20
  154. package/src/server/hosts/claudeTurn.js +2 -4
  155. package/src/server/hosts/codexConfiguration.js +6 -8
  156. package/src/server/hosts/codexEvents.js +4 -2
  157. package/src/server/hosts/codexProcess.js +1 -1
  158. package/src/server/hosts/codexProvider.js +11 -9
  159. package/src/server/hosts/codexTurn.js +3 -3
  160. package/src/server/hosts/openCodeClient.js +4 -2
  161. package/src/server/index.js +0 -2
  162. package/src/server/lib/aiConnectionClient.js +35 -25
  163. package/src/server/lib/ndjson.js +1 -0
  164. package/src/server/lib/providers/anthropicClient.js +15 -6
  165. package/src/server/lib/providers/common.js +10 -6
  166. package/src/server/lib/providers/openAiCompatibleClient.js +40 -23
  167. package/src/server/lib/serviceToolCatalog.js +29 -283
  168. package/src/server/lib/serviceToolEntries.js +270 -0
  169. package/src/server/types.js +78 -0
  170. package/src/shared/assistantResource.js +0 -23
  171. package/src/shared/conversation/answerChoiceSugar.js +2 -14
  172. package/src/shared/conversation/messageInputSugar.js +18 -0
  173. package/src/shared/conversation/numberedQuestionSugar.js +6 -16
  174. package/src/shared/conversation/pagination.js +1 -1
  175. package/src/shared/conversation/questions.js +1 -0
  176. package/src/shared/conversation/streaming.js +1 -0
  177. package/src/shared/index.js +0 -1
  178. package/src/testing/conversationStorageContract.js +20 -0
  179. package/test/aiConnectionClient.test.js +42 -1
  180. package/test/apiConversationDriver.test.js +217 -38
  181. package/test/assistantBoundedToolLoop.test.js +1 -1
  182. package/test/claudeConversationSession.test.js +15 -11
  183. package/test/claudeInstructionResume.test.js +1 -1
  184. package/test/claudeProcess.test.js +9 -9
  185. package/test/claudeTurn.test.js +21 -8
  186. package/test/codexAccountChange.test.js +5 -3
  187. package/test/codexConversationSession.test.js +22 -21
  188. package/test/codexEvents.test.js +3 -1
  189. package/test/codexHelperDeletion.test.js +3 -7
  190. package/test/codexIsolation.test.js +6 -6
  191. package/test/codexModelCatalog.test.js +6 -7
  192. package/test/codexProcess.test.js +3 -3
  193. package/test/codexThreadSelection.test.js +4 -1
  194. package/test/codexTurn.test.js +29 -20
  195. package/test/conversationApiFacilities.test.js +11 -9
  196. package/test/conversationAttachments.test.js +3 -3
  197. package/test/conversationConfiguration.test.js +4 -4
  198. package/test/conversationNativeStorage.test.js +11 -10
  199. package/test/conversationReplacement.test.js +13 -13
  200. package/test/conversationRuntime.test.js +9 -5
  201. package/test/conversationSession.test.js +78 -22
  202. package/test/conversationTools.test.js +47 -26
  203. package/test/fileConversationStorage.test.js +10 -3
  204. package/test/fixtures/codexHelperThreadLedger.js +3 -3
  205. package/test/frameworkActions.test.js +33 -18
  206. package/test/localConversationExecution.test.js +4 -4
  207. package/test/localExecution.test.js +1 -1
  208. package/test/openCodeConnectionChange.test.js +3 -2
  209. package/test/openCodeRuntime.test.js +8 -7
  210. package/test/openCodeTools.test.js +1 -1
  211. package/test/opencodeConversationSession.test.js +4 -3
  212. package/test/serviceToolCatalog.test.js +3 -8
  213. package/src/server/lib/assistantCompletion.js +0 -190
  214. package/src/server/lib/assistantToolLoop.js +0 -259
@@ -13,15 +13,31 @@ const statusReads = new Map();
13
13
  const STATUS_RETRY_MS = 30_000;
14
14
  const CLAUDE_EXIT_WAIT_MS = 5_000;
15
15
 
16
+ /** A Claude process whose caller records no execution identity. */
17
+ function ignoreProcessStart() {}
18
+
19
+ /** A Claude process whose caller keeps no state after its connection fails. */
20
+ function ignoreProcessFailure() {}
21
+
22
+ /** Account queries outside a conversation owner borrow no running process. */
23
+ function noOwnedProcesses() {
24
+ return [];
25
+ }
26
+
27
+ /** Account queries outside a conversation owner never close for reconnection. */
28
+ function neverClosing() {
29
+ return false;
30
+ }
31
+
16
32
  /** Person-facing cause for a Claude process that ended without being stopped. */
17
33
  export function claudeUnexpectedExitMessage(processExit = {}) {
18
34
  const resume = "The conversation so far is saved; send a message to continue.";
19
- if (Number(processExit?.taskLimit?.count) > 0) {
35
+ if (Number(processExit.taskLimit?.count) > 0) {
20
36
  return "Claude stopped unexpectedly: the server refused to create more processes or threads for it " +
21
37
  `(process/thread limit reached). ${resume}`;
22
38
  }
23
- if (processExit?.signal) return `Claude stopped unexpectedly: it was terminated by ${processExit.signal}. ${resume}`;
24
- if (Number.isInteger(processExit?.code)) {
39
+ if (processExit.signal) return `Claude stopped unexpectedly: it was terminated by ${processExit.signal}. ${resume}`;
40
+ if (Number.isInteger(processExit.code)) {
25
41
  return `Claude stopped unexpectedly with exit code ${processExit.code}. ${resume}`;
26
42
  }
27
43
  return `Claude stopped unexpectedly. ${resume}`;
@@ -38,12 +54,22 @@ async function fileRevision(file) {
38
54
  }
39
55
  }
40
56
 
41
- export async function readClaudeCodeAuthStatus({
42
- env = process.env, credentialHome = { home: env.HOME || homedir() }, command = "claude", commandRunner, signal
43
- } = {}) {
44
- if (credentialHome?.ok === false) return { loggedIn: false, error: credentialHome.error };
57
+ /**
58
+ * The CLI's own account status, cached until its credentials change.
59
+ * @param {object} options
60
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.env
61
+ * @param {{ home: string, ok?: boolean, error?: string }} [options.credentialHome] The account's home; the environment's HOME when not given.
62
+ * @param {string} [options.command]
63
+ * @param {(request: Record<string, any>) => Promise<Record<string, any>>} options.commandRunner
64
+ * @param {AbortSignal} [options.signal]
65
+ */
66
+ export async function readClaudeCodeAuthStatus({ env, credentialHome, command = "claude", commandRunner, signal }) {
67
+ if (!env) throw new TypeError("readClaudeCodeAuthStatus requires the application's environment, such as process.env.");
68
+ const accountHome = credentialHome ?? { home: env.HOME || homedir() };
69
+ if (accountHome.ok === false) return { loggedIn: false, error: accountHome.error };
70
+ // readable-check: allow method-probe - input validation: the capture runner a host hands the account status read
45
71
  if (typeof commandRunner !== "function") throw new TypeError("Claude account status requires its host capture runner.");
46
- const configRoot = env.CLAUDE_CONFIG_DIR || path.join(credentialHome.home, ".claude");
72
+ const configRoot = env.CLAUDE_CONFIG_DIR || path.join(accountHome.home, ".claude");
47
73
  // Inspect file metadata only. Claude still owns reading and validating credentials.
48
74
  // Sign-in writes .credentials.json and sign-out removes it. Claude Code also
49
75
  // rewrites .claude.json during turns and after every status read, so that
@@ -51,13 +77,13 @@ export async function readClaudeCodeAuthStatus({
51
77
  const revision = await fileRevision(path.join(configRoot, ".credentials.json"));
52
78
  // One entry per account, whichever command path or host runner reads it: only
53
79
  // the account's location and its identity variables count.
54
- const key = jsonHash([configRoot, credentialHome.home, Object.entries(env).filter(([name, value]) =>
80
+ const key = jsonHash([configRoot, accountHome.home, Object.entries(env).filter(([name, value]) =>
55
81
  value && (name === "CLAUDE_CODE_OAUTH_TOKEN" || name.startsWith("ANTHROPIC_"))).sort()]);
56
82
  const previous = statusReads.get(key);
57
83
  if (previous?.revision === revision && (previous.pending || previous.expiresAt > Date.now())) return previous.value;
58
84
  const entry = { revision, pending: true, expiresAt: 0 };
59
85
  statusReads.set(key, entry);
60
- entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then(({ status, unfinished }) => {
86
+ entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome: accountHome, command, commandRunner, signal }).then(({ status, unfinished }) => {
61
87
  // Keychain changes cannot be observed with file metadata on macOS.
62
88
  if (process.platform !== "darwin") {
63
89
  // Claude's answer holds until its credentials change. A read the host timed
@@ -99,7 +125,7 @@ function claudeCodeAuthStatus(result) {
99
125
  }
100
126
  }
101
127
 
102
- /** Exact existing native connection pin, shared with stopped metadata conversion. */
128
+ /** A native connection's exact pin, shared with stopped metadata conversion. */
103
129
  export function claudeConnectionIdentity(configRoot, providerId, baseUrl, apiKey) {
104
130
  return jsonHash([configRoot, providerId, baseUrl, apiKey]);
105
131
  }
@@ -142,6 +168,7 @@ export async function verifyClaudeProviderKey(provider, apiKey, fetchImpl) {
142
168
  throw new Error(`${provider.label} could not be reached through Claude Code. Your previous connection is unchanged; try again.`);
143
169
  }
144
170
  if (!response.ok) {
171
+ // readable-check: allow optional-call - platform object: another fetch implementation's Response may lack a readable body
145
172
  await response.body?.cancel?.();
146
173
  throw new Error(response.status === 401 || response.status === 403
147
174
  ? `${provider.label} rejected this key. Check the key and the required account plan.`
@@ -287,8 +314,20 @@ export function claudeCatalogueModels(initialization) {
287
314
  * usage is shared by `accountIdentity` with every other owner in the process, so
288
315
  * that identity names exactly one Claude account. `configRoot` is the account's
289
316
  * Claude configuration directory, whose credentials the model list follows.
317
+ * @param {object} options
318
+ * @param {(request: { signal: AbortSignal, purpose: string }) => Promise<ClaudeCodeProcess>} options.createProcess
319
+ * @param {(context: Record<string, any>) => Promise<string>} options.accountIdentity
320
+ * @param {string} options.configRoot
321
+ * @param {() => Iterable<{ process: ClaudeCodeProcess | null, stopping?: unknown, accountIdentity: string }>} [options.processes]
322
+ * @param {() => boolean} [options.isClosing]
290
323
  */
291
- export function createClaudeAccountQueries({ createProcess, accountIdentity, configRoot, processes = () => [], isClosing = () => false }) {
324
+ export function createClaudeAccountQueries({
325
+ createProcess,
326
+ accountIdentity,
327
+ configRoot,
328
+ processes = noOwnedProcesses,
329
+ isClosing = neverClosing
330
+ }) {
292
331
  const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
293
332
  const accountProcessStops = new Set();
294
333
  const accountReads = new Set();
@@ -349,112 +388,141 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, con
349
388
  fileRevision(command)]));
350
389
  }
351
390
 
352
- return Object.freeze({
353
- async readCatalogue(context) {
354
- const expectedRevision = revision;
355
- assertCurrent(expectedRevision);
356
- const identity = await accountIdentity(context);
357
- assertCurrent(expectedRevision);
358
- // An answer is kept until its account, credentials or executable change, so
359
- // reading it again starts no Claude.
360
- const kept = catalog?.identity === identity ? catalog : null;
361
- if (kept && kept.revision === await catalogueRevision(kept.command)) {
362
- assertCurrent(expectedRevision);
363
- return kept.value;
364
- }
365
- if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
366
- // A conversation's process answers with what it knew when it started, so
367
- // the list comes from a Claude started for this read.
368
- const { value, command } = await readAccountProcess(identity, expectedRevision, "catalogue",
369
- async (native) => ({ value: await native.initialization, command: native.command }));
370
- const answered = await catalogueRevision(command);
371
- if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
372
- assertCurrent(expectedRevision);
373
- catalog = { identity, command, revision: answered, value };
374
- return value;
375
- })().finally(() => { catalogStarts.delete(identity); }));
376
- return catalogStarts.get(identity);
377
- },
378
- async readPlanUsage(context) {
379
- const expectedRevision = revision;
391
+ async function readCatalogue(context) {
392
+ const expectedRevision = revision;
393
+ assertCurrent(expectedRevision);
394
+ const identity = await accountIdentity(context);
395
+ assertCurrent(expectedRevision);
396
+ // An answer is kept until its account, credentials or executable change, so
397
+ // reading it again starts no Claude.
398
+ const kept = catalog?.identity === identity ? catalog : null;
399
+ if (kept && kept.revision === await catalogueRevision(kept.command)) {
380
400
  assertCurrent(expectedRevision);
381
- const identity = await accountIdentity(context);
401
+ return kept.value;
402
+ }
403
+ if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
404
+ // A conversation's process answers with what it knew when it started, so
405
+ // the list comes from a Claude started for this read.
406
+ const { value, command } = await readAccountProcess(identity, expectedRevision, "catalogue",
407
+ async (native) => ({ value: await native.initialization, command: native.command }));
408
+ const answered = await catalogueRevision(command);
409
+ if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
382
410
  assertCurrent(expectedRevision);
383
- // One reading per account, whichever owner asks; a started process is stopped after its read.
384
- let usage = planUsageReads.get(identity);
385
- if (!usage) {
386
- usage = { liveReadAt: -Infinity, temporaryReadAt: -Infinity, reading: null, pending: null };
387
- planUsageReads.set(identity, usage);
388
- }
389
- usageAccounts.add(identity);
390
- if (usage.pending) return usage.pending;
391
- const live = Boolean(ownedProcess(identity));
392
- const liveDue = live && Date.now() - usage.liveReadAt >= PLAN_USAGE_LIVE_READ_MS;
393
- // A one-shot Claude starts only when neither a verified reading nor a one-shot
394
- // read is from the last idle interval.
395
- const temporaryDue = () => Date.now() - Math.max(usage.temporaryReadAt, usage.reading?.checkedAt ?? -Infinity) >=
396
- PLAN_USAGE_IDLE_READ_MS;
397
- if (!liveDue && (live || !temporaryDue())) return presentPlanUsage(usage.reading);
398
- const pending = (async () => {
399
- // A live process's held endpoint read is its own, so a fresh one-shot Claude
400
- // may still reach the endpoint when the live answer cannot prove it is current.
401
- for (const borrow of liveDue ? [true, false] : [false]) {
402
- if (!borrow && !temporaryDue()) break;
403
- usage[borrow ? "liveReadAt" : "temporaryReadAt"] = Date.now();
404
- let value;
405
- try {
406
- value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
407
- { subtype: "get_usage", skip_behaviors: true }), { borrow });
408
- } catch (cause) {
409
- if (borrow || !liveDue) throw cause;
410
- assertCurrent(expectedRevision);
411
- break;
412
- }
413
- if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
411
+ catalog = { identity, command, revision: answered, value };
412
+ return value;
413
+ })().finally(() => { catalogStarts.delete(identity); }));
414
+ return catalogStarts.get(identity);
415
+ }
416
+
417
+ async function readPlanUsage(context) {
418
+ const expectedRevision = revision;
419
+ assertCurrent(expectedRevision);
420
+ const identity = await accountIdentity(context);
421
+ assertCurrent(expectedRevision);
422
+ // One reading per account, whichever owner asks; a started process is stopped after its read.
423
+ let usage = planUsageReads.get(identity);
424
+ if (!usage) {
425
+ usage = { liveReadAt: -Infinity, temporaryReadAt: -Infinity, reading: null, pending: null };
426
+ planUsageReads.set(identity, usage);
427
+ }
428
+ usageAccounts.add(identity);
429
+ if (usage.pending) return usage.pending;
430
+ const live = Boolean(ownedProcess(identity));
431
+ const liveDue = live && Date.now() - usage.liveReadAt >= PLAN_USAGE_LIVE_READ_MS;
432
+ // A one-shot Claude starts only when neither a verified reading nor a one-shot
433
+ // read is from the last idle interval.
434
+ const temporaryDue = () => Date.now() - Math.max(usage.temporaryReadAt, usage.reading?.checkedAt ?? -Infinity) >=
435
+ PLAN_USAGE_IDLE_READ_MS;
436
+ if (!liveDue && (live || !temporaryDue())) return presentPlanUsage(usage.reading);
437
+ const pending = (async () => {
438
+ // A live process's held endpoint read is its own, so a fresh one-shot Claude
439
+ // may still reach the endpoint when the live answer cannot prove it is current.
440
+ for (const borrow of liveDue ? [true, false] : [false]) {
441
+ if (!borrow && !temporaryDue()) break;
442
+ usage[borrow ? "liveReadAt" : "temporaryReadAt"] = Date.now();
443
+ let value;
444
+ try {
445
+ value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
446
+ { subtype: "get_usage", skip_behaviors: true }), { borrow });
447
+ } catch (cause) {
448
+ if (borrow || !liveDue) throw cause;
414
449
  assertCurrent(expectedRevision);
415
- // An answer that cannot prove it is current keeps the last verified reading.
416
- if (claudePlanUsageVerified(value)) {
417
- usage.reading = { ...claudePlanUsage(value), identity };
418
- break;
419
- }
450
+ break;
451
+ }
452
+ if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
453
+ assertCurrent(expectedRevision);
454
+ // An answer that cannot prove it is current keeps the last verified reading.
455
+ if (claudePlanUsageVerified(value)) {
456
+ usage.reading = { ...claudePlanUsage(value), identity };
457
+ break;
420
458
  }
421
- return presentPlanUsage(usage.reading);
422
- })().finally(() => {
423
- usageReads.delete(pending);
424
- if (usage.pending === pending) usage.pending = null;
425
- });
426
- // Requests that arrive while the account is read share that read.
427
- usage.pending = pending;
428
- usageReads.add(pending);
429
- return pending;
430
- },
431
- // A native usage change lets the next request read again without hiding the last reading.
432
- invalidateUsage() {
433
- for (const identity of usageAccounts) {
434
- const usage = planUsageReads.get(identity);
435
- if (usage) usage.liveReadAt = -Infinity;
436
459
  }
437
- },
438
- async invalidate() {
439
- revision++;
440
- // Dropped at once: a read started before this cannot store its answer.
441
- catalog = null;
442
- for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
443
- await Promise.allSettled([...catalogStarts.values(), ...usageReads]);
444
- // The account changed: the next request for an account this owner used reads again.
445
- for (const identity of usageAccounts) planUsageReads.delete(identity);
446
- usageAccounts.clear();
447
- for (const native of accountProcessStops) await stopAccountProcess(native);
460
+ return presentPlanUsage(usage.reading);
461
+ })().finally(() => {
462
+ usageReads.delete(pending);
463
+ if (usage.pending === pending) usage.pending = null;
464
+ });
465
+ // Requests that arrive while the account is read share that read.
466
+ usage.pending = pending;
467
+ usageReads.add(pending);
468
+ return pending;
469
+ }
470
+
471
+ /** A native usage change lets the next request read again without hiding the last reading. */
472
+ function invalidateUsage() {
473
+ for (const identity of usageAccounts) {
474
+ const usage = planUsageReads.get(identity);
475
+ if (usage) usage.liveReadAt = -Infinity;
448
476
  }
477
+ }
478
+
479
+ async function invalidate() {
480
+ revision++;
481
+ // Dropped at once: a read started before this cannot store its answer.
482
+ catalog = null;
483
+ for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
484
+ await Promise.allSettled([...catalogStarts.values(), ...usageReads]);
485
+ // The account changed: the next request for an account this owner used reads again.
486
+ for (const identity of usageAccounts) planUsageReads.delete(identity);
487
+ usageAccounts.clear();
488
+ for (const native of accountProcessStops) await stopAccountProcess(native);
489
+ }
490
+
491
+ return Object.freeze({
492
+ readCatalogue,
493
+ readPlanUsage,
494
+ invalidateUsage,
495
+ invalidate
449
496
  });
450
497
  }
451
498
 
452
- /** Native startup and handshake; an execution host owns processes and their identity. */
499
+ /**
500
+ * @typedef {Awaited<ReturnType<typeof createClaudeCodeProcess>>} ClaudeCodeProcess
501
+ */
502
+
503
+ /**
504
+ * Native startup and handshake; an execution host owns processes and their identity.
505
+ * @param {object} options
506
+ * @param {string} [options.command]
507
+ * @param {import("./localExecution.js").ConversationExecution} [options.execution]
508
+ * @param {import("@jskit-ai/kernel/server/types").RuntimeEnv} options.env
509
+ * @param {string} [options.workdir]
510
+ * @param {(frame: Record<string, any>) => Promise<unknown>} [options.onEvent]
511
+ * @param {(request: Record<string, any>, context: { signal: AbortSignal }) => Promise<unknown>} [options.onControlRequest]
512
+ * @param {(error: Error) => unknown} [options.onFailure]
513
+ * @param {(executionId: string, stop: () => Promise<Record<string, any>>) => unknown} [options.onStarted]
514
+ * @param {object} [options.executionLimits]
515
+ * @param {AbortSignal} [options.signal]
516
+ * @param {Record<string, any>} [options.commandWrapper]
517
+ * @param {number} [options.initializationTimeoutMs]
518
+ * @param {(stage: string) => void} [options.onInitializationProgress]
519
+ */
453
520
  export async function createClaudeCodeProcess({
454
- command = "claude", execution = createLocalConversationExecution(), env = process.env,
455
- workdir, onEvent, onControlRequest, onFailure, onStarted, executionLimits, signal, commandWrapper,
456
- initializationTimeoutMs = 30_000, onInitializationProgress, ...options
457
- } = {}) {
521
+ command = "claude", execution = createLocalConversationExecution(), env,
522
+ workdir, onEvent, onControlRequest, onFailure = ignoreProcessFailure, onStarted = ignoreProcessStart, executionLimits, signal,
523
+ commandWrapper, initializationTimeoutMs = 30_000, onInitializationProgress, ...options
524
+ }) {
525
+ if (!env) throw new TypeError("createClaudeCodeProcess requires the application's environment, such as process.env.");
458
526
  if (commandWrapper !== undefined) validateCommandWrapper(commandWrapper);
459
527
  const environment = { ...env, DISABLE_AUTOUPDATER: "1" };
460
528
  for (const name of ["DBUS_SESSION_BUS_ADDRESS", "DBUS_STARTER_ADDRESS", "DBUS_STARTER_BUS_TYPE",
@@ -479,7 +547,7 @@ export async function createClaudeCodeProcess({
479
547
  signal?.throwIfAborted();
480
548
  native = await execution.start({ command, args: claudeCodeArguments(options), cwd: workdir,
481
549
  env: environment, stream: true, limits: executionLimits });
482
- await onStarted?.(native.id, stop);
550
+ await onStarted(native.id, stop);
483
551
  signal?.throwIfAborted();
484
552
  const stream = Duplex.from({ readable: native.stdout, writable: native.stdin });
485
553
  // A lost stream usually means the process ended. Its exit details arrive
@@ -489,11 +557,11 @@ export async function createClaudeCodeProcess({
489
557
  if (failure?.code === "assistant_claude_observation_lost" && exited && !stopped && !stopping) {
490
558
  const processExit = failure.processExit || await Promise.race([
491
559
  exited.catch(() => null),
492
- new Promise((resolve) => setTimeout(resolve, CLAUDE_EXIT_WAIT_MS, null).unref?.())
560
+ new Promise((resolve) => setTimeout(resolve, CLAUDE_EXIT_WAIT_MS, null).unref())
493
561
  ]);
494
562
  if (processExit) Object.assign(failure, { processExit, message: claudeUnexpectedExitMessage(processExit) });
495
563
  }
496
- return onFailure?.(failure);
564
+ return onFailure(failure);
497
565
  };
498
566
  client = createClaudeJsonClient({ stream, onEvent, onFailure: describeFailure,
499
567
  onControlRequest(request, context) {
@@ -525,7 +593,13 @@ export async function createClaudeCodeProcess({
525
593
  }, error => {
526
594
  if (!stopped && !stopping) stream.destroy(error);
527
595
  });
528
- return { client, initialization, executionId: native.id, command, stop };
596
+ return Object.freeze({
597
+ client,
598
+ initialization,
599
+ executionId: native.id,
600
+ command,
601
+ stop
602
+ });
529
603
  } catch (error) {
530
604
  // A rejected start may already own an execution and its cleanup proof.
531
605
  // Only a returned native handle transfers that cleanup to this owner.
@@ -0,0 +1,214 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { createClaudeCodeProcess } from "./claudeProcess.js";
3
+ import { claudeApplicationToolResponse } from "./claudeTools.js";
4
+
5
+ export function hasPendingClaudeStartup(entry) {
6
+ return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
7
+ }
8
+
9
+ /**
10
+ * Starts a retained entry's Claude process: it fences preparation, routes the
11
+ * process's frames and application tool calls, keeps startup custody, and
12
+ * suspends a retained goal before the first prompt. Local commands (goal clear,
13
+ * compaction) run on that process outside the turn view.
14
+ * @param {{
15
+ * store: import("./claudeTurn.js").ClaudeConversationStore,
16
+ * process: import("./claudeTurn.js").ClaudeConversationProcessHost,
17
+ * createError: import("./claudeTurn.js").ClaudeConversationErrorFactory,
18
+ * onEvent: import("./claudeTurn.js").ClaudeConversationEventHandler,
19
+ * bound: boolean,
20
+ * readHistory: (entry: import("./claudeTurn.js").ClaudeConversationEntry) => Promise<Record<string, any>>,
21
+ * isClosing: (contextKey: string) => boolean,
22
+ * applicationCall: import("node:async_hooks").AsyncLocalStorage<Record<string, any>>,
23
+ * currentApplicationCommand: (entry: import("./claudeTurn.js").ClaudeConversationEntry, command: Record<string, any>, owner?: Record<string, any>) => boolean
24
+ * }} options
25
+ */
26
+ export function createClaudeProcessStart({
27
+ store,
28
+ process,
29
+ createError,
30
+ onEvent,
31
+ bound,
32
+ readHistory,
33
+ isClosing,
34
+ applicationCall,
35
+ currentApplicationCommand
36
+ }) {
37
+ const createProcess = process.create || createClaudeCodeProcess;
38
+
39
+ async function startProcess(entry, configuration) {
40
+ const context = entry.context;
41
+ const revision = entry.preparationRevision;
42
+ const assertPreparing = () => {
43
+ if (revision !== entry.preparationRevision || entry.disposed || isClosing(context.key)) {
44
+ throw createError("Claude preparation was stopped while this session was closing or changing.", { code: "conversation_claude_preparation_stopped" });
45
+ }
46
+ };
47
+ assertPreparing();
48
+ let prepared = process.prepare ? process.prepare(entry, configuration, context) : { workdir: entry.nativeWorkdir };
49
+ // readable-check: allow method-probe - data: a host prepares the workdir synchronously or with a promise; a synchronous one starts without yielding
50
+ if (prepared && typeof prepared.then === "function") prepared = await prepared;
51
+ entry.nativeWorkdir = prepared.workdir;
52
+ if (!entry.sent) entry.sent = (await readHistory(entry)).exists;
53
+ entry.stopping = false;
54
+ entry.profile = configuration.profile;
55
+ let options = process.configure(entry, configuration, { context, prepared });
56
+ // readable-check: allow method-probe - data: a host configures the process synchronously or with a promise; a synchronous one starts without yielding
57
+ if (options && typeof options.then === "function") options = await options;
58
+ assertPreparing();
59
+ let startupCleanup;
60
+ let startedExecutionId;
61
+ const processToken = {};
62
+ try { entry.process = await createProcess({ ...options,
63
+ workdir: entry.nativeWorkdir, sessionId: entry.id, resume: entry.sent,
64
+ onStarted: async (executionId, stop) => {
65
+ startedExecutionId = executionId;
66
+ if (typeof stop === "function") {
67
+ startupCleanup = { executionId, stop, pending: true };
68
+ entry.startupCleanup = startupCleanup;
69
+ }
70
+ if (entry.executionId !== executionId) {
71
+ entry.executionId = executionId;
72
+ await store.save(entry, { execution: true });
73
+ await onEvent(entry, { type: "execution", context, executionId });
74
+ }
75
+ assertPreparing();
76
+ },
77
+ async onEvent(frame) {
78
+ const pending = entry.goalToolStop;
79
+ if (pending && pending.executionId === startedExecutionId && entry.process === pending.process &&
80
+ frame.type === "user" && !frame.parent_tool_use_id && Array.isArray(frame.message?.content) &&
81
+ frame.message.content.some(block => block.type === "tool_result" && block.tool_use_id === pending.toolUseId)) {
82
+ // The CLI has consumed the tool response. Do not join process cleanup
83
+ // on its event reader: interrupt/exit also needs that reader to drain.
84
+ pending.stop();
85
+ }
86
+ // Local commands (retained goal clear, compaction) are not AI turns.
87
+ // Their frames stay out of the turn view until their own result.
88
+ const local = entry.localCommand;
89
+ if (local?.processToken === processToken) {
90
+ if (frame.type === "result" && frame.user_message_uuid === local.id) {
91
+ if (frame.local_command === local.name && frame.is_error !== true && frame.subtype === "success") local.resolve();
92
+ else local.reject(createError(local.failureMessage, { code: local.failureCode }));
93
+ }
94
+ return;
95
+ }
96
+ const current = entry.command;
97
+ if (current && frame.type === "assistant" && !frame.parent_tool_use_id) {
98
+ for (const tool of frame.message?.content || []) if (tool.type === "tool_use" && tool.id && tool.name?.startsWith("mcp__application__")) {
99
+ if (current.nativeToolCount >= current.maximumToolCalls && !current.nativeTools.has(tool.id)) {
100
+ throw new Error("Claude exceeded the application tool-call limit.");
101
+ }
102
+ if (!current.nativeTools.has(tool.id)) {
103
+ current.nativeToolCount++;
104
+ current.nativeTools.set(tool.id, { name: tool.name, input: tool.input, messageId: current.messageId });
105
+ }
106
+ }
107
+ }
108
+ await entry.nativeTurn.receive(frame);
109
+ },
110
+ ...(bound || options.applicationTools || entry.command?.tools ? {
111
+ async onControlRequest(request, { signal }) {
112
+ const active = entry.command;
113
+ const suppliedTools = !bound && active?.tools;
114
+ const owner = active?.nativeOwner;
115
+ const work = applicationCall.run({ entry, command: active, owner,
116
+ toolUseId: request.message?.params?._meta?.["claudecode/toolUseId"] },
117
+ () => claudeApplicationToolResponse(request, { schemas: entry.toolSchemas || [], turn: active, signal,
118
+ ...(suppliedTools ? {
119
+ assertCurrent(native) {
120
+ if (!currentApplicationCommand(entry, active, owner) || active.nativeOwner !== owner ||
121
+ native.messageId !== active.messageId) throw new Error("Claude's application call belongs to a retired input.");
122
+ },
123
+ onExecutionFailure(error, native) {
124
+ // Already-invoked old work retains its original result. Only
125
+ // this still-current authored input can fail this native run.
126
+ if (currentApplicationCommand(entry, active, owner) && active.nativeOwner === owner &&
127
+ native.messageId === active.messageId) {
128
+ active.toolFailure = error;
129
+ active.failure.reject(error);
130
+ }
131
+ }
132
+ } : {}) }));
133
+ active?.toolWork.add(work);
134
+ try { return await work; }
135
+ finally { active?.toolWork.delete(work); }
136
+ }
137
+ } : {}),
138
+ onFailure: async error => {
139
+ if (entry.localCommand?.processToken === processToken) entry.localCommand.reject(error);
140
+ if (bound) {
141
+ entry.nativeTurn.invalidateInstructions();
142
+ try { await entry.nativeTurn.stopProcess(error.message); }
143
+ catch (cleanupError) { error = cleanupError; await context.reportFailure(cleanupError); }
144
+ entry.command?.completion.reject(error);
145
+ return;
146
+ }
147
+ entry.observationError = error.message;
148
+ await entry.nativeTurn.stopProcess(error.message);
149
+ }
150
+ }); } catch (error) {
151
+ if (error.executionId && error.stopProof?.scopeEmpty !== true && !entry.executionId) {
152
+ // Streaming startup can fail after host admission, before onStarted.
153
+ // Retain that custody through the same binding owner for Stop recovery.
154
+ error.cleanupFailed = true;
155
+ entry.executionId = error.executionId;
156
+ try {
157
+ await store.save(entry, { execution: true });
158
+ await onEvent(entry, { type: "execution", context, executionId: entry.executionId });
159
+ } catch (bindingError) { error.bindingError = bindingError; }
160
+ }
161
+ if (bound && error.stopProof?.scopeEmpty) {
162
+ await store.releaseExecution(entry);
163
+ if (entry.startupCleanup?.executionId === error.executionId) entry.startupCleanup = null;
164
+ }
165
+ throw error;
166
+ } finally {
167
+ if (startupCleanup) startupCleanup.pending = false;
168
+ }
169
+ try { assertPreparing(); }
170
+ catch (error) {
171
+ await entry.nativeTurn.stopProcess(error.message);
172
+ throw error;
173
+ }
174
+ if (entry.startupCleanup?.executionId === entry.executionId) entry.startupCleanup = null;
175
+ entry.processToken = processToken;
176
+ if (entry.pausedGoal || entry.blockedGoal) {
177
+ try {
178
+ const id = await runLocalCommand(entry, "/goal clear", { name: "goal", timeoutMs: 30_000,
179
+ failureMessage: "Claude could not suspend its retained goal checker.", failureCode: "conversation_claude_goal_suspend_failed",
180
+ timeoutMessage: "Claude timed out suspending its retained goal checker.", timeoutCode: "conversation_claude_goal_suspend_timeout" });
181
+ const cleared = await readHistory(entry);
182
+ if (cleared.goal || cleared.goalCommandId !== id) {
183
+ throw createError("Claude has not saved the retained goal's native clear receipt.", { code: "conversation_claude_goal_clear_unconfirmed" });
184
+ }
185
+ assertPreparing();
186
+ } catch (error) {
187
+ await entry.nativeTurn.stopProcess(error.message);
188
+ throw Object.assign(error, { delivery: "not-sent" });
189
+ }
190
+ }
191
+ return entry.process;
192
+ }
193
+
194
+ async function runLocalCommand(entry, command, { name, timeoutMs, failureMessage, failureCode, timeoutMessage, timeoutCode }) {
195
+ const completion = Promise.withResolvers();
196
+ completion.promise.catch(() => {});
197
+ const local = { ...completion, id: randomUUID(), name, failureMessage, failureCode, processToken: entry.processToken };
198
+ entry.localCommand = local;
199
+ const timer = setTimeout(() => completion.reject(createError(timeoutMessage, { code: timeoutCode })), timeoutMs);
200
+ try {
201
+ await entry.process.client.send(command, { messageId: local.id, sessionId: entry.id });
202
+ await completion.promise;
203
+ return local.id;
204
+ } finally {
205
+ clearTimeout(timer);
206
+ if (entry.localCommand === local) entry.localCommand = null;
207
+ }
208
+ }
209
+
210
+ return Object.freeze({
211
+ startProcess,
212
+ runLocalCommand
213
+ });
214
+ }