@jskit-ai/assistant-core 0.2.2 → 0.2.4

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 (59) hide show
  1. package/examples/conversation/package.json +5 -5
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
  4. package/locales/en.json +4 -2
  5. package/package.json +5 -5
  6. package/src/client/conversation/AssistantConversationElement.vue +8 -1
  7. package/src/client/conversation/AssistantGoalControl.vue +2 -2
  8. package/src/client/conversation/AssistantTranscript.vue +49 -12
  9. package/src/client/conversation/messageDelivery.js +23 -6
  10. package/src/client/lib/assistantApi.js +5 -0
  11. package/src/server/conversation/claudeProcess.js +105 -58
  12. package/src/server/conversation/claudeTurn.js +179 -59
  13. package/src/server/conversation/codexAuthPreflight.js +3 -0
  14. package/src/server/conversation/codexConfiguration.js +42 -4
  15. package/src/server/conversation/codexConversations.js +256 -114
  16. package/src/server/conversation/codexEvents.js +16 -3
  17. package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
  18. package/src/server/conversation/codexHelperThreads.js +10 -1
  19. package/src/server/conversation/codexHomeStorage.js +69 -0
  20. package/src/server/conversation/codexProcess.js +7 -2
  21. package/src/server/conversation/codexProvider.js +57 -46
  22. package/src/server/conversation/codexProviderOwner.js +234 -29
  23. package/src/server/conversation/codexProviderSelection.js +13 -3
  24. package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
  25. package/src/server/conversation/codexSessionCleanup.js +67 -20
  26. package/src/server/conversation/codexSessionReconciliation.js +18 -4
  27. package/src/server/conversation/codexTurn.js +9 -2
  28. package/src/server/conversation/continuity.js +13 -1
  29. package/src/server/conversation/openCodeClient.js +34 -0
  30. package/src/server/conversation/openCodeProcess.js +37 -5
  31. package/src/server/conversation/openCodeRuntime.js +225 -64
  32. package/src/server/conversation/providers/claudeDriver.js +3 -3
  33. package/src/server/conversation/providers/codexDriver.js +10 -4
  34. package/src/server/conversation/providers/opencodeDriver.js +4 -2
  35. package/src/server/conversation/runtime.js +72 -2
  36. package/src/server/hosts/codexConfiguration.js +4 -0
  37. package/test/assistantScroll.browser.test.js +169 -10
  38. package/test/claudeLifecycle.test.js +293 -0
  39. package/test/claudeProcess.test.js +195 -51
  40. package/test/claudeTurn.test.js +174 -3
  41. package/test/codexAccountChange.test.js +261 -0
  42. package/test/codexConversationsCheckpoint.test.js +3 -0
  43. package/test/codexEvents.test.js +23 -1
  44. package/test/codexHomeStorage.test.js +57 -0
  45. package/test/codexInteractiveArguments.test.js +9 -0
  46. package/test/codexLifecycleIsolation.test.js +441 -0
  47. package/test/codexModelCatalog.test.js +48 -2
  48. package/test/codexProcess.test.js +21 -0
  49. package/test/codexTemporaryProviderRelease.test.js +180 -0
  50. package/test/codexTurn.test.js +21 -1
  51. package/test/conversationNativeStorage.test.js +57 -0
  52. package/test/conversationRuntime.test.js +66 -0
  53. package/test/conversationSession.test.js +4 -8
  54. package/test/messageDelivery.browser.test.js +18 -2
  55. package/test/messageDelivery.test.js +34 -1
  56. package/test/openCodeClient.test.js +31 -0
  57. package/test/openCodeConnectionChange.test.js +166 -0
  58. package/test/openCodeProcess.test.js +36 -0
  59. package/test/openCodeRuntime.test.js +25 -78
@@ -9,9 +9,8 @@ import { createLocalConversationExecution } from "./localExecution.js";
9
9
  import { CLAUDE_APPLICATION_TOOL_SERVER } from "./claudeTools.js";
