@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.
- package/examples/conversation/package.json +5 -5
- package/fixtures/responsive-assistant/ConversationFixture.vue +2 -2
- package/fixtures/responsive-assistant/DeliveryFixture.vue +15 -2
- package/locales/en.json +4 -2
- package/package.json +5 -5
- package/src/client/conversation/AssistantConversationElement.vue +8 -1
- package/src/client/conversation/AssistantGoalControl.vue +2 -2
- package/src/client/conversation/AssistantTranscript.vue +41 -11
- package/src/client/conversation/messageDelivery.js +23 -6
- package/src/client/lib/assistantApi.js +5 -0
- package/src/server/conversation/claudeProcess.js +171 -53
- package/src/server/conversation/claudeTurn.js +199 -63
- package/src/server/conversation/codexAuthPreflight.js +3 -0
- package/src/server/conversation/codexConfiguration.js +42 -4
- package/src/server/conversation/codexConversations.js +256 -114
- package/src/server/conversation/codexEvents.js +16 -3
- package/src/server/conversation/codexHelperThreadRecovery.js +17 -4
- package/src/server/conversation/codexHelperThreads.js +10 -1
- package/src/server/conversation/codexHomeStorage.js +69 -0
- package/src/server/conversation/codexProcess.js +7 -2
- package/src/server/conversation/codexProvider.js +57 -46
- package/src/server/conversation/codexProviderOwner.js +272 -10
- package/src/server/conversation/codexProviderSelection.js +13 -3
- package/src/server/conversation/codexRuntimeLifecycle.js +56 -4
- package/src/server/conversation/codexSessionCleanup.js +67 -20
- package/src/server/conversation/codexSessionReconciliation.js +18 -4
- package/src/server/conversation/codexTurn.js +9 -2
- package/src/server/conversation/continuity.js +34 -14
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +257 -69
- package/src/server/conversation/providers/claudeDriver.js +7 -5
- package/src/server/conversation/providers/codexDriver.js +23 -16
- package/src/server/conversation/providers/opencodeDriver.js +17 -14
- package/src/server/conversation/providers/scoped.js +5 -2
- package/src/server/conversation/runtime.js +93 -6
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +134 -18
- package/test/claudeConversationSession.test.js +26 -1
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +364 -33
- package/test/claudeTurn.test.js +192 -3
- package/test/codexAccountChange.test.js +261 -0
- package/test/codexConversationsCheckpoint.test.js +3 -0
- package/test/codexEvents.test.js +23 -1
- package/test/codexHomeStorage.test.js +57 -0
- package/test/codexInteractiveArguments.test.js +9 -0
- package/test/codexLifecycleIsolation.test.js +441 -0
- package/test/codexModelCatalog.test.js +48 -2
- package/test/codexProcess.test.js +21 -0
- package/test/codexTemporaryProviderRelease.test.js +180 -0
- package/test/codexTurn.test.js +52 -1
- package/test/conversationNativeStorage.test.js +284 -0
- package/test/conversationRuntime.test.js +66 -0
- package/test/conversationSession.test.js +4 -8
- package/test/messageDelivery.browser.test.js +18 -2
- package/test/messageDelivery.test.js +34 -1
- package/test/openCodeClient.test.js +31 -0
- package/test/openCodeConnectionChange.test.js +166 -0
- package/test/openCodeProcess.test.js +36 -0
- package/test/openCodeRuntime.test.js +25 -78
|
@@ -9,7 +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
|
|
12
|
+
const statusReads = new Map();
|
|
13
|
+
const STATUS_RETRY_MS = 30_000;
|
|
13
14
|
const CLAUDE_EXIT_WAIT_MS = 5_000;
|
|
14
15
|
|
|
15
16
|
/** Person-facing cause for a Claude process that ended without being stopped. */
|
|
@@ -26,6 +27,17 @@ export function claudeUnexpectedExitMessage(processExit = {}) {
|
|
|
26
27
|
return `Claude stopped unexpectedly. ${resume}`;
|
|
27
28
|
}
|
|
28
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
|
+
|
|
29
41
|
export async function readClaudeCodeAuthStatus({
|
|
30
42
|
env = process.env, credentialHome = { home: env.HOME || homedir() }, command = "claude", commandRunner, signal
|
|
31
43
|
} = {}) {
|
|
@@ -33,29 +45,27 @@ export async function readClaudeCodeAuthStatus({
|
|
|
33
45
|
if (typeof commandRunner !== "function") throw new TypeError("Claude account status requires its host capture runner.");
|
|
34
46
|
const configRoot = env.CLAUDE_CONFIG_DIR || path.join(credentialHome.home, ".claude");
|
|
35
47
|
// Inspect file metadata only. Claude still owns reading and validating credentials.
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
throw error;
|
|
46
|
-
}
|
|
47
|
-
}));
|
|
48
|
-
let reads = statusReads.get(commandRunner);
|
|
49
|
-
if (!reads) statusReads.set(commandRunner, reads = new Map());
|
|
50
|
-
const key = JSON.stringify([command, env, credentialHome]);
|
|
51
|
-
const revision = JSON.stringify(revisions);
|
|
52
|
-
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);
|
|
53
57
|
if (previous?.revision === revision && (previous.pending || previous.expiresAt > Date.now())) return previous.value;
|
|
54
58
|
const entry = { revision, pending: true, expiresAt: 0 };
|
|
55
|
-
|
|
56
|
-
entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then((status) => {
|
|
59
|
+
statusReads.set(key, entry);
|
|
60
|
+
entry.value = readNativeClaudeCodeAuthStatus({ env, credentialHome, command, commandRunner, signal }).then(({ status, unfinished }) => {
|
|
57
61
|
// Keychain changes cannot be observed with file metadata on macOS.
|
|
58
|
-
if (
|
|
62
|
+
if (process.platform !== "darwin") {
|
|
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;
|
|
68
|
+
}
|
|
59
69
|
return Object.freeze(status);
|
|
60
70
|
}).finally(() => { entry.pending = false; });
|
|
61
71
|
return entry.value;
|
|
@@ -67,6 +77,12 @@ async function readNativeClaudeCodeAuthStatus({ env, credentialHome, command, co
|
|
|
67
77
|
credentialHome, cwd: credentialHome.home, mode: "capture",
|
|
68
78
|
timeout: 30_000, maxBuffer: 64 * 1024, ...(signal ? { signal } : {})
|
|
69
79
|
});
|
|
80
|
+
const status = claudeCodeAuthStatus(result);
|
|
81
|
+
return { status, unfinished: Boolean(status.error) && result.ok !== true &&
|
|
82
|
+
(result.timedOut === true || result.retryable === true) };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function claudeCodeAuthStatus(result) {
|
|
70
86
|
const failure = result.error ? { loggedIn: false, error: result.error }
|
|
71
87
|
: { loggedIn: false, error: "Claude Code is not connected.", messageCode: "assistant.claude.notConnected", messageParams: {} };
|
|
72
88
|
if (!result.ok && (result.exitCode !== 1 || result.timedOut || result.signal)) return failure;
|
|
@@ -226,6 +242,37 @@ export function claudePlanUsage(value) {
|
|
|
226
242
|
windows, checkedAt: Date.now() };
|
|
227
243
|
}
|
|
228
244
|
|
|
245
|
+
// Plan usage is read through a conversation's live process at most this often,
|
|
246
|
+
// and otherwise through a one-shot Claude started only for the read.
|
|
247
|
+
const PLAN_USAGE_LIVE_READ_MS = 60_000;
|
|
248
|
+
const PLAN_USAGE_IDLE_READ_MS = 300_000;
|
|
249
|
+
// A verified reading stays current until the next idle read, one client poll and
|
|
250
|
+
// its read deadline could have replaced it.
|
|
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();
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Claude Code answers `get_usage` from the usage endpoint, or from its saved copy
|
|
260
|
+
* of that answer while under a minute old, including the endpoint's `limits` list.
|
|
261
|
+
* While that read fails or is held after a 429, it answers from the rate-limit
|
|
262
|
+
* headers of the answering process's last API response, which can be hours old,
|
|
263
|
+
* and omits `limits`. The answer has no age, so only one with `limits` is current.
|
|
264
|
+
*/
|
|
265
|
+
function claudePlanUsageVerified(value) {
|
|
266
|
+
return !value.rate_limits_available || Array.isArray(value.rate_limits?.limits);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
function presentPlanUsage(reading) {
|
|
270
|
+
if (!reading) return { status: "unavailable", windows: [] };
|
|
271
|
+
if (reading.status !== "available") return reading;
|
|
272
|
+
const currentForMs = PLAN_USAGE_CURRENT_MS - (Date.now() - reading.checkedAt);
|
|
273
|
+
return currentForMs > 0 ? { ...reading, currentForMs } : { ...reading, status: "stale" };
|
|
274
|
+
}
|
|
275
|
+
|
|
229
276
|
export function claudeCatalogueModels(initialization) {
|
|
230
277
|
return (initialization.models || []).map((model) => ({
|
|
231
278
|
id: model.value, label: model.displayName || model.value, description: model.description || "",
|
|
@@ -235,15 +282,21 @@ export function claudeCatalogueModels(initialization) {
|
|
|
235
282
|
}));
|
|
236
283
|
}
|
|
237
284
|
|
|
238
|
-
/**
|
|
239
|
-
|
|
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 }) {
|
|
240
292
|
const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
|
|
241
293
|
const accountProcessStops = new Set();
|
|
242
294
|
const accountReads = new Set();
|
|
243
295
|
let catalog;
|
|
244
296
|
const catalogStarts = new Map();
|
|
245
|
-
|
|
246
|
-
const
|
|
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();
|
|
247
300
|
let revision = 0;
|
|
248
301
|
|
|
249
302
|
function assertCurrent(expectedRevision) {
|
|
@@ -257,12 +310,16 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
257
310
|
accountProcessStops.delete(native);
|
|
258
311
|
}
|
|
259
312
|
|
|
260
|
-
|
|
313
|
+
function ownedProcess(identity) {
|
|
314
|
+
return [...processes()].find((entry) =>
|
|
315
|
+
entry.process && !entry.stopping && entry.accountIdentity === identity);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
async function readAccountProcess(identity, expectedRevision, purpose, read, { borrow = false } = {}) {
|
|
261
319
|
assertCurrent(expectedRevision);
|
|
262
320
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
263
321
|
assertCurrent(expectedRevision);
|
|
264
|
-
const owned =
|
|
265
|
-
entry.process && !entry.stopping && entry.accountIdentity === identity);
|
|
322
|
+
const owned = borrow ? ownedProcess(identity) : null;
|
|
266
323
|
const controller = new AbortController();
|
|
267
324
|
accountReads.add(controller);
|
|
268
325
|
const cancelled = Promise.withResolvers();
|
|
@@ -283,49 +340,110 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
283
340
|
}
|
|
284
341
|
}
|
|
285
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
|
+
|
|
286
352
|
return Object.freeze({
|
|
287
353
|
async readCatalogue(context) {
|
|
288
354
|
const expectedRevision = revision;
|
|
289
355
|
assertCurrent(expectedRevision);
|
|
290
356
|
const identity = await accountIdentity(context);
|
|
291
357
|
assertCurrent(expectedRevision);
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
return value;
|
|
299
|
-
})().finally(() => { catalogStarts.delete(identity); }));
|
|
300
|
-
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;
|
|
301
364
|
}
|
|
302
|
-
|
|
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);
|
|
303
377
|
},
|
|
304
378
|
async readPlanUsage(context) {
|
|
305
379
|
const expectedRevision = revision;
|
|
306
380
|
assertCurrent(expectedRevision);
|
|
307
381
|
const identity = await accountIdentity(context);
|
|
308
382
|
assertCurrent(expectedRevision);
|
|
309
|
-
|
|
310
|
-
|
|
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);
|
|
311
398
|
const pending = (async () => {
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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: [] };
|
|
414
|
+
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
|
+
}
|
|
420
|
+
}
|
|
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);
|
|
320
429
|
return pending;
|
|
321
430
|
},
|
|
322
|
-
|
|
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
|
+
}
|
|
437
|
+
},
|
|
323
438
|
async invalidate() {
|
|
324
439
|
revision++;
|
|
325
|
-
|
|
326
|
-
await Promise.allSettled([...catalogStarts.values(), ...planUsagePending.values()]);
|
|
440
|
+
// Dropped at once: a read started before this cannot store its answer.
|
|
327
441
|
catalog = null;
|
|
328
|
-
|
|
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();
|
|
329
447
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
330
448
|
}
|
|
331
449
|
});
|
|
@@ -407,7 +525,7 @@ export async function createClaudeCodeProcess({
|
|
|
407
525
|
}, error => {
|
|
408
526
|
if (!stopped && !stopping) stream.destroy(error);
|
|
409
527
|
});
|
|
410
|
-
return { client, initialization, executionId: native.id, stop };
|
|
528
|
+
return { client, initialization, executionId: native.id, command, stop };
|
|
411
529
|
} catch (error) {
|
|
412
530
|
// A rejected start may already own an execution and its cleanup proof.
|
|
413
531
|
// Only a returned native handle transfers that cleanup to this owner.
|