@jskit-ai/assistant-core 0.2.1 → 0.2.3

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 (61) 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 +41 -11
  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 +171 -53
  12. package/src/server/conversation/claudeTurn.js +199 -63
  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 +272 -10
  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 +34 -14
  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 +257 -69
  32. package/src/server/conversation/providers/claudeDriver.js +7 -5
  33. package/src/server/conversation/providers/codexDriver.js +23 -16
  34. package/src/server/conversation/providers/opencodeDriver.js +17 -14
  35. package/src/server/conversation/providers/scoped.js +5 -2
  36. package/src/server/conversation/runtime.js +93 -6
  37. package/src/server/hosts/codexConfiguration.js +4 -0
  38. package/test/assistantScroll.browser.test.js +134 -18
  39. package/test/claudeConversationSession.test.js +26 -1
  40. package/test/claudeLifecycle.test.js +293 -0
  41. package/test/claudeProcess.test.js +364 -33
  42. package/test/claudeTurn.test.js +192 -3
  43. package/test/codexAccountChange.test.js +261 -0
  44. package/test/codexConversationsCheckpoint.test.js +3 -0
  45. package/test/codexEvents.test.js +23 -1
  46. package/test/codexHomeStorage.test.js +57 -0
  47. package/test/codexInteractiveArguments.test.js +9 -0
  48. package/test/codexLifecycleIsolation.test.js +441 -0
  49. package/test/codexModelCatalog.test.js +48 -2
  50. package/test/codexProcess.test.js +21 -0
  51. package/test/codexTemporaryProviderRelease.test.js +180 -0
  52. package/test/codexTurn.test.js +52 -1
  53. package/test/conversationNativeStorage.test.js +284 -0
  54. package/test/conversationRuntime.test.js +66 -0
  55. package/test/conversationSession.test.js +4 -8
  56. package/test/messageDelivery.browser.test.js +18 -2
  57. package/test/messageDelivery.test.js +34 -1
  58. package/test/openCodeClient.test.js +31 -0
  59. package/test/openCodeConnectionChange.test.js +166 -0
  60. package/test/openCodeProcess.test.js +36 -0
  61. package/test/openCodeRuntime.test.js +25 -78
@@ -1,5 +1,5 @@
1
1
  import assert from "node:assert/strict";
2
- import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, mkdtemp, rename, rm, writeFile } from "node:fs/promises";
3
3
  import os, { tmpdir } from "node:os";
4
4
  import path, { join } from "node:path";
5
5
  import { test } from "node:test";
@@ -29,6 +29,9 @@ test("native command construction preserves tool-free constraints without granti
29
29
  assert.deepEqual(JSON.parse(coding[coding.indexOf("--mcp-config") + 1]), { mcpServers: { application: { type: "sdk", name: "application" } } });
30
30
  });
31
31
 
32
+ // Account queries whose credentials are not the subject read them from nowhere.
33
+ const ABSENT_CLAUDE_CONFIG = join(tmpdir(), "jskit-absent-claude-config");
34
+
32
35
  async function fixture(t, body) {
33
36
  const directory = await mkdtemp(join(tmpdir(), "jskit-claude-process-"));
34
37
  t.after(() => rm(directory, { recursive: true, force: true }));
@@ -110,10 +113,10 @@ test("Claude model configuration maps background calls and resets external setti
110
113
  });
111
114
 