10
10
  import { nativeCommandHook, validateCommandWrapper } from "./commandWrapper.js";
11
11
 
12
- const statusReads = new WeakMap();
13
- const STATUS_REUSE_MS = 30_000;
14
- const STATUS_UNFINISHED_GRACE_MS = 5 * 60_000;
12
+ const statusReads = new Map();
13
+ const STATUS_RETRY_MS = 30_000;
15
14
  const CLAUDE_EXIT_WAIT_MS = 5_000;
16
15
 
17
16
  /** Person-facing cause for a Claude process that ended without being stopped. */
@@ -28,6 +27,17 @@ export function claudeUnexpectedExitMessage(processExit = {}) {
28
27
  return `Claude stopped unexpectedly. ${resume}`;
29
28
  }
30
29
 
30
+ // A file's metadata, following links, or null when it is absent.
31
+ async function fileRevision(file) {
32
+ try {
33
+ const value = await stat(file, { bigint: true });
34
+ return [value.dev, value.ino, value.size, value.mtimeNs].join(":");
35
+ } catch (error) {
36
+ if (error.code === "ENOENT") return null;
37
+ throw error;
38
+ }
39
+ }
40
+
31
41
  export async function readClaudeCodeAuthStatus({
32
42
  env = process.env, credentialHome = { home: env.HOME || homedir() }, command = "claude", commandRunner, signal
33
43
  } = {}) {
@@ -35,39 +45,28 @@ export async function readClaudeCodeAuthStatus({
35
45
  if (typeof commandRunner !== "function") throw new TypeError("Claude account status requires its host capture runner.");
36
46
  const configRoot = env.CLAUDE_CONFIG_DIR || path.join(credentialHome.home, ".claude");
37
47
  // Inspect file metadata only. Claude still owns reading and validating credentials.
38
- const revisions = await Promise.all([
39
- path.join(configRoot, ".credentials.json"), path.join(configRoot, ".claude.json"),
40
- path.join(credentialHome.home, ".claude.json")
41
- ].map(async (file) => {
42
- try {
43
- const value = await stat(file, { bigint: true });
44
- return [value.dev, value.ino, value.size, value.mtimeNs, value.ctimeNs].map(String);
45
- } catch (error) {
46
- if (error.code === "ENOENT") return null;
47
- throw error;
48
- }
49
- }));
50
- let reads = statusReads.get(commandRunner);
51
- if (!reads) statusReads.set(commandRunner, reads = new Map());
52
- const key = JSON.stringify([command, env, credentialHome]);
53
- const revision = JSON.stringify(revisions);
54
- const previous = reads.get(key);
48
+ // Sign-in writes .credentials.json and sign-out removes it. Claude Code also
49
+ // rewrites .claude.json during turns and after every status read, so that
50
+ // file is not watched.
51
+ const revision = await fileRevision(path.join(configRoot, ".credentials.json"));
52
+ // One entry per account, whichever command path or host runner reads it: only
53
+ // the account's location and its identity variables count.
54
+ const key = jsonHash([configRoot, credentialHome.home, Object.entries(env).filter(([name, value]) =>
55
+ value && (name === "CLAUDE_CODE_OAUTH_TOKEN" || name.startsWith("ANTHROPIC_"))).sort()]);
56
+ const previous = statusReads.get(key);
55
57
  if (previous?.revision === revision && (previous.pending || previous.expiresAt > Date.now())) return previous.value;
56
- // Claude's last answer for these exact account files.
57
- const answered = previous?.revision === revision ? previous.answered : null;
58
- const entry = { revision, pending: true, expiresAt: 0, answered };
59
- reads.set(key, entry);
58
+ const entry = { revision, pending: true, expiresAt: 0 };
59
+ statusReads.set(key, entry);
60
60
  entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then(({ status, unfinished }) => {
61
- // A read the host timed out or refused for now says nothing about the account.
62
- const value = unfinished && answered && Date.now() - answered.at < STATUS_UNFINISHED_GRACE_MS
63
- ? answered.status : Object.freeze(status);
64
61
  // Keychain changes cannot be observed with file metadata on macOS.
65
62
  if (process.platform !== "darwin") {
66
- // Unfinished reads back off too, so a busy host is not probed again by every caller.
67
- if (!status.error || unfinished) entry.expiresAt = Date.now() + STATUS_REUSE_MS;
68
- if (!status.error) entry.answered = { status: value, at: Date.now() };
63
+ // Claude's answer holds until its credentials change. A read the host timed
64
+ // out or refused for now backs off, so a busy host is not probed again by
65
+ // every caller; a read that ran and failed is asked again next time.
66
+ if (!status.error) entry.expiresAt = Infinity;
67
+ else if (unfinished) entry.expiresAt = Date.now() + STATUS_RETRY_MS;
69
68
  }
70
- return value;
69
+ return Object.freeze(status);
71
70
  }).finally(() => { entry.pending = false; });
72
71
  return entry.value;
73
72
  }
@@ -250,6 +249,11 @@ const PLAN_USAGE_IDLE_READ_MS = 300_000;
250
249
  // A verified reading stays current until the next idle read, one client poll and
251
250
  // its read deadline could have replaced it.
252
251
  const PLAN_USAGE_CURRENT_MS = 420_000;
252
+ // One plan-usage reading per Claude account in this server process, with its read
253
+ // times and the read in flight. Every account-query owner, page and session asking
254
+ // for the account shares it, so however many ask, the account's one-shot Claude
255
+ // starts at most once per idle interval.
256
+ const planUsageReads = new Map();
253
257
 
254
258
  /**
255
259
  * Claude Code answers `get_usage` from the usage endpoint, or from its saved copy
@@ -278,15 +282,21 @@ export function claudeCatalogueModels(initialization) {
278
282
  }));
279
283
  }
280
284
 
281
- /** Native account queries share process reuse, caches and retryable cleanup. */
282
- export function createClaudeAccountQueries({ createProcess, accountIdentity, processes = () => [], isClosing = () => false }) {
285
+ /**
286
+ * Native account queries share process reuse, caches and retryable cleanup. Plan
287
+ * usage is shared by `accountIdentity` with every other owner in the process, so
288
+ * that identity names exactly one Claude account. `configRoot` is the account's
289
+ * Claude configuration directory, whose credentials the model list follows.
290
+ */
291
+ export function createClaudeAccountQueries({ createProcess, accountIdentity, configRoot, processes = () => [], isClosing = () => false }) {
283
292
  const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
284
293
  const accountProcessStops = new Set();
285
294
  const accountReads = new Set();
286
295
  let catalog;
287
296
  const catalogStarts = new Map();
288
- const planUsage = new Map();
289
- const planUsagePending = new Map();
297
+ // Accounts whose shared plan-usage reading this owner used, and the reads it started.
298
+ const usageAccounts = new Set();
299
+ const usageReads = new Set();
290
300
  let revision = 0;
291
301
 
292
302
  function assertCurrent(expectedRevision) {
@@ -305,7 +315,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
305
315
  entry.process && !entry.stopping && entry.accountIdentity === identity);
306
316
  }
307
317
 
308
- async function readAccountProcess(identity, expectedRevision, purpose, read, { borrow = true } = {}) {
318
+ async function readAccountProcess(identity, expectedRevision, purpose, read, { borrow = false } = {}) {
309
319
  assertCurrent(expectedRevision);
310
320
  for (const native of accountProcessStops) await stopAccountProcess(native);
311
321
  assertCurrent(expectedRevision);
@@ -330,36 +340,60 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
330
340
  }
331
341
  }
332
342
 
343
+ // The model list belongs to the account and the Claude Code version that gave
344
+ // it: the account's credentials file changes with sign-in, sign-out and each
345
+ // token refresh (which carries a plan change), and the executable with each
346
+ // Claude Code update. An executable given as a bare name has no metadata.
347
+ async function catalogueRevision(command) {
348
+ return JSON.stringify(await Promise.all([fileRevision(path.join(configRoot, ".credentials.json")),
349
+ fileRevision(command)]));
350
+ }
351
+
333
352
  return Object.freeze({
334
353
  async readCatalogue(context) {
335
354
  const expectedRevision = revision;
336
355
  assertCurrent(expectedRevision);
337
356
  const identity = await accountIdentity(context);
338
357
  assertCurrent(expectedRevision);
339
- if (!catalog || catalog.identity !== identity || Date.now() - catalog.at > 600_000) {
340
- if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
341
- const value = await readAccountProcess(identity, expectedRevision, "catalogue", (native) => native.initialization);
342
- if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
343
- assertCurrent(expectedRevision);
344
- catalog = { at: Date.now(), identity, value };
345
- return value;
346
- })().finally(() => { catalogStarts.delete(identity); }));
347
- return catalogStarts.get(identity);
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;
348
364
  }
349
- return catalog.value;
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);
350
377
  },
351
378
  async readPlanUsage(context) {
352
379
  const expectedRevision = revision;
353
380
  assertCurrent(expectedRevision);
354
381
  const identity = await accountIdentity(context);
355
382
  assertCurrent(expectedRevision);
356
- if (planUsagePending.has(identity)) return planUsagePending.get(identity);
357
- // One reading per account; a started process is stopped after its read.
358
- const usage = planUsage.get(identity) || { liveReadAt: -Infinity, temporaryReadAt: -Infinity, reading: null };
359
- planUsage.set(identity, usage);
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;
360
391
  const live = Boolean(ownedProcess(identity));
361
392
  const liveDue = live && Date.now() - usage.liveReadAt >= PLAN_USAGE_LIVE_READ_MS;
362
- const temporaryDue = () => Date.now() - usage.temporaryReadAt >= PLAN_USAGE_IDLE_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;
363
397
  if (!liveDue && (live || !temporaryDue())) return presentPlanUsage(usage.reading);
364
398
  const pending = (async () => {
365
399
  // A live process's held endpoint read is its own, so a fresh one-shot Claude
@@ -385,18 +419,31 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
385
419
  }
386
420
  }
387
421
  return presentPlanUsage(usage.reading);
388
- })().finally(() => { planUsagePending.delete(identity); });
389
- planUsagePending.set(identity, pending);
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);
390
429
  return pending;
