@jskit-ai/assistant-core 0.2.2 → 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 +105 -58
- package/src/server/conversation/claudeTurn.js +179 -59
- 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 +234 -29
- 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 +13 -1
- package/src/server/conversation/openCodeClient.js +34 -0
- package/src/server/conversation/openCodeProcess.js +37 -5
- package/src/server/conversation/openCodeRuntime.js +225 -64
- package/src/server/conversation/providers/claudeDriver.js +3 -3
- package/src/server/conversation/providers/codexDriver.js +10 -4
- package/src/server/conversation/providers/opencodeDriver.js +4 -2
- package/src/server/conversation/runtime.js +72 -2
- package/src/server/hosts/codexConfiguration.js +4 -0
- package/test/assistantScroll.browser.test.js +93 -0
- package/test/claudeLifecycle.test.js +293 -0
- package/test/claudeProcess.test.js +195 -51
- package/test/claudeTurn.test.js +174 -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 +21 -1
- package/test/conversationNativeStorage.test.js +57 -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,6 +14,7 @@ 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;
|
|
@@ -27,6 +28,16 @@ function nativeWorkSettled(entry) {
|
|
|
27
28
|
return !entry.sending && !entry.process && !entry.executionId && !entry.command && !entry.stopPending;
|
|
28
29
|
}
|
|
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
|
+
|
|
30
41
|
export function claudeNativeMessageId(id) {
|
|
31
42
|
const value = createHash("sha256").update(String(id)).digest("hex");
|
|
32
43
|
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-8${value.slice(17, 20)}-${value.slice(20, 32)}`;
|
|
@@ -181,9 +192,13 @@ export function createClaudeConversationOwner({
|
|
|
181
192
|
// Otherwise the host retains entries and their original checkpoint order.
|
|
182
193
|
const bound = Boolean(store.releaseExecution);
|
|
183
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);
|
|
184
199
|
|
|
185
200
|
function createAccountQueries({ accountIdentity, createProcess }) {
|
|
186
|
-
return createClaudeAccountQueries({ accountIdentity,
|
|
201
|
+
return createClaudeAccountQueries({ accountIdentity, configRoot,
|
|
187
202
|
processes: () => entries.values(), isClosing: () => closing, createProcess });
|
|
188
203
|
}
|
|
189
204
|
|
|
@@ -227,6 +242,12 @@ export function createClaudeConversationOwner({
|
|
|
227
242
|
return readClaudeHistory({ configRoot, workdir: entry.nativeWorkdir, conversationId: entry.id });
|
|
228
243
|
}
|
|
229
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
|
+
|
|
230
251
|
async function open(context, { conversationId = "", mainId, create = false,
|
|
231
252
|
cleanupExecutionId, recoveringCleanup = false, nativeWorkdir = context.workdir } = {}) {
|
|
232
253
|
const main = !conversationId || conversationId === mainId;
|
|
@@ -246,7 +267,7 @@ export function createClaudeConversationOwner({
|
|
|
246
267
|
let saved = store.read(context, id);
|
|
247
268
|
if (saved && typeof saved.then === "function") saved = await saved;
|
|
248
269
|
if (!main && !saved && !create && !recoveringCleanup) throw createError("This Claude conversation is unavailable.");
|
|
249
|
-
const state = saved
|
|
270
|
+
const state = savedState(saved, id);
|
|
250
271
|
const entry = { id, key, main, persistent: state.persistent === true, context, process: null, startupCleanup: null,
|
|
251
272
|
executionId: state.executionId || (recoveringCleanup ? cleanupExecutionId : ""), preparationRevision: 0,
|
|
252
273
|
accountIdentities: state.accountIdentities || (state.accountIdentity ? { anthropic: state.accountIdentity } : {}),
|
|
@@ -326,9 +347,9 @@ export function createClaudeConversationOwner({
|
|
|
326
347
|
|
|
327
348
|
async function ensureReady(entry) {
|
|
328
349
|
if (entry.process) {
|
|
329
|
-
// Status reads retain active instructions
|
|
330
|
-
|
|
331
|
-
|
|
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);
|
|
332
353
|
} else {
|
|
333
354
|
await bindAccount(entry);
|
|
334
355
|
if (entry.executionId && !hasPendingClaudeStartup(entry)) await entry.nativeTurn.stopProcess("Claude's previous process exit could not be confirmed.");
|
|
@@ -707,7 +728,7 @@ export function createClaudeConversationOwner({
|
|
|
707
728
|
else await input.onPromptSending?.({ threadId: entry.id, displayAttachments: input.displayAttachments, turnMetadata: actorMetadata });
|
|
708
729
|
if (input.signal?.aborted) throw Object.assign(createError("The goal command was stopped before delivery."), { delivery: "not-sent" });
|
|
709
730
|
await entry.nativeTurn.send(command ? entry.process.client : native.client, command ? preparation.message(entry, input) : prompt,
|
|
710
|
-
{ messageId: uuid, accept, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
731
|
+
{ messageId: uuid, accept, steer: steering, ...(command ? { timeoutMs: command.timeoutMs } : {}) });
|
|
711
732
|
} catch (error) {
|
|
712
733
|
if (!command) await entry.nativeTurn.stopProcess(error.message);
|
|
713
734
|
throw error;
|
|
@@ -763,9 +784,11 @@ export function createClaudeConversationOwner({
|
|
|
763
784
|
threadId: entry.id, turnId: accepted ? turnId : "" };
|
|
764
785
|
}
|
|
765
786
|
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
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));
|
|
769
792
|
}
|
|
770
793
|
|
|
771
794
|
function hasActiveConversation(binding) {
|
|
@@ -804,16 +827,28 @@ export function createClaudeConversationOwner({
|
|
|
804
827
|
return [...entries.values()].filter((entry) => entry.context.key === context.key);
|
|
805
828
|
}
|
|
806
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.
|
|
807
833
|
async function closeSession(context, application) {
|
|
808
|
-
|
|
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 }) {
|
|
809
845
|
closingSessions.add(ctx.key);
|
|
810
846
|
try {
|
|
811
|
-
await
|
|
847
|
+
if (recover) await recoverSavedCustody(ctx);
|
|
812
848
|
const proofs = await drain(ctx.key);
|
|
813
849
|
const terminal = await application.terminals.close(ctx.sessionId);
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
}
|
|
850
|
+
// A forgotten entry is drained: everything it held is in its saved record.
|
|
851
|
+
if (terminal?.ok !== false && forgetEntries) forget(ctx.key);
|
|
817
852
|
return { ok: terminal?.ok !== false, closed: proofs.length + Number(terminal?.closed || 0),
|
|
818
853
|
processExitProof: proofs.at(-1) || { exited: true, scopeEmpty: true }, processExitProofs: proofs };
|
|
819
854
|
} finally {
|
|
@@ -821,19 +856,27 @@ export function createClaudeConversationOwner({
|
|
|
821
856
|
}
|
|
822
857
|
}
|
|
823
858
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
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);
|
|
835
871
|
}
|
|
836
|
-
|
|
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 }));
|
|
837
880
|
}
|
|
838
881
|
|
|
839
882
|
async function invalidateRuntimes(input = {}, application, account) {
|
|
@@ -842,44 +885,78 @@ export function createClaudeConversationOwner({
|
|
|
842
885
|
return stopSelected(entry => store.modelProviderId(entry) === input.modelProviderId,
|
|
843
886
|
"The provider connection changed.");
|
|
844
887
|
}
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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 };
|
|
851
897
|
}
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
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)));
|
|
859
919
|
let closed = 0;
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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(" ") } : {}) };
|
|
868
930
|
}
|
|
869
931
|
|
|
870
932
|
async function drain(key) {
|
|
871
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
|
+
}
|
|
872
944
|
// Startup already lends cleanup before initialization completes. Stop it
|
|
873
945
|
// first; waiting for the preparation queue first can exhaust host shutdown.
|
|
874
|
-
|
|
946
|
+
await stop();
|
|
875
947
|
await Promise.allSettled(selected.map(entry => entry.nativeTurn.whenIdle()));
|
|
876
|
-
|
|
877
|
-
|
|
948
|
+
await stop();
|
|
949
|
+
// Every other entry still stopped; the first failure keeps the close retryable.
|
|
950
|
+
if (failure) throw failure;
|
|
878
951
|
return proofs;
|
|
879
952
|
}
|
|
880
953
|
|
|
881
954
|
function forget(contextKey) {
|
|
882
|
-
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
|
+
}
|
|
883
960
|
}
|
|
884
961
|
|
|
885
962
|
async function close(entry) {
|
|
@@ -904,12 +981,22 @@ export function createClaudeConversationOwner({
|
|
|
904
981
|
}
|
|
905
982
|
}
|
|
906
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.
|
|
907
986
|
async function stopSelected(select, reason) {
|
|
908
987
|
let closed = 0;
|
|
909
|
-
|
|
910
|
-
|
|
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
|
+
}
|
|
911
998
|
}
|
|
912
|
-
return { ok:
|
|
999
|
+
return { ok: failed.length === 0, closed, failed };
|
|
913
1000
|
}
|
|
914
1001
|
|
|
915
1002
|
async function wait(entry, input, { context, acquire }) {
|
|
@@ -1172,7 +1259,7 @@ export function createClaudeConversationOwner({
|
|
|
1172
1259
|
}
|
|
1173
1260
|
|
|
1174
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,
|
|
1175
|
-
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1262
|
+
interrupt, read, inspectAdmission, hasActiveTemporaryConversation, hasActiveConversation, retireConversationHistory, reconcileSessions, restoreSessionEntries, closeSession, releaseSession, closeProject, invalidateRuntimes, drain, forget, close, deleteConversation, recover, stopSelected, wait, stopConversation,
|
|
1176
1263
|
readGoal, updateGoal, renewal, run, steer, cancel });
|
|
1177
1264
|
}
|
|
1178
1265
|
|
|
@@ -1189,6 +1276,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1189
1276
|
// Inputs Claude has begun reading. A queued steer joins the running loop, so
|
|
1190
1277
|
// one result can finish several of them; queued-only input stays in flight.
|
|
1191
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;
|
|
1192
1283
|
const tasks = new Set();
|
|
1193
1284
|
let turnId = "";
|
|
1194
1285
|
let messageId = "";
|
|
@@ -1222,6 +1313,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1222
1313
|
clearFinalAssistantResult();
|
|
1223
1314
|
}
|
|
1224
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
|
+
|
|
1225
1330
|
function currentReceiptOwner(owner) {
|
|
1226
1331
|
return Boolean(entry && owner && inputOwner === owner && acceptedInput === owner &&
|
|
1227
1332
|
owner.commandId === currentCommandId && entry.process && entry.process === owner.process &&
|
|
@@ -1466,6 +1571,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1466
1571
|
currentCommandId = uuid;
|
|
1467
1572
|
startedInputs.add(uuid);
|
|
1468
1573
|
}
|
|
1574
|
+
if (frame.state !== "queued") waitingSteers.delete(uuid);
|
|
1469
1575
|
if (frame.state === "completed") {
|
|
1470
1576
|
inFlight.delete(uuid);
|
|
1471
1577
|
startedInputs.delete(uuid);
|
|
@@ -1509,6 +1615,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1509
1615
|
for (const task of frame.tasks || []) if (task.task_id && !task.ambient) tasks.add(task.task_id);
|
|
1510
1616
|
}
|
|
1511
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();
|
|
1512
1620
|
if (["task_notification", "task_updated"].includes(frame.subtype) &&
|
|
1513
1621
|
["completed", "failed", "stopped", "killed"].includes(frame.status || frame.patch?.status)) {
|
|
1514
1622
|
tasks.delete(frame.task_id);
|
|
@@ -1596,7 +1704,10 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1596
1704
|
for (const id of pendingAssistantMessages.keys()) await flushAssistantMessages(id, role);
|
|
1597
1705
|
const output = typeof frame.structured_output === "object" ? JSON.stringify(frame.structured_output) : String(frame.result || "");
|
|
1598
1706
|
checkOutput(output);
|
|
1599
|
-
|
|
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;
|
|
1600
1711
|
result = failed ? "" : output;
|
|
1601
1712
|
if (result && ![...messages.values()].some((block) => block.complete && block.role === "assistant" && block.text === result)) {
|
|
1602
1713
|
await publishMessage({ id: `claude_${frame.uuid || turnId}_result`, role: "assistant", text: result, complete: true,
|
|
@@ -1646,6 +1757,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1646
1757
|
admissions.clear();
|
|
1647
1758
|
inFlight.clear();
|
|
1648
1759
|
startedInputs.clear();
|
|
1760
|
+
waitingSteers.clear();
|
|
1761
|
+
steeringClient = null;
|
|
1649
1762
|
tasks.clear();
|
|
1650
1763
|
// Verified process cleanup can precede Claude's terminal generation frame.
|
|
1651
1764
|
// Retain already displayed text for the existing transcript reader, with
|
|
@@ -1714,7 +1827,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1714
1827
|
acceptedInput = null;
|
|
1715
1828
|
clearFinalAssistantResult();
|
|
1716
1829
|
},
|
|
1717
|
-
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000 } = {}) {
|
|
1830
|
+
async send(client, message, { messageId: uuid, accept, timeoutMs = 30_000, steer = false } = {}) {
|
|
1718
1831
|
if (!active || stopped || cancelling) throw Object.assign(
|
|
1719
1832
|
failure("Claude has no active turn for this message.", !active && !stopped && !cancelling
|
|
1720
1833
|
? "conversation_steer_turn_finished" : "assistant_claude_turn_failed"),
|
|
@@ -1730,14 +1843,20 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1730
1843
|
const admitted = Promise.withResolvers();
|
|
1731
1844
|
void admitted.promise.catch(() => {});
|
|
1732
1845
|
inFlight.add(uuid);
|
|
1846
|
+
if (steer) {
|
|
1847
|
+
waitingSteers.add(uuid);
|
|
1848
|
+
steeringClient = client;
|
|
1849
|
+
}
|
|
1733
1850
|
const timer = setTimeout(() => admitted.reject(Object.assign(
|
|
1734
1851
|
failure("Claude has not acknowledged this prompt. Its delivery is uncertain.", "assistant_claude_admission_unknown"),
|
|
1735
1852
|
{ delivery: "uncertain" }
|
|
1736
1853
|
)), timeoutMs);
|
|
1737
1854
|
admissions.set(uuid, { ...admitted, accept, timer, receiptOwner });
|
|
1738
1855
|
try {
|
|
1739
|
-
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]);
|
|
1740
1858
|
} catch (error) {
|
|
1859
|
+
waitingSteers.delete(uuid);
|
|
1741
1860
|
if (inputOwner === receiptOwner) invalidateFinalAssistantResult();
|
|
1742
1861
|
throw error;
|
|
1743
1862
|
} finally {
|
|
@@ -1753,7 +1872,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent = async (
|
|
|
1753
1872
|
}
|
|
1754
1873
|
// Claude Code acknowledges streamed input as queued and folds it into the
|
|
1755
1874
|
// running turn after the current tool call, or starts it once the main
|
|
1756
|
-
// 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.
|
|
1757
1877
|
// The next input owns completion now: a result or task finishing before
|
|
1758
1878
|
// it starts must not settle this turn with the previous answer.
|
|
1759
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
|
|