112
115
  test("Claude status reads native JSON and returns only public account details", async () => {
113
- const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture" }, commandRunner: async (input) => {
116
+ const status = await readClaudeCodeAuthStatus({ credentialHome: { home: "/home/fixture-json" }, commandRunner: async (input) => {
114
117
  assert.deepEqual(input.args, ["auth", "status", "--json"]);
115
118
  assert.equal(input.timeout, 30_000);
116
- assert.equal(input.credentialHome.home, "/home/fixture");
119
+ assert.equal(input.credentialHome.home, "/home/fixture-json");
117
120
  return { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai", subscriptionType: "max", accessToken: "not-public" }) };
118
121
  } });
119
122
  assert.equal(status.loggedIn, true);
@@ -122,8 +125,10 @@ test("Claude status reads native JSON and returns only public account details",
122
125
  });
123
126
 
124
127
  test("Claude signed-out JSON is normal while failed and malformed status responses stay errors", async () => {
128
+ // Reads of one account share an entry, so each response gets its own account.
129
+ let account = 0;
125
130
  const readStatus = (result) => readClaudeCodeAuthStatus({
126
- credentialHome: { home: "/home/fixture" }, commandRunner: async () => result
131
+ credentialHome: { home: `/home/fixture-signed-out-${account += 1}` }, commandRunner: async () => result
127
132
  });
128
133
  const signedOut = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false, authMethod: "none" }) };
129
134
  assert.deepEqual(await readStatus(signedOut), {
@@ -144,7 +149,7 @@ test("Claude signed-out JSON is normal while failed and malformed status respons
144
149
  }
145
150
  });
146
151
 
147
- test("Claude status shares concurrent reads and invalidates when native account files change", async (t) => {
152
+ test("Claude status shares one read per account and invalidates only when its credentials change", async (t) => {
148
153
  const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
149
154
  t.after(() => rm(home, { recursive: true, force: true }));
150
155
  const configRoot = path.join(home, ".claude");
@@ -163,16 +168,32 @@ test("Claude status shares concurrent reads and invalidates when native account
163
168
  await readClaudeCodeAuthStatus(input);
164
169
  assert.equal(calls, 1);
165
170
  }
166
- for (const file of [path.join(configRoot, ".credentials.json"), path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
167
- email = `${calls}@example.test`;
168
- // Deliberately not JSON: the wrapper must never parse native credentials.
169
- await writeFile(file, "native-account-changed");
170
- assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
171
- await rm(file);
172
- const before = calls;
171
+ // macOS reuses nothing: file metadata cannot show Keychain changes.
172
+ if (process.platform === "darwin") return;
173
+ // Claude Code rewrites .claude.json during ordinary turns: the account is unchanged.
174
+ for (const file of [path.join(home, ".claude.json"), path.join(configRoot, ".claude.json")]) {
175
+ await writeFile(file, "rewritten-during-a-turn");
173
176
  await readClaudeCodeAuthStatus(input);
174
- assert.equal(calls, before + 1);
177
+ assert.equal(calls, 1, `${path.basename(path.dirname(file))}/.claude.json does not bust the read`);
175
178
  }
179
+ // Another command path and host runner reading the same account share its entry.
180
+ const otherRunner = async () => assert.fail("The same account is read once.");
181
+ await readClaudeCodeAuthStatus({ ...input, command: "/opt/other/bin/claude", commandRunner: otherRunner,
182
+ env: { ...input.env, PATH: "/opt/other/bin", HOME: home } });
183
+ assert.equal(calls, 1);
184
+ // Sign-in writes the credentials and sign-out removes them.
185
+ const credentials = path.join(configRoot, ".credentials.json");
186
+ email = "second@example.test";
187
+ // Deliberately not JSON: the wrapper must never parse native credentials.
188
+ await writeFile(credentials, "native-account-changed");
189
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, email);
190
+ assert.equal(calls, 2);
191
+ await rm(credentials);
192
+ await readClaudeCodeAuthStatus(input);
193
+ assert.equal(calls, 3);
194
+ // A different identity variable is a different account.
195
+ await readClaudeCodeAuthStatus({ ...input, env: { ...input.env, CLAUDE_CODE_OAUTH_TOKEN: "fixture-token" } });
196
+ assert.equal(calls, 4);
176
197
  });
177
198
 
178
199
  test("Claude status failures do not poison later reads or share across credential homes", async () => {
@@ -182,12 +203,79 @@ test("Claude status failures do not poison later reads or share across credentia
182
203
  return calls === 1 ? { ok: false, error: "Temporary failure" }
183
204
  : { ok: true, stdout: JSON.stringify({ loggedIn: false }) };
184
205
  };
185
- const input = { env: {}, credentialHome: { home: "/home/fixture" }, commandRunner };
206
+ const input = { env: {}, credentialHome: { home: "/home/fixture-failure" }, commandRunner };
186
207
  assert.equal((await readClaudeCodeAuthStatus(input)).error, "Temporary failure");
187
208
  assert.equal((await readClaudeCodeAuthStatus(input)).error, undefined);
188
209
  assert.equal(calls, 2);
189
- await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/another" } });
210
+ await readClaudeCodeAuthStatus({ ...input, credentialHome: { home: "/home/fixture-failure-another" } });
211
+ assert.equal(calls, 3);
212
+ });
213
+
214
+ test("a Claude status answer holds until the credentials change, even across its own .claude.json rewrite", {
215
+ skip: process.platform === "darwin"
216
+ }, async (t) => {
217
+ const home = await mkdtemp(path.join(os.tmpdir(), "claude-auth-status-"));
218
+ t.after(() => rm(home, { recursive: true, force: true }));
219
+ const realNow = Date.now;
220
+ let now = realNow();
221
+ Date.now = () => now;
222
+ t.after(() => { Date.now = realNow; });
223
+ const configRoot = path.join(home, ".claude");
224
+ await mkdir(configRoot);
225
+ const credentials = path.join(configRoot, ".credentials.json");
226
+ await writeFile(credentials, "signed-in");
227
+ const signedIn = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test", authMethod: "claude.ai" }) };
228
+ let calls = 0;
229
+ let result = signedIn;
230
+ const input = { env: { CLAUDE_CONFIG_DIR: configRoot }, credentialHome: { home }, commandRunner: async () => {
231
+ calls += 1;
232
+ // Each native status run rewrites ~/.claude.json a few seconds later.
233
+ await writeFile(path.join(home, ".claude.json"), `status-run-${calls}`);
234
+ return result;
235
+ } };
236
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
237
+ now += 10 * 60_000;
238
+ assert.equal((await readClaudeCodeAuthStatus(input)).email, "owner@example.test");
239
+ assert.equal(calls, 1, "the answer is not read again on a timer or after the run's own rewrite");
240
+ // Sign-out removes the credentials: Claude is asked again at once.
241
+ await rm(credentials);
242
+ result = { ok: false, exitCode: 1, stdout: JSON.stringify({ loggedIn: false }) };
243
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, false);
244
+ assert.equal(calls, 2);
245
+ // A read the host timed out is reported and not asked again at once.
246
+ await writeFile(credentials, "signed-in-again");
247
+ result = { ok: false, exitCode: 1, timedOut: true, error: "Command timed out." };
248
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
249
+ now += 29_000;
250
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Command timed out.");
190
251
  assert.equal(calls, 3);
252
+ // A read that ran and failed is reported, and asked again next time.
253
+ now += 2_000;
254
+ result = { ok: false, exitCode: 2, error: "Claude Code crashed." };
255
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Claude Code crashed.");
256
+ result = signedIn;
257
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
258
+ assert.equal(calls, 5);
259
+ });
260
+
261
+ test("an unfinished Claude status read is reported without probing again at once", {
262
+ skip: process.platform === "darwin"
263
+ }, async (t) => {
264
+ const realNow = Date.now;
265
+ let now = realNow();
266
+ Date.now = () => now;
267
+ t.after(() => { Date.now = realNow; });
268
+ let calls = 0;
269
+ let result = { ok: false, exitCode: 1, retryable: true, error: "Memory-pressure spike." };
270
+ const input = { env: {}, credentialHome: { home: "/home/fixture-busy" }, commandRunner: async () => { calls += 1; return result; } };
271
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
272
+ now += 29_000;
273
+ assert.equal((await readClaudeCodeAuthStatus(input)).error, "Memory-pressure spike.");
274
+ assert.equal(calls, 1);
275
+ now += 2_000;
276
+ result = { ok: true, stdout: JSON.stringify({ loggedIn: true, email: "owner@example.test" }) };
277
+ assert.equal((await readClaudeCodeAuthStatus(input)).loggedIn, true);
278
+ assert.equal(calls, 2);
191
279
  });
192
280
 
193
281
  test("Claude plan usage retains real windows and never invents an allowance after reset", () => {
@@ -469,22 +557,27 @@ test("Claude account queries isolate concurrent identities and deduplicate each
469
557
  for (const method of ["readCatalogue", "readPlanUsage"]) {
470
558
  const pending = new Map(["first", "second"].map(id => [id, Promise.withResolvers()]));
471
559
  const calls = [];
560
+ const native = id => ({ command: process.execPath, async stop() { return { scopeEmpty: true }; },
561
+ get initialization() { calls.push(id); return pending.get(id).promise; },
562
+ client: { request() { calls.push(id); return pending.get(id).promise; } } });
563
+ // Plan usage borrows each account's conversation; the model list starts its own Claude.
564
+ const starting = ["first", "second"];
472
565
  const queries = createClaudeAccountQueries({
473
- accountIdentity: async context => context.id,
474
- processes: () => [...pending].map(([id, held]) => ({ accountIdentity: id, process: {
475
- get initialization() { calls.push(id); return held.promise; },
476
- client: { request() { calls.push(id); return held.promise; } }
477
- } })),
478
- createProcess() { throw new Error("An owned account process should be reused."); }
566
+ accountIdentity: async context => context.id, configRoot: ABSENT_CLAUDE_CONFIG,
567
+ processes: () => method === "readPlanUsage" ? starting.map(id => ({ accountIdentity: id, process: native(id) })) : [],
568
+ createProcess() {
569
+ if (method === "readPlanUsage") throw new Error("An owned account process should be reused.");
570
+ return native(starting.shift());
571
+ }
479
572
  });
480
573
  const first = queries[method]({ id: "first" });
481
574
  const duplicate = queries[method]({ id: "first" });
482
575
  const second = queries[method]({ id: "second" });
483
576
  // Finish in reverse order to expose cross-account sharing and cache races.
484
577
  pending.get("second").resolve({ models: [{ value: "second" }], rate_limits_available: true,
485
- rate_limits: { five_hour: { utilization: 20 } } });
578
+ rate_limits: { five_hour: { utilization: 20 }, limits: [] } });
486
579
  pending.get("first").resolve({ models: [{ value: "first" }], rate_limits_available: true,
487
- rate_limits: { five_hour: { utilization: 80 } } });
580
+ rate_limits: { five_hour: { utilization: 80 }, limits: [] } });
488
581
  const [a, same, b] = await Promise.all([first, duplicate, second]);
489
582
  assert.deepEqual(calls, ["first", "second"], method);
490
583
  assert.deepEqual(same, a);
@@ -501,15 +594,247 @@ test("Claude account queries isolate concurrent identities and deduplicate each
501
594
  }
502
595
  });
503
596
 
504
- test("Claude account queries label temporary startup without changing cached process reuse", async () => {
597
+ test("Claude plan usage shows only an answer from the usage endpoint as current", async () => {
598
+ let answer;
599
+ const queries = createClaudeAccountQueries({
600
+ accountIdentity: async () => "account",
601
+ processes: () => [{ accountIdentity: "account", process: { client: { request: async () => answer } } }],
602
+ createProcess() { throw new Error("No temporary Claude can start here."); }
603
+ });
604
+ const resets_at = new Date(Date.now() + 86_400_000).toISOString();
605
+ // Shape of Claude Code's answer while its endpoint read fails or is held: the
606
+ // window readings of its own last API response, which can be hours old, without
607
+ // the endpoint's `limits` list and without any age.
608
+ const seeded = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
609
+ answer = seeded;
610
+ assert.deepEqual(await queries.readPlanUsage({}), { status: "unavailable", windows: [] });
611
+ queries.invalidateUsage();
612
+ answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } };
613
+ const verified = await queries.readPlanUsage({});
614
+ assert.equal(verified.status, "available");
615
+ assert.equal(verified.windows[0].remainingPercent, 77);
616
+ assert.ok(verified.currentForMs > 0 && verified.currentForMs <= 420_000);
617
+ queries.invalidateUsage();
618
+ answer = seeded;
619
+ const kept = await queries.readPlanUsage({});
620
+ assert.equal(kept.windows[0].remainingPercent, 77, "an unverified answer replaced the endpoint reading");
621
+ assert.equal(kept.checkedAt, verified.checkedAt);
622
+ await queries.invalidate();
623
+ });
624
+
625
+ test("Claude plan usage asks a temporary Claude when a live process cannot prove its answer", async (t) => {
626
+ const realNow = Date.now;
627
+ let now = Date.parse("2026-10-29T03:00:00Z");
628
+ Date.now = () => now;
629
+ t.after(() => { Date.now = realNow; });
630
+ const resets_at = "2026-11-04T05:00:00Z";
631
+ const starts = [];
632
+ let stops = 0;
633
+ let liveReads = 0;
634
+ let startRefused = false;
635
+ const queries = createClaudeAccountQueries({
636
+ accountIdentity: async () => "account",
637
+ // An idle conversation whose own endpoint read is held answers from old headers.
638
+ processes: () => [{ accountIdentity: "account", process: { client: { request: async () => {
639
+ liveReads++;
640
+ return { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at } } };
641
+ } } } }],
642
+ createProcess(input) {
643
+ if (startRefused) throw new Error("Memory is low.");
644
+ starts.push(input.purpose);
645
+ return { client: { request: async () => ({ rate_limits_available: true,
646
+ rate_limits: { seven_day: { utilization: 23, resets_at }, limits: [] } }) },
647
+ async stop() { stops++; return { scopeEmpty: true }; } };
648
+ }
649
+ });
650
+ const first = await queries.readPlanUsage({});
651
+ assert.equal(first.status, "available");
652
+ assert.equal(first.windows[0].remainingPercent, 77);
653
+ assert.deepEqual(starts, ["usage"]);
654
+ for (let poll = 1; poll <= 4; poll++) {
655
+ now += 60_000;
656
+ assert.equal((await queries.readPlanUsage({})).windows[0].remainingPercent, 77);
657
+ }
658
+ assert.equal(liveReads, 5);
659
+ assert.deepEqual(starts, ["usage"], "a fallback started Claude inside the idle interval");
660
+ now += 61_000;
661
+ await queries.readPlanUsage({});
662
+ assert.deepEqual(starts, ["usage", "usage"]);
663
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
664
+ // A refused fallback keeps the last verified reading rather than failing the read.
665
+ now += 301_000;
666
+ startRefused = true;
667
+ const kept = await queries.readPlanUsage({});
668
+ assert.equal(kept.windows[0].remainingPercent, 77);
669
+ await queries.invalidate();
670
+ });
671
+
672
+ test("Claude plan usage starts one temporary Claude per idle interval and marks an old reading stale", async (t) => {
673
+ const realNow = Date.now;
674
+ let now = Date.parse("2026-10-29T03:00:00Z");
675
+ Date.now = () => now;
676
+ t.after(() => { Date.now = realNow; });
677
+ const starts = [];
678
+ let stops = 0;
679
+ let answer = { rate_limits_available: true, rate_limits: {
680
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
681
+ const queries = createClaudeAccountQueries({
682
+ accountIdentity: async () => "account",
683
+ createProcess(input) {
684
+ starts.push(input.purpose);
685
+ return { client: { request: async () => answer }, async stop() { stops++; return { scopeEmpty: true }; } };
686
+ }
687
+ });
688
+ const first = await queries.readPlanUsage({});
689
+ assert.equal(first.status, "available");
690
+ assert.equal(first.windows[0].remainingPercent, 77);
691
+ // Pages polling every minute share the account's reading.
692
+ for (let poll = 1; poll <= 4; poll++) {
693
+ now += 60_000;
694
+ assert.equal((await queries.readPlanUsage({})).status, "available");
695
+ }
696
+ assert.deepEqual(starts, ["usage"]);
697
+ now += 61_000;
698
+ answer = { rate_limits_available: true, rate_limits: { seven_day: { utilization: 7, resets_at: "2026-11-04T05:00:00Z" } } };
699
+ const unverified = await queries.readPlanUsage({});
700
+ assert.deepEqual(starts, ["usage", "usage"]);
701
+ assert.equal(unverified.windows[0].remainingPercent, 77);
702
+ now += 120_000;
703
+ const stale = await queries.readPlanUsage({});
704
+ assert.equal(stale.status, "stale");
705
+ assert.equal(stale.windows[0].remainingPercent, 77);
706
+ assert.equal(stale.checkedAt, first.checkedAt);
707
+ assert.equal("currentForMs" in stale, false);
708
+ assert.deepEqual(starts, ["usage", "usage"], "a poll inside the idle interval started Claude");
709
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
710
+ await queries.invalidate();
711
+ });
712
+
713
+ test("Claude plan usage starts one temporary Claude per account for every owner polling it", async (t) => {
714
+ const realNow = Date.now;
715
+ let now = Date.parse("2026-10-29T03:00:00Z");
716
+ Date.now = () => now;
717
+ t.after(() => { Date.now = realNow; });
718
+ const starts = [];
719
+ let stops = 0;
720
+ // Three account-query owners in one server process, none with a live conversation.
721
+ const owners = ["first", "second", "third"].map(owner => createClaudeAccountQueries({
722
+ accountIdentity: async context => context.account,
723
+ createProcess(input) {
724
+ starts.push([owner, input.purpose]);
725
+ return { client: { request: async () => ({ rate_limits_available: true, rate_limits: {
726
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } }) },
727
+ async stop() { stops++; return { scopeEmpty: true }; } };
728
+ }
729
+ }));
730
+ t.after(() => Promise.all(owners.map(queries => queries.invalidate())));
731
+ const account = { account: "plan-usage-shared-across-owners" };
732
+ // Requests that arrive while the account is read share that one read.
733
+ const concurrent = await Promise.all(owners.map(queries => queries.readPlanUsage(account)));
734
+ assert.deepEqual(starts, [["first", "usage"]]);
735
+ for (const reading of concurrent) assert.deepEqual(reading, concurrent[0]);
736
+ assert.equal(concurrent[0].windows[0].remainingPercent, 77);
737
+ // An hour of every owner polling once a minute.
738
+ for (let minute = 1; minute < 60; minute++) {
739
+ now += 60_000;
740
+ for (const queries of owners) assert.equal((await queries.readPlanUsage(account)).status, "available");
741
+ }
742
+ assert.equal(starts.length, 12, "the account started more than one temporary Claude per idle interval");
743
+ assert.equal(stops, starts.length, "a temporary usage process outlived its read");
744
+ // Another account is read on its own.
745
+ await owners[1].readPlanUsage({ account: "plan-usage-another-account" });
746
+ assert.equal(starts.length, 13);
747
+ });
748
+
749
+ test("Claude plan usage reuses another owner's live reading instead of starting a temporary Claude", async (t) => {
750
+ const realNow = Date.now;
751
+ let now = Date.parse("2026-10-29T03:00:00Z");
752
+ Date.now = () => now;
753
+ t.after(() => { Date.now = realNow; });
754
+ const account = "plan-usage-live-in-another-owner";
755
+ const answer = { rate_limits_available: true, rate_limits: {
756
+ seven_day: { utilization: 23, resets_at: "2026-11-04T05:00:00Z" }, limits: [] } };
757
+ let liveReads = 0;
758
+ const starts = [];
759
+ const createProcess = () => {
760
+ starts.push("usage");
761
+ return { client: { request: async () => answer }, async stop() { return { scopeEmpty: true }; } };
762
+ };
763
+ const withConversation = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess,
764
+ processes: () => [{ accountIdentity: account, process: { client: { request: async () => { liveReads++; return answer; } } } }] });
765
+ const idle = createClaudeAccountQueries({ accountIdentity: async () => account, createProcess });
766
+ t.after(() => Promise.all([withConversation.invalidate(), idle.invalidate()]));
767
+ const live = await withConversation.readPlanUsage({});
768
+ assert.equal(liveReads, 1);
769
+ // Only the idle owner's page stays open: the account's verified reading serves it
770
+ // until that reading is an idle interval old.
771
+ for (let minute = 1; minute <= 4; minute++) {
772
+ now += 60_000;
773
+ const reading = await idle.readPlanUsage({});
774
+ assert.equal(reading.checkedAt, live.checkedAt);
775
+ assert.equal(reading.status, "available");
776
+ }
777
+ assert.deepEqual(starts, [], "a temporary Claude started while the account had a recent verified reading");
778
+ now += 60_000;
779
+ await idle.readPlanUsage({});
780
+ assert.deepEqual(starts, ["usage"]);
781
+ });
782
+
783
+ test("Claude model list starts a Claude only after its account, credentials or Claude Code changed", async (t) => {
784
+ const root = await mkdtemp(join(tmpdir(), "jskit-claude-catalogue-"));
785
+ t.after(() => rm(root, { recursive: true, force: true }));
786
+ const configRoot = join(root, ".claude");
787
+ await mkdir(configRoot);
788
+ // Sign-in, a token refresh and an update each replace the file at a stable path.
789
+ const replace = async (file, contents) => {
790
+ await writeFile(`${file}.next`, contents);
791
+ await rename(`${file}.next`, file);
792
+ };
793
+ const credentials = join(configRoot, ".credentials.json");
794
+ const command = join(root, "claude");
795
+ await replace(credentials, "signed in");
796
+ await replace(command, "2.1.1");
797
+ const realNow = Date.now;
798
+ let now = realNow();
799
+ Date.now = () => now;
800
+ t.after(() => { Date.now = realNow; });
801
+ let account = "account";
802
+ const starts = [];
803
+ const queries = createClaudeAccountQueries({ accountIdentity: async () => account, configRoot,
804
+ // A conversation that started before every change below.
805
+ processes: () => [{ accountIdentity: account, process: { command, initialization: { models: [{ value: "live" }] } } }],
806
+ createProcess({ purpose }) {
807
+ starts.push(purpose);
808
+ return { command, initialization: { models: [{ value: `read-${starts.length}` }] },
809
+ async stop() { return { scopeEmpty: true }; } };
810
+ } });
811
+ const model = async () => (await queries.readCatalogue({})).models[0].value;
812
+ // Reconnects, tab returns and closed sessions ask about four times an hour.
813
+ const askForADay = async (expected) => {
814
+ for (let read = 0; read < 96; read++, now += 15 * 60_000) assert.equal(await model(), expected);
815
+ };
816
+ await askForADay("read-1");
817
+ assert.deepEqual(starts, ["catalogue"]);
818
+ await replace(credentials, "refreshed");
819
+ await askForADay("read-2");
820
+ await replace(command, "2.1.2");
821
+ await askForADay("read-3");
822
+ account = "other account";
823
+ await askForADay("read-4");
824
+ assert.deepEqual(starts, ["catalogue", "catalogue", "catalogue", "catalogue"]);
825
+ await queries.invalidate();
826
+ assert.equal(await model(), "read-5");
827
+ });
828
+
829
+ test("Claude account queries label temporary startup and reuse each account's recent reading", async () => {
505
830
  const starts = [];
506
831
  let stops = 0;
507
832
  const queries = createClaudeAccountQueries({
508
- accountIdentity: async () => "private-account-identity",
833
+ accountIdentity: async () => "private-account-identity", configRoot: ABSENT_CLAUDE_CONFIG,
509
834
  createProcess(input) {
510
835
  starts.push(input);
511
836
  return {
512
- initialization: { models: [] },
837
+ initialization: { models: [] }, command: process.execPath,
513
838
  client: { request: async () => ({ rate_limits_available: false }) },
514
839
  async stop() { stops++; return { scopeEmpty: true }; }
515
840
  };
@@ -526,31 +851,33 @@ test("Claude account queries label temporary startup without changing cached pro
526
851
  await queries.invalidate();
527
852
  });
528
853
 
529
- test("account invalidation cancels held startup and usage without stopping a borrowed conversation", async () => {
530
- for (const phase of ["startup", "owned usage", "borrowed usage"]) {
854
+ test("account invalidation cancels held startup and reads without stopping a borrowed conversation", async () => {
855
+ for (const phase of ["catalogue startup", "owned catalogue", "borrowed usage"]) {
531
856
  const started = Promise.withResolvers();
532
857
  let stops = 0;
533
858
  const native = { client: { request() { started.resolve(); return new Promise(() => {}); } },
859
+ get initialization() { started.resolve(); return new Promise(() => {}); },
534
860
  async stop() { stops++; return { scopeEmpty: true }; } };
535
861
  const queries = createClaudeAccountQueries({ accountIdentity: async () => "account",
536
862
  processes: () => phase === "borrowed usage" ? [{ accountIdentity: "account", process: native }] : [],
537
863
  createProcess({ signal }) {
538
- if (phase !== "startup") return native;
864
+ if (phase !== "catalogue startup") return native;
539
865
  started.resolve();
540
866
  return new Promise((_resolve, reject) => signal.addEventListener("abort", () => reject(signal.reason), { once: true }));
541
867
  }
542
868
  });
543
- const pending = queries.readPlanUsage({});
869
+ const pending = phase === "borrowed usage" ? queries.readPlanUsage({}) : queries.readCatalogue({});
544
870
  const rejected = assert.rejects(pending, /cancelled for reconnection/);
545
871
  await started.promise;
546
872
  await queries.invalidate();
547
873
  await rejected;
548
- assert.equal(stops, phase === "owned usage" ? 1 : 0, phase);
874
+ assert.equal(stops, phase === "owned catalogue" ? 1 : 0, phase);
549
875
  }
550
876
  });
551
877
 
552
878
  for (const method of ["readCatalogue", "readPlanUsage"]) {
553
- for (const phase of ["initial identity", "final identity", "retained cleanup"]) {
879
+ // Plan usage borrows a conversation's process, so it has no cleanup to retain.
880
+ for (const phase of method === "readPlanUsage" ? ["initial identity", "final identity"] : ["initial identity", "final identity", "retained cleanup"]) {
554
881
  test(`${method} invalidation fences ${phase} before launching or caching a stale query`, { timeout: 2_000 }, async () => {
555
882
  const entered = Promise.withResolvers();
556
883
  const release = Promise.withResolvers();
@@ -566,10 +893,14 @@ for (const method of ["readCatalogue", "readPlanUsage"]) {
566
893
  }
567
894
  return "account";
568
895
  },
896
+ // Usage reads count as native reads of the borrowed conversation process.
897
+ processes: () => method === "readPlanUsage" ? [{ accountIdentity: "account", process: { client: {
898
+ request: async () => { starts++; return { rate_limits_available: true, rate_limits: { limits: [] } }; } } } }] : [],
899
+ configRoot: ABSENT_CLAUDE_CONFIG,
569
900
  createProcess() {
570
901
  starts++;
571
902
  return {
572
- initialization: { models: [{ value: `model-${starts}` }] },
903
+ initialization: { models: [{ value: `model-${starts}` }] }, command: process.execPath,
573
904
  client: { request: async () => ({ rate_limits_available: true, rate_limits: {} }) },
574
905
  async stop() {
575
906
  stops++;