391
430
  },
392
431
  // A native usage change lets the next request read again without hiding the last reading.
393
- invalidateUsage() { for (const usage of planUsage.values()) usage.liveReadAt = -Infinity; },
432
+ invalidateUsage() {
433
+ for (const identity of usageAccounts) {
434
+ const usage = planUsageReads.get(identity);
435
+ if (usage) usage.liveReadAt = -Infinity;
436
+ }
437
+ },
394
438
  async invalidate() {
395
439
  revision++;
396
- for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
397
- await Promise.allSettled([...catalogStarts.values(), ...planUsagePending.values()]);
440
+ // Dropped at once: a read started before this cannot store its answer.
398
441
  catalog = null;
399
- planUsage.clear();
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();
400
447
  for (const native of accountProcessStops) await stopAccountProcess(native);
401
448
  }
402
449
  });
@@ -478,7 +525,7 @@ export async function createClaudeCodeProcess({
478
525
  }, error => {
479
526
  if (!stopped && !stopping) stream.destroy(error);
480
527
  });
481
- return { client, initialization, executionId: native.id, stop };
528
+ return { client, initialization, executionId: native.id, command, stop };
482
529
  } catch (error) {
483
530
  // A rejected start may already own an execution and its cleanup proof.
484
531
  // Only a returned native handle transfers that cleanup to this owner.