@jskit-ai/assistant-core 0.1.223 → 0.1.224
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 +1 -1
- package/package.json +1 -1
- package/src/client/conversation/AssistantGoalControl.vue +8 -8
- package/src/server/conversation/claudeHistory.js +9 -3
- package/src/server/conversation/claudeProcess.js +60 -20
- package/src/server/conversation/claudeTurn.js +190 -41
- package/src/server/conversation/codexProvider.js +67 -3
- package/src/server/conversation/providers/claude.js +5 -1
- package/src/server/conversation/providers/claudeDriver.js +5 -1
- package/src/server/conversation/providers/codex.js +325 -216
- package/src/server/conversation/providers/codexDriver.js +14 -1
- package/src/server/conversation/providers/scoped.js +39 -23
- package/src/server/conversation/runtime.js +20 -1
- package/test/claudeConversationSession.test.js +3 -2
- package/test/claudeProcess.test.js +139 -1
- package/test/claudeTurn.test.js +253 -3
- package/test/codexConversationSession.test.js +27 -4
- package/test/codexGoal.test.js +152 -0
- package/test/conversationRuntime.test.js +168 -4
- package/test/conversationSession.test.js +48 -0
package/package.json
CHANGED
|
@@ -6,9 +6,9 @@ const open = ref(false);
|
|
|
6
6
|
const objective = ref("");
|
|
7
7
|
const tokenBudget = ref("");
|
|
8
8
|
const now = ref(Date.now());
|
|
9
|
-
const goal = computed(() => props.state?.goal);
|
|
9
|
+
const goal = computed(() => props.state?.loading || props.state?.available === false ? null : props.state?.goal);
|
|
10
10
|
const running = computed(() => goal.value?.status === "active");
|
|
11
|
-
const label = computed(() => ({
|
|
11
|
+
const label = computed(() => props.state?.loading ? "Loading goal…" : props.state?.available === false ? "Goal unavailable" : ({
|
|
12
12
|
active: "Goal running", paused: "Goal paused", blocked: "Goal blocked",
|
|
13
13
|
usageLimited: "Goal waiting for allowance", budgetLimited: "Goal budget reached", complete: "Goal complete"
|
|
14
14
|
})[goal.value?.status] || "Set goal");
|
|
@@ -21,7 +21,7 @@ const elapsed = computed(() => {
|
|
|
21
21
|
const minutes = Math.floor(total / 60);
|
|
22
22
|
return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
|
|
23
23
|
});
|
|
24
|
-
const canSet = computed(() => typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
|
|
24
|
+
const canSet = computed(() => !props.state?.loading && props.state?.available !== false && typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
|
|
25
25
|
const validBudget = computed(() => props.state?.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
|
|
26
26
|
let timer;
|
|
27
27
|
onMounted(() => watch(() => [props.state?.enabled, running.value, goal.value?.elapsedSeconds, goal.value?.sampledAt], () => {
|
|
@@ -45,7 +45,7 @@ async function setGoal() {
|
|
|
45
45
|
<template #activator="{ props: menuProps }">
|
|
46
46
|
<v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsed} running time`, goal?.objective].filter(Boolean).join('\n')">
|
|
47
47
|
<span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
|
|
48
|
-
<span class="assistant-goal__label">{{ goal ? 'Goal' : 'Set goal' }}</span>
|
|
48
|
+
<span class="assistant-goal__label">{{ state.loading ? 'Loading goal…' : state.available === false ? 'Goal unavailable' : goal ? 'Goal' : 'Set goal' }}</span>
|
|
49
49
|
<span v-if="elapsed" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
|
|
50
50
|
</v-btn>
|
|
51
51
|
</template>
|
|
@@ -54,15 +54,15 @@ async function setGoal() {
|
|
|
54
54
|
<p v-if="goal" class="my-2">{{ goal.objective }}</p>
|
|
55
55
|
<p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
|
|
56
56
|
<p v-if="elapsed" class="text-body-small my-2">Running time: {{ elapsed }}</p>
|
|
57
|
-
<v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.
|
|
58
|
-
<v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.
|
|
59
|
-
<v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">Cancel goal</v-btn>
|
|
57
|
+
<v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
|
|
58
|
+
<v-btn v-else-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? 'Resuming…' : 'Resume goal' }}</v-btn>
|
|
59
|
+
<v-btn v-if="goal && goal.status !== 'complete' && state.cancel" color="error" size="small" variant="text" :disabled="state.pending" @click="state.cancel()">{{ state.action === 'cancel' ? 'Cancelling…' : 'Cancel goal' }}</v-btn>
|
|
60
60
|
<p v-if="running || goal?.status === 'paused'" class="text-body-small mt-2">{{ state.pauseInterruptsTurn ? 'Pause stops the current turn and keeps the goal for later.' : 'Pausing prevents further automatic turns. Use Stop to interrupt the current turn.' }}</p>
|
|
61
61
|
<p v-if="goal && goal.status !== 'complete' && state.cancel" class="text-body-small mt-2">{{ state.cancelInterruptsTurn ? 'Cancel stops the current turn and clears the goal.' : 'Cancel removes this goal. Use Stop to interrupt a turn already running.' }}</p>
|
|
62
62
|
<form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
|
|
63
63
|
<v-textarea v-model="objective" label="Goal objective" rows="2" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
|
|
64
64
|
<v-text-field v-if="state.tokenBudgetSupported !== false" v-model="tokenBudget" label="Token budget (optional)" type="number" min="1" step="1" :disabled="state.pending" :error="!validBudget" hide-details />
|
|
65
|
-
<v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.
|
|
65
|
+
<v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.action === 'set' ? 'Starting…' : 'Start goal' }}</v-btn>
|
|
66
66
|
</form>
|
|
67
67
|
<p v-if="state.error" role="alert" class="text-error text-body-small mt-2">{{ state.error }}</p>
|
|
68
68
|
<slot :goal="goal" />
|
|
@@ -68,14 +68,14 @@ async function claudeHistoryPath({ configRoot, workdir, conversationId }) {
|
|
|
68
68
|
return null;
|
|
69
69
|
}
|
|
70
70
|
|
|
71
|
-
function claudeMessageBlocks(frame, { includeNested = false } = {}) {
|
|
71
|
+
function claudeMessageBlocks(frame, { includeNested = false, commentary = false } = {}) {
|
|
72
72
|
if (frame.type !== "assistant" || frame.parent_tool_use_id && !includeNested) return [];
|
|
73
73
|
const content = frame.message?.content;
|
|
74
74
|
if (!Array.isArray(content)) return [];
|
|
75
75
|
// Native frames split one API message into blocks with distinct UUIDs.
|
|
76
76
|
const messageId = frame.uuid || frame.message.id;
|
|
77
77
|
if (!messageId) return [];
|
|
78
|
-
|
|
78
|
+
commentary ||= content.some((block) => block.type === "tool_use");
|
|
79
79
|
return content.flatMap((block, index) => {
|
|
80
80
|
const text = block.type === "thinking" ? block.thinking : block.type === "text" ? block.text : "";
|
|
81
81
|
return typeof text === "string" && text.trim() ? [{
|
|
@@ -125,6 +125,12 @@ async function readClaudeHistory(options) {
|
|
|
125
125
|
leafUuid = byId.get(leafUuid)?.parentUuid;
|
|
126
126
|
}
|
|
127
127
|
}
|
|
128
|
+
const toolMessages = new Set();
|
|
129
|
+
for (const frame of frames) {
|
|
130
|
+
if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
|
|
131
|
+
if (frame.type === "assistant" && !frame.parent_tool_use_id && frame.message?.id &&
|
|
132
|
+
frame.message.content?.some(block => block.type === "tool_use")) toolMessages.add(frame.message.id);
|
|
133
|
+
}
|
|
128
134
|
for (const frame of frames) {
|
|
129
135
|
if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
|
|
130
136
|
const status = frame.attachment;
|
|
@@ -175,7 +181,7 @@ async function readClaudeHistory(options) {
|
|
|
175
181
|
}
|
|
176
182
|
}
|
|
177
183
|
}
|
|
178
|
-
for (const message of claudeMessageBlocks(frame)) {
|
|
184
|
+
for (const message of claudeMessageBlocks(frame, { commentary: toolMessages.has(frame.message?.id) })) {
|
|
179
185
|
messages.set(message.id, { ...message, userId: lastUserId });
|
|
180
186
|
}
|
|
181
187
|
}
|
|
@@ -220,10 +220,17 @@ export function claudeCatalogueModels(initialization) {
|
|
|
220
220
|
export function createClaudeAccountQueries({ createProcess, accountIdentity, processes = () => [], isClosing = () => false }) {
|
|
221
221
|
const error = message => Object.assign(new Error(message), { code: "claude_account_query_failed" });
|
|
222
222
|
const accountProcessStops = new Set();
|
|
223
|
+
const accountReads = new Set();
|
|
223
224
|
let catalog;
|
|
224
|
-
|
|
225
|
+
const catalogStarts = new Map();
|
|
225
226
|
let planUsage;
|
|
226
|
-
|
|
227
|
+
const planUsagePending = new Map();
|
|
228
|
+
let revision = 0;
|
|
229
|
+
|
|
230
|
+
function assertCurrent(expectedRevision) {
|
|
231
|
+
if (expectedRevision !== revision) throw error("Claude account query was cancelled for reconnection.");
|
|
232
|
+
if (isClosing()) throw error("Claude is reconnecting.");
|
|
233
|
+
}
|
|
227
234
|
|
|
228
235
|
async function stopAccountProcess(native) {
|
|
229
236
|
accountProcessStops.add(native);
|
|
@@ -231,50 +238,73 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
|
|
|
231
238
|
accountProcessStops.delete(native);
|
|
232
239
|
}
|
|
233
240
|
|
|
234
|
-
async function readAccountProcess(identity, read) {
|
|
235
|
-
|
|
241
|
+
async function readAccountProcess(identity, expectedRevision, read) {
|
|
242
|
+
assertCurrent(expectedRevision);
|
|
236
243
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
244
|
+
assertCurrent(expectedRevision);
|
|
237
245
|
const owned = [...processes()].find((entry) =>
|
|
238
246
|
entry.process && !entry.stopping && entry.accountIdentity === identity);
|
|
239
|
-
|
|
240
|
-
|
|
247
|
+
const controller = new AbortController();
|
|
248
|
+
accountReads.add(controller);
|
|
249
|
+
const cancelled = Promise.withResolvers();
|
|
250
|
+
const abort = () => cancelled.reject(controller.signal.reason);
|
|
251
|
+
controller.signal.addEventListener("abort", abort, { once: true });
|
|
252
|
+
// Startup owns its abort and exit proof before it can return a process.
|
|
253
|
+
// Observe cancellation while awaiting that factory as well.
|
|
254
|
+
cancelled.promise.catch(() => {});
|
|
255
|
+
let native;
|
|
241
256
|
try {
|
|
242
|
-
|
|
257
|
+
native = owned?.process || await createProcess({ signal: controller.signal });
|
|
258
|
+
controller.signal.throwIfAborted();
|
|
259
|
+
return await Promise.race([read(native), cancelled.promise]);
|
|
243
260
|
} finally {
|
|
244
|
-
|
|
261
|
+
accountReads.delete(controller);
|
|
262
|
+
controller.signal.removeEventListener("abort", abort);
|
|
263
|
+
if (native && !owned) await stopAccountProcess(native);
|
|
245
264
|
}
|
|
246
265
|
}
|
|
247
266
|
|
|
248
267
|
return Object.freeze({
|
|
249
268
|
async readCatalogue(context) {
|
|
250
|
-
|
|
269
|
+
const expectedRevision = revision;
|
|
270
|
+
assertCurrent(expectedRevision);
|
|
251
271
|
const identity = await accountIdentity(context);
|
|
272
|
+
assertCurrent(expectedRevision);
|
|
252
273
|
if (!catalog || catalog.identity !== identity || Date.now() - catalog.at > 600_000) {
|
|
253
|
-
|
|
254
|
-
const value = await readAccountProcess(identity, (native) => native.initialization);
|
|
274
|
+
if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
|
|
275
|
+
const value = await readAccountProcess(identity, expectedRevision, (native) => native.initialization);
|
|
255
276
|
if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
|
|
277
|
+
assertCurrent(expectedRevision);
|
|
256
278
|
catalog = { at: Date.now(), identity, value };
|
|
257
|
-
|
|
258
|
-
|
|
279
|
+
return value;
|
|
280
|
+
})().finally(() => { catalogStarts.delete(identity); }));
|
|
281
|
+
return catalogStarts.get(identity);
|
|
259
282
|
}
|
|
260
283
|
return catalog.value;
|
|
261
284
|
},
|
|
262
285
|
async readPlanUsage(context) {
|
|
286
|
+
const expectedRevision = revision;
|
|
287
|
+
assertCurrent(expectedRevision);
|
|
263
288
|
const identity = await accountIdentity(context);
|
|
289
|
+
assertCurrent(expectedRevision);
|
|
264
290
|
if (planUsage?.identity === identity && Date.now() - planUsage.checkedAt < 60_000) return planUsage;
|
|
265
|
-
if (planUsagePending) return planUsagePending;
|
|
266
|
-
|
|
267
|
-
const value = await readAccountProcess(identity, (native) => native.client.request(
|
|
291
|
+
if (planUsagePending.has(identity)) return planUsagePending.get(identity);
|
|
292
|
+
const pending = (async () => {
|
|
293
|
+
const value = await readAccountProcess(identity, expectedRevision, (native) => native.client.request(
|
|
268
294
|
{ subtype: "get_usage", skip_behaviors: true }));
|
|
269
295
|
if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
|
|
296
|
+
assertCurrent(expectedRevision);
|
|
270
297
|
planUsage = { ...claudePlanUsage(value), identity };
|
|
271
298
|
return planUsage;
|
|
272
|
-
})().finally(() => { planUsagePending
|
|
273
|
-
|
|
299
|
+
})().finally(() => { planUsagePending.delete(identity); });
|
|
300
|
+
planUsagePending.set(identity, pending);
|
|
301
|
+
return pending;
|
|
274
302
|
},
|
|
275
303
|
invalidateUsage() { planUsage = null; },
|
|
276
304
|
async invalidate() {
|
|
277
|
-
|
|
305
|
+
revision++;
|
|
306
|
+
for (const controller of accountReads) controller.abort(error("Claude account query was cancelled for reconnection."));
|
|
307
|
+
await Promise.allSettled([...catalogStarts.values(), ...planUsagePending.values()]);
|
|
278
308
|
catalog = null;
|
|
279
309
|
planUsage = null;
|
|
280
310
|
for (const native of accountProcessStops) await stopAccountProcess(native);
|
|
@@ -313,7 +343,8 @@ export async function createClaudeCodeProcess({
|
|
|
313
343
|
env: environment, stream: true, limits: executionLimits });
|
|
314
344
|
await onStarted?.(native.id, stop);
|
|
315
345
|
signal?.throwIfAborted();
|
|
316
|
-
|
|
346
|
+
const stream = Duplex.from({ readable: native.stdout, writable: native.stdin });
|
|
347
|
+
client = createClaudeJsonClient({ stream, onEvent, onFailure,
|
|
317
348
|
onControlRequest(request, context) {
|
|
318
349
|
if (request.subtype === "hook_callback" && commandWrapper && request.callback_id === "jskit-command-wrapper") {
|
|
319
350
|
return nativeCommandHook(commandWrapper, request.input);
|
|
@@ -334,6 +365,15 @@ export async function createClaudeCodeProcess({
|
|
|
334
365
|
});
|
|
335
366
|
})
|
|
336
367
|
]) : initializing);
|
|
368
|
+
// Children can retain stdout after the parent exits. Keep observing the
|
|
369
|
+
// execution receipt after startup instead of waiting forever for pipe EOF.
|
|
370
|
+
if (native.exited) void native.exited.then(processExit => {
|
|
371
|
+
if (!stopped && !stopping) stream.destroy(Object.assign(new Error("Claude exited unexpectedly."), {
|
|
372
|
+
code: "assistant_claude_observation_lost", processExit
|
|
373
|
+
}));
|
|
374
|
+
}, error => {
|
|
375
|
+
if (!stopped && !stopping) stream.destroy(error);
|
|
376
|
+
});
|
|
337
377
|
return { client, initialization, executionId: native.id, stop };
|
|
338
378
|
} catch (error) {
|
|
339
379
|
// A rejected start may already own an execution and its cleanup proof.
|