@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
|
@@ -14,11 +14,30 @@ function failure(message, code = "assistant_claude_turn_failed") {
|
|
|
14
14
|
|
|
15
15
|
const text = normalizeNullableText;
|
|
16
16
|
const now = () => new Date().toISOString();
|
|
17
|
+
const CLAUDE_SIGNED_OUT_MESSAGE = "Claude isn't connected. Sign in to Claude, then send your message again.";
|
|
17
18
|
|
|
18
19
|
function hasPendingClaudeStartup(entry) {
|
|
19
20
|
return entry.startupCleanup?.pending === true && entry.startupCleanup.executionId === entry.executionId;
|
|
20
21
|
}
|
|
21
22
|
|
|
23
|
+
// Claude records an admitted prompt in its history, and only a send in
|
|
24
|
+
// progress writes prompts to a live process. With neither left, a prompt
|
|
25
|
+
// missing from history can never arrive. A turn left marked active by a
|
|
26
|
+
// lost process does not change that.
|
|
27
|
+
function nativeWorkSettled(entry) {
|
|
28
|
+
return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Work this server is running: a send, command or stop in progress, a live
|
|
32
|
+
// process, or a startup still lending cleanup. A restored execution with none
|
|
33
|
+
// of these belongs to an earlier server; no Claude process outlives the
|
|
34
|
+
// server that started it.
|
|
35
|
+
function runsNativeWork(entry) {
|
|
36
|
+
return Boolean(entry.sending || entry.process || entry.command || entry.stopPending || hasPendingClaudeStartup(entry));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const ACTIVE_TURN_STATES = ["starting", "active", "finalizing"];
|
|
40
|
+
|
|
22
41
|
export function claudeNativeMessageId(id) {
|
|
23
42
|
const value = createHash("sha256").update(String(id)).digest("hex");
|
|
24
43
|
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
|
|
@@ -173,9 +192,13 @@ export function createClaudeConversationOwner({
|
|
|
173
192
|
// Otherwise the host retains entries and their original checkpoint order.
|
|
174
193
|
const bound = Boolean(store.releaseExecution);
|
|
175
194
|
const isTurnActive = entry => bound ? entry.nativeTurn.read().active : Boolean(entry.turn?.active);
|
|
195
|
+
// What cleanup must stop: running work, or custody (an execution or an
|
|
196
|
+
// active turn) that only a verified stop retires. An idle entry has nothing
|
|
197
|
+
// to stop, and stopping it would only rewrite its saved record.
|
|
198
|
+
const holdsNativeWork = entry => runsNativeWork(entry) || Boolean(entry.executionId) || isTurnActive(entry);
|
|
176
199
|
|
|
177
200
|
function createAccountQueries({ accountIdentity, createProcess }) {
|
|
178
|
-
return createClaudeAccountQueries({ accountIdentity,
|
|
201
|
+
return createClaudeAccountQueries({ accountIdentity, configRoot,
|
|
179
202
|
processes: () => entries.values(), isClosing: () => closing, createProcess });
|
|
180
203
|
}
|
|
181
204
|
|
|
@@ -219,6 +242,12 @@ export function createClaudeConversationOwner({
|
|
|
219
242
|
return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
|
|
220
243
|
}
|
|
221
244
|
|
|
245
|
+
function savedState(saved, conversationId) {
|
|
246
|
+
if (!saved) return {};
|
|
247
|
+
try { return JSON.parse(saved); }
|
|
248
|
+
catch { throw createError(`The saved Claude conversation ${conversationId} is unreadable. Restore its saved conversation state.`); }
|
|
249
|
+
}
|
|
250
|
+
|
|
222
251
|
async function open(context, { conversationId = "", mainId, create = false,
|
|
223
252
|
cleanupExecutionId, recoveringCleanup = false, nativeWorkdir = context.workdir } = {}) {
|
|
224
253
|
const main = !conversationId || conversationId === mainId;
|
|
@@ -238,7 +267,7 @@ export function createClaudeConversationOwner({
|
|
|
238
267
|
let saved = store.read(context, id);
|
|
239
268
|
if (saved && typeof saved.then === "function") saved = await saved;
|
|
240
269
|
if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
|
|
241
|
-
const state = saved
|
|
270
|
+
const state = savedState(saved, id);
|
|
242
271
|
const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
|
|
243
272
|
executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
|
|
244
273
|
accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
|
|
@@ -318,9 +347,9 @@ export function createClaudeConversationOwner({
|
|
|
318
347
|
|
|
319
348
|
async function ensureReady(entry) {
|
|
320
349
|
if (entry.process) {
|
|
321
|
-
// Status reads retain active instructions
|
|
322
|
-
|
|
323
|
-
|
|
350
|
+
// Status reads retain an active turn's instructions and account: an
|
|
351
|
+
// account change never stops running work. Both apply at idle admission.
|
|
352
|
+
if (!isTurnActive(entry)) await ensureProcess(entry);
|
|
324
353
|
} else {
|
|
325
354
|
await bindAccount(entry);
|
|
326
355
|
if (entry.executionId && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
|
|
@@ -563,7 +592,13 @@ export function createClaudeConversationOwner({
|
|
|
563
592
|
timeoutMessage: "Claude timed out summarising its context." });
|
|
564
593
|
}
|
|
565
594
|
|
|
566
|
-
async function send(entry,
|
|
595
|
+
async function send(entry, ...args) {
|
|
596
|
+
entry.sending = (entry.sending || 0) + 1;
|
|
597
|
+
try { return await dispatchPrompt(entry, ...args); }
|
|
598
|
+
finally { entry.sending -= 1; }
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
async function dispatchPrompt(entry, input = {}, { renewal = false, command, applicationCommand } = {}) {
|
|
567
602
|
const message = command ? input.text : text(input.message || input.prompt);
|
|
568
603
|
if (!command && !message) throw createError("Enter a message for Claude.");
|
|
569
604
|
const context = entry.context;
|
|
@@ -693,7 +728,7 @@ export function createClaudeConversationOwner({
|
|
|
693
728
|
else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
|
|
694
729
|
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
695
730
|
await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
|
|
696
|
-
{ messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
731
|
+
{ messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
697
732
|
} catch (error) {
|
|
698
733
|
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
699
734
|
throw error;
|
|
@@ -732,10 +767,11 @@ export function createClaudeConversationOwner({
|
|
|
732
767
|
}
|
|
733
768
|
const requestIds = new Map((input.requestMessageIds || [input.messageId]).filter(id => typeof id === "string" && id)
|
|
734
769
|
.map(id => [claudeNativeMessageId(id), id]));
|
|
770
|
+
const admitted = history.userIds.includes(claudeNativeMessageId(input.messageId || ""));
|
|
735
771
|
return { ...entry.nativeTurn.readResult(), ok: true,
|
|
736
772
|
messages: [...messages.values()].map(message => ({ ...message,
|
|
737
773
|
...(message.userId ? { requestMessageId: requestIds.get(message.userId) || "" } : {}) })), text: current.text || history.text,
|
|
738
|
-
admitted:
|
|
774
|
+
admitted, notAdmitted: !admitted && Boolean(input.messageId) && nativeWorkSettled(entry) };
|
|
739
775
|
}
|
|
740
776
|
|
|
741
777
|
async function inspectAdmission(entry, input) {
|
|
@@ -743,13 +779,16 @@ export function createClaudeConversationOwner({
|
|
|
743
779
|
const history = await readHistory(entry);
|
|
744
780
|
const turnId = claudeNativeMessageId(input.messageId);
|
|
745
781
|
const accepted = history.userIds.includes(turnId);
|
|
746
|
-
|
|
782
|
+
const admission = accepted ? "accepted" : nativeWorkSettled(entry) ? "not-admitted" : "unknown";
|
|
783
|
+
return { ok: true, admission, messageId: input.messageId,
|
|
747
784
|
threadId: entry.id, turnId: accepted ? turnId : "" };
|
|
748
785
|
}
|
|
749
786
|
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
787
|
+
// Temporary work this server runs. Saved conversations are not reopened to
|
|
788
|
+
// answer: a turn an earlier server saved as active ended with that server.
|
|
789
|
+
function hasActiveTemporaryConversation(context) {
|
|
790
|
+
return [...entries.values()].some((entry) => entry.context.key === context.key && !entry.main &&
|
|
791
|
+
isTurnActive(entry) && runsNativeWork(entry));
|
|
753
792
|
}
|
|
754
793
|
|
|
755
794
|
function hasActiveConversation(binding) {
|
|
@@ -788,16 +827,28 @@ export function createClaudeConversationOwner({
|
|
|
788
827
|
return [...entries.values()].filter((entry) => entry.context.key === context.key);
|
|
789
828
|
}
|
|
790
829
|
|
|
830
|
+
// A session's close stops what this server runs for it, then reopens only
|
|
831
|
+
// saved custody an earlier server left. Once the owner is closing for an
|
|
832
|
+
// account change or shutdown it reopens nothing.
|
|
791
833
|
async function closeSession(context, application) {
|
|
792
|
-
|
|
834
|
+
return closeRetained(await application.readContext(context), application,
|
|
835
|
+
{ recover: !closing, forget: context.forgetConversationBinding === true });
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
// Renewal proof release ends a session this server no longer uses. Its own
|
|
839
|
+
// close already recovered saved custody while its record could be written.
|
|
840
|
+
async function releaseSession(context, application) {
|
|
841
|
+
return closeRetained(await application.readContext(context), application, { recover: false, forget: true });
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
async function closeRetained(ctx, application, { recover, forget: forgetEntries }) {
|
|
793
845
|
closingSessions.add(ctx.key);
|
|
794
846
|
try {
|
|
795
|
-
await
|
|
847
|
+
if (recover) await recoverSavedCustody(ctx);
|
|
796
848
|
const proofs = await drain(ctx.key);
|
|
797
849
|
const terminal = await application.terminals.close(ctx.sessionId);
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
850
|
+
// A forgotten entry is drained: everything it held is in its saved record.
|
|
851
|
+
if (terminal?.ok !== false && forgetEntries) forget(ctx.key);
|
|
801
852
|
return { ok: terminal?.ok !== false, closed: proofs.length + Number(terminal?.closed || 0),
|
|
802
853
|
processExitProof: proofs.at(-1) || { exited: true, scopeEmpty: true }, processExitProofs: proofs };
|
|
803
854
|
} finally {
|
|
@@ -805,19 +856,27 @@ export function createClaudeConversationOwner({
|
|
|
805
856
|
}
|
|
806
857
|
}
|
|
807
858
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
859
|
+
// Saved custody an earlier server left: an execution whose exit is still
|
|
860
|
+
// unproven, or a turn saved as active. Only those conversations are reopened,
|
|
861
|
+
// so a verified stop can retire them; idle saved conversations stay closed.
|
|
862
|
+
async function recoverSavedCustody(ctx) {
|
|
863
|
+
let saved = store.readSessionConversations(ctx);
|
|
864
|
+
if (saved && typeof saved.then === "function") saved = await saved;
|
|
865
|
+
for (const conversationId of saved.ids) {
|
|
866
|
+
if (entries.has(`${ctx.key}\0${conversationId}`)) continue;
|
|
867
|
+
let record = store.read(saved.context, conversationId);
|
|
868
|
+
if (record && typeof record.then === "function") record = await record;
|
|
869
|
+
const state = savedState(record, conversationId);
|
|
870
|
+
if (state.executionId || ACTIVE_TURN_STATES.includes(state.state)) await acquire(saved.context, conversationId);
|
|
819
871
|
}
|
|
820
|
-
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
// Project close releases what this server runs for the project's sessions,
|
|
875
|
+
// from their retained entries alone, and forgets them: nothing uses them.
|
|
876
|
+
function closeProject(input = {}, application) {
|
|
877
|
+
const root = text(input.projectContextRoot);
|
|
878
|
+
return eachSession(entry => !root || path.resolve(store.projectContextRoot(entry)) === path.resolve(root),
|
|
879
|
+
ctx => closeRetained(ctx, application, { recover: false, forget: true }));
|
|
821
880
|
}
|
|
822
881
|
|
|
823
882
|
async function invalidateRuntimes(input = {}, application, account) {
|
|
@@ -826,44 +885,78 @@ export function createClaudeConversationOwner({
|
|
|
826
885
|
return stopSelected(entry => store.modelProviderId(entry) === input.modelProviderId,
|
|
827
886
|
"The provider connection changed.");
|
|
828
887
|
}
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
888
|
+
if (input.reason !== "server-shutdown") {
|
|
889
|
+
// An account change stops and waits on nothing. Claude Code reads its
|
|
890
|
+
// credentials for every request, so running work continues on the newly
|
|
891
|
+
// connected account or ends at once as signed out; each conversation
|
|
892
|
+
// rebinds its account at its next idle admission. Only the account's
|
|
893
|
+
// cached reads are dropped. A cancelled query's process stop that fails
|
|
894
|
+
// stays owned and is retried before the next query.
|
|
895
|
+
account.queries.invalidate().catch(() => {});
|
|
896
|
+
return { ok: true, closed: 0 };
|
|
835
897
|
}
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
898
|
+
closing = true;
|
|
899
|
+
// Neither waits for the other: a failed account query cleanup must not
|
|
900
|
+
// leave the sessions' processes running.
|
|
901
|
+
const [queries, retained] = await Promise.allSettled([account.queries.invalidate(), stopRetained(application)]);
|
|
902
|
+
if (queries.status === "rejected") await account.failure(queries.reason);
|
|
903
|
+
if (retained.status === "rejected") throw retained.reason;
|
|
904
|
+
return retained.value;
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
// Shutdown stops only what this server runs: a Claude process cannot outlive
|
|
908
|
+
// the server that started it, so retained entries hold every one. Saved
|
|
909
|
+
// conversations are not reopened just to be stopped.
|
|
910
|
+
function stopRetained(application) {
|
|
911
|
+
return eachSession(() => true, ctx => closeRetained(ctx, application, { recover: false, forget: false }));
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
// Sessions close independently and together: one that fails is reported by
|
|
915
|
+
// its ID while every other session still stops.
|
|
916
|
+
async function eachSession(select, close) {
|
|
917
|
+
const contexts = new Map([...entries.values()].filter(select).map(entry => [entry.context.key, entry.context]));
|
|
918
|
+
const settled = await Promise.allSettled([...contexts.values()].map(ctx => close(ctx)));
|
|
843
919
|
let closed = 0;
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
920
|
+
const failed = [];
|
|
921
|
+
[...contexts.values()].forEach((ctx, index) => {
|
|
922
|
+
const result = settled[index];
|
|
923
|
+
if (result.status === "fulfilled") closed += result.value.closed;
|
|
924
|
+
const error = result.status === "rejected" ? result.reason?.message || String(result.reason)
|
|
925
|
+
: result.value.ok === false ? "Its Claude Code terminal could not be closed." : "";
|
|
926
|
+
if (error) failed.push({ sessionId: ctx.sessionId, error });
|
|
927
|
+
});
|
|
928
|
+
return { ok: failed.length === 0, closed, failed,
|
|
929
|
+
...(failed.length ? { error: failed.map(({ sessionId, error }) => `Claude session ${sessionId}: ${error}`).join(" ") } : {}) };
|
|
852
930
|
}
|
|
853
931
|
|
|
854
932
|
async function drain(key) {
|
|
855
933
|
const selected = [...entries.values()].filter(entry => entry.context.key === key);
|
|
934
|
+
// Fence configuration still in progress: it must not start a process later.
|
|
935
|
+
for (const entry of selected) entry.preparationRevision++;
|
|
936
|
+
const proofs = [];
|
|
937
|
+
let failure;
|
|
938
|
+
async function stop() {
|
|
939
|
+
for (const entry of selected) if (holdsNativeWork(entry)) {
|
|
940
|
+
try { proofs.push(await entry.nativeTurn.stopProcess()); }
|
|
941
|
+
catch (error) { failure ||= error; }
|
|
942
|
+
}
|
|
943
|
+
}
|
|
856
944
|
// Startup already lends cleanup before initialization completes. Stop it
|
|
857
945
|
// first; waiting for the preparation queue first can exhaust host shutdown.
|
|
858
|
-
|
|
946
|
+
await stop();
|
|
859
947
|
await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
|
|
860
|
-
|
|
861
|
-
|
|
948
|
+
await stop();
|
|
949
|
+
// Every other entry still stopped; the first failure keeps the close retryable.
|
|
950
|
+
if (failure) throw failure;
|
|
862
951
|
return proofs;
|
|
863
952
|
}
|
|
864
953
|
|
|
865
954
|
function forget(contextKey) {
|
|
866
|
-
for (const [key, entry] of entries) if (entry.context.key === contextKey)
|
|
955
|
+
for (const [key, entry] of entries) if (entry.context.key === contextKey) {
|
|
956
|
+
// Nothing would ever stop a process a forgotten entry started.
|
|
957
|
+
entry.disposed = true;
|
|
958
|
+
entries.delete(key);
|
|
959
|
+
}
|
|
867
960
|
}
|
|
868
961
|
|
|
869
962
|
async function close(entry) {
|
|
@@ -888,12 +981,22 @@ export function createClaudeConversationOwner({
|
|
|
888
981
|
}
|
|
889
982
|
}
|
|
890
983
|
|
|
984
|
+
// A provider connection change stops the matching work this server runs.
|
|
985
|
+
// Each entry stops on its own, so one failure is reported by ID.
|
|
891
986
|
async function stopSelected(select, reason) {
|
|
892
987
|
let closed = 0;
|
|
893
|
-
|
|
894
|
-
|
|
988
|
+
const failed = [];
|
|
989
|
+
for (const entry of [...entries.values()].filter(select)) {
|
|
990
|
+
entry.preparationRevision++;
|
|
991
|
+
if (!holdsNativeWork(entry)) continue;
|
|
992
|
+
try {
|
|
993
|
+
await entry.nativeTurn.stopProcess(reason);
|
|
994
|
+
closed++;
|
|
995
|
+
} catch (error) {
|
|
996
|
+
failed.push({ sessionId: entry.context.sessionId, conversationId: entry.id, error: error.message });
|
|
997
|
+
}
|
|
895
998
|
}
|
|
896
|
-
return { ok:
|
|
999
|
+
return { ok: failed.length === 0, closed, failed };
|
|
897
1000
|
}
|
|
898
1001
|
|
|
899
1002
|
async function wait(entry, input, { context, acquire }) {
|
|
@@ -1155,8 +1258,8 @@ export function createClaudeConversationOwner({
|
|
|
1155
1258
|
if (!bound) return { value: delivered.value };
|
|
1156
1259
|
}
|
|
1157
1260
|
|
|
1158
|
-
return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
|
|
1159
|
-
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1261
|
+
return Object.freeze({ applicationToolsSupported: true, entries, closingSessions, get closing() { return closing; }, createAccountQueries, open, acquire, createConversation, snapshot, readRetainedTurn, readFinalAssistantResult, readHistory, nativeWorkSettled, ensureReady, ensureProcess, readContextUsage, compactContext, releaseRetainedGoal, stopForTerminal, prepareTerminal, send, startTurn,
|
|
1262
|
+
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, releaseSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1160
1263
|
readGoal, updateGoal, renewal, run, steer, cancel });
|
|
1161
1264
|
}
|
|
1162
1265
|
|
|
@@ -1173,6 +1276,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1173
1276
|
// Inputs Claude has begun reading. A queued steer joins the running loop, so
|
|
1174
1277
|
// one result can finish several of them; queued-only input stays in flight.
|
|
1175
1278
|
const startedInputs = new Set();
|
|
1279
|
+
// Steers written into a running loop that Claude has not begun reading. Claude
|
|
1280
|
+
// Code reads queued input only when the running tool call returns.
|
|
1281
|
+
const waitingSteers = new Set();
|
|
1282
|
+
let steeringClient = null;
|
|
1176
1283
|
const tasks = new Set();
|
|
1177
1284
|
let turnId = "";
|
|
1178
1285
|
let messageId = "";
|
|
@@ -1206,6 +1313,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1206
1313
|
clearFinalAssistantResult();
|
|
1207
1314
|
}
|
|
1208
1315
|
|
|
1316
|
+
// A waiting steer would otherwise sit behind a long foreground command (or a
|
|
1317
|
+
// wait loop) until it returns. Claude's Ctrl+B control moves foreground Bash
|
|
1318
|
+
// and subagents to the background: they keep running and report completion,
|
|
1319
|
+
// and the blocked tool call returns so Claude reads the steer now.
|
|
1320
|
+
function backgroundForegroundWork() {
|
|
1321
|
+
const client = steeringClient;
|
|
1322
|
+
if (!waitingSteers.size || !client?.request || stopped || cancelling) return;
|
|
1323
|
+
void client.request({ subtype: "background_tasks" }).then(
|
|
1324
|
+
() => publish({ type: "diagnostic", stage: "steering_backgrounded", pendingCommands: inFlight.size, backgroundTasks: tasks.size }),
|
|
1325
|
+
// An older CLI still reads the steer when its tool call returns.
|
|
1326
|
+
error => publish({ type: "diagnostic", stage: "steering_background_failed", error: error.message })
|
|
1327
|
+
).catch(() => {});
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1209
1330
|
function currentReceiptOwner(owner) {
|
|
1210
1331
|
return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
|
|
1211
1332
|
owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
|
|
@@ -1450,6 +1571,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1450
1571
|
currentCommandId = uuid;
|
|
1451
1572
|
startedInputs.add(uuid);
|
|
1452
1573
|
}
|
|
1574
|
+
if (frame.state !== "queued") waitingSteers.delete(uuid);
|
|
1453
1575
|
if (frame.state === "completed") {
|
|
1454
1576
|
inFlight.delete(uuid);
|
|
1455
1577
|
startedInputs.delete(uuid);
|
|
@@ -1493,6 +1615,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1493
1615
|
for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
|
|
1494
1616
|
}
|
|
1495
1617
|
if (frame.subtype === "task_started" && frame.task_id && !frame.ambient) tasks.add(frame.task_id);
|
|
1618
|
+
// A command Claude started before reading a waiting steer would hold it too.
|
|
1619
|
+
if (frame.subtype === "task_started") backgroundForegroundWork();
|
|
1496
1620
|
if (["task_notification", "task_updated"].includes(frame.subtype) &&
|
|
1497
1621
|
["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
|
|
1498
1622
|
tasks.delete(frame.task_id);
|
|
@@ -1580,7 +1704,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1580
1704
|
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
|
|
1581
1705
|
const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
|
|
1582
1706
|
checkOutput(output);
|
|
1583
|
-
|
|
1707
|
+
// A conversation whose account signed out under it ends its next request
|
|
1708
|
+
// at once with Claude Code's own "Not logged in" answer.
|
|
1709
|
+
const message = !failed ? "" : /^Not logged in\b/u.test(output) ? CLAUDE_SIGNED_OUT_MESSAGE
|
|
1710
|
+
: (frame.errors || []).join("; ") || output || frame.subtype;
|
|
1584
1711
|
result = failed ? "" : output;
|
|
1585
1712
|
if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
|
|
1586
1713
|
await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
|
|
@@ -1630,6 +1757,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1630
1757
|
admissions.clear();
|
|
1631
1758
|
inFlight.clear();
|
|
1632
1759
|
startedInputs.clear();
|
|
1760
|
+
waitingSteers.clear();
|
|
1761
|
+
steeringClient = null;
|
|
1633
1762
|
tasks.clear();
|
|
1634
1763
|
// Verified process cleanup can precede Claude's terminal generation frame.
|
|
1635
1764
|
// Retain already displayed text for the existing transcript reader, with
|
|
@@ -1698,7 +1827,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1698
1827
|
acceptedInput = null;
|
|
1699
1828
|
clearFinalAssistantResult();
|
|
1700
1829
|
},
|
|
1701
|
-
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
|
|
1830
|
+
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, steer = false } = {}) {
|
|
1702
1831
|
if (!active || stopped || cancelling) throw Object.assign(
|
|
1703
1832
|
failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
|
|
1704
1833
|
? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
|
|
@@ -1714,14 +1843,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1714
1843
|
const admitted = Promise.withResolvers();
|
|
1715
1844
|
void admitted.promise.catch(() => {});
|
|
1716
1845
|
inFlight.add(uuid);
|
|
1846
|
+
if (steer) {
|
|
1847
|
+
waitingSteers.add(uuid);
|
|
1848
|
+
steeringClient = client;
|
|
1849
|
+
}
|
|
1717
1850
|
const timer = setTimeout(() => admitted.reject(Object.assign(
|
|
1718
1851
|
failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
|
|
1719
1852
|
{ delivery: "uncertain" }
|
|
1720
1853
|
)), timeoutMs);
|
|
1721
1854
|
admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
|
|
1722
1855
|
try {
|
|
1723
|
-
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
|
|
1856
|
+
await Promise.all([client.send(message, { messageId: uuid, sessionId: conversationId })
|
|
1857
|
+
.then(() => { if (steer) backgroundForegroundWork(); }), admitted.promise]);
|
|
1724
1858
|
} catch (error) {
|
|
1859
|
+
waitingSteers.delete(uuid);
|
|
1725
1860
|
if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
|
|
1726
1861
|
throw error;
|
|
1727
1862
|
} finally {
|
|
@@ -1737,7 +1872,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1737
1872
|
}
|
|
1738
1873
|
// Claude Code acknowledges streamed input as queued and folds it into the
|
|
1739
1874
|
// running turn after the current tool call, or starts it once the main
|
|
1740
|
-
// loop is idle. Interrupting instead would kill a running foreground tool
|
|
1875
|
+
// loop is idle. Interrupting instead would kill a running foreground tool;
|
|
1876
|
+
// send() moves such a tool to the background so the steer is read now.
|
|
1741
1877
|
// The next input owns completion now: a result or task finishing before
|
|
1742
1878
|
// it starts must not settle this turn with the previous answer.
|
|
1743
1879
|
outcome = null;
|
|
@@ -11,6 +11,8 @@ import { normalizeNullableText as normalizeAgentText } from "./normalize.js";
|
|
|
11
11
|
// reconnect-required output marks the stored credentials invalid.
|
|
12
12
|
const CODEX_AUTH_PREFLIGHT_TIMEOUT_MS = 15000;
|
|
13
13
|
const CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES = 4096;
|
|
14
|
+
// The host names this run where it lists running work.
|
|
15
|
+
const CODEX_AUTH_PREFLIGHT_EXECUTION = Object.freeze({ label: "Checking the Codex sign-in", operationId: "codex-auth-preflight" });
|
|
14
16
|
|
|
15
17
|
function tailAppend(text = "", chunk = "", maxBytes = CODEX_AUTH_PREFLIGHT_OUTPUT_TAIL_BYTES) {
|
|
16
18
|
const next = `${String(text || "")}${String(chunk || "")}`;
|
|
@@ -60,6 +62,7 @@ async function runCodexAuthPreflight({
|
|
|
60
62
|
command: codexCommand,
|
|
61
63
|
credentialHome: codexAppServerCredentialHome(normalizedToolHomeSource, baseEnv),
|
|
62
64
|
cwd: processCwd,
|
|
65
|
+
execution: CODEX_AUTH_PREFLIGHT_EXECUTION,
|
|
63
66
|
inheritProcessEnv: false,
|
|
64
67
|
mode: "capture",
|
|
65
68
|
timeout: normalizePositiveInteger(timeoutMs, { fallback: CODEX_AUTH_PREFLIGHT_TIMEOUT_MS })
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { execFile } from "node:child_process";
|
|
3
|
-
import { open, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
3
|
+
import { open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
4
|
+
import os from "node:os";
|
|
4
5
|
import path from "node:path";
|
|
5
6
|
import { promisify } from "node:util";
|
|
6
7
|
import { isRecord } from "@jskit-ai/kernel/shared/support/normalize";
|
|
7
8
|
import { normalizeNullableText } from "./normalize.js";
|
|
9
|
+
import { codexDisabledProcessFeatureArguments } from "./codexHomeStorage.js";
|
|
8
10
|
|
|
9
11
|
const execute = promisify(execFile);
|
|
10
12
|
const CODEX_THREAD_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/iu;
|
|
@@ -101,7 +103,8 @@ export function codexInteractiveArguments({
|
|
|
101
103
|
return [
|
|
102
104
|
command,
|
|
103
105
|
...(disableStartupUpdates ? ["-c", "check_for_update_on_startup=false"] : []),
|
|
104
|
-
|
|
106
|
+
// A remote terminal uses its app-server's features; a local one starts its own.
|
|
107
|
+
...(remoteEndpoint ? ["--remote", remoteEndpoint] : codexDisabledProcessFeatureArguments()),
|
|
105
108
|
"--model",
|
|
106
109
|
model,
|
|
107
110
|
"-c",
|
|
@@ -155,10 +158,42 @@ export async function codexAppServerProjectHookTrustConfig(provider, cwd = "", {
|
|
|
155
158
|
return { "hooks.state": state };
|
|
156
159
|
}
|
|
157
160
|
|
|
161
|
+
// What an exported catalogue was made from: the Codex binary, the catalogue
|
|
162
|
+
// source and added models, and the metadata (never the contents) of the home's
|
|
163
|
+
// account and configuration files. A bare command name has no binary identity,
|
|
164
|
+
// so its catalogue is exported on every start.
|
|
165
|
+
async function codexModelCatalogRevision({ command, codexHome, bundled, additionalModels }) {
|
|
166
|
+
if (!path.isAbsolute(command)) return null;
|
|
167
|
+
const identity = async (file) => {
|
|
168
|
+
const found = await stat(file).catch(() => null);
|
|
169
|
+
return found && [found.dev, found.ino, found.size, found.mtimeMs, found.ctimeMs];
|
|
170
|
+
};
|
|
171
|
+
const binary = await identity(command);
|
|
172
|
+
if (!binary) return null;
|
|
173
|
+
return createHash("sha256").update(JSON.stringify({
|
|
174
|
+
command, binary, bundled, additionalModels,
|
|
175
|
+
auth: await identity(path.join(codexHome, "auth.json")),
|
|
176
|
+
config: await identity(path.join(codexHome, "config.toml"))
|
|
177
|
+
})).digest("hex");
|
|
178
|
+
}
|
|
179
|
+
|
|
158
180
|
// Codex's model manager reads model_catalog_json once, before any threads
|
|
159
181
|
// exist. A thread override changes routing, but cannot supply model metadata.
|
|
160
182
|
// Export from the installed CLI so native model definitions stay CLI-owned.
|
|
161
|
-
|
|
183
|
+
// An app-server start reuses the runtime's exported catalogue until its
|
|
184
|
+
// revision changes, so reconnects and idle restarts run no exporter.
|
|
185
|
+
export async function prepareCodexModelCatalog({
|
|
186
|
+
command, runtimeDir, bundled = false, signal, additionalModels = [],
|
|
187
|
+
codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex")
|
|
188
|
+
}) {
|
|
189
|
+
const catalogPath = path.join(runtimeDir, "models.json");
|
|
190
|
+
const revisionPath = path.join(runtimeDir, "models.revision");
|
|
191
|
+
const revisionInput = { command, codexHome, bundled, additionalModels };
|
|
192
|
+
const revision = await codexModelCatalogRevision(revisionInput);
|
|
193
|
+
if (revision && await stat(catalogPath).then(() => true, () => false) &&
|
|
194
|
+
await readFile(revisionPath, "utf8").catch(() => "") === revision) {
|
|
195
|
+
return catalogPath;
|
|
196
|
+
}
|
|
162
197
|
let catalog;
|
|
163
198
|
const models = new Map();
|
|
164
199
|
try {
|
|
@@ -185,15 +220,18 @@ export async function prepareCodexModelCatalog({ command, runtimeDir, bundled =
|
|
|
185
220
|
for (const model of additionalModels) {
|
|
186
221
|
models.set(model.slug, { ...model, priority: ++priority });
|
|
187
222
|
}
|
|
188
|
-
const catalogPath = path.join(runtimeDir, "models.json");
|
|
189
223
|
const temporary = `${catalogPath}.tmp`;
|
|
190
224
|
try {
|
|
191
225
|
signal?.throwIfAborted();
|
|
226
|
+
await rm(revisionPath, { force: true });
|
|
192
227
|
await writeFile(temporary, JSON.stringify({ ...catalog, models: [...models.values()] }), { mode: 0o600 });
|
|
193
228
|
await rename(temporary, catalogPath);
|
|
194
229
|
} finally {
|
|
195
230
|
await rm(temporary, { force: true });
|
|
196
231
|
}
|
|
232
|
+
// The exporter can refresh the account's tokens; record what it left behind.
|
|
233
|
+
const exported = await codexModelCatalogRevision(revisionInput);
|
|
234
|
+
if (exported) await writeFile(revisionPath, exported, { mode: 0o600 });
|
|
197
235
|
return catalogPath;
|
|
198
236
|
}
|
|
199
237
|
|