@jskit-ai/assistant-core 0.2.33 → 0.2.34
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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
## Unreleased
|
|
4
4
|
|
|
5
|
+
- A Bash command that Claude steering moves to the background keeps its own time limit: its `timeout`, or Claude's two-minute default, counted from when it started. Moved by the `background_tasks` control (Ctrl+B) so a person's message reaches Claude, the command used to lose that limit, so a wait loop that never ended held the turn in its background phase (and the host's Save behind it) until it failed half an hour later. When its limit passes, the shared Claude turn stops its task with the `stop_task` control and publishes the `steered_command_limit` diagnostic (`steered_command_stop_failed` when Claude refuses it); Claude answers the task notification and the turn settles as after a foreground timeout. Commands Claude starts with `run_in_background`, and subagents, keep running as before.
|
|
5
6
|
- Claude's model list is kept once per account in the server process instead of once per account-query owner, as plan usage already is: an owner the host creates again (a restarted service, a reopened project) answers `readCatalogue` from the list another owner read, without starting a tool-free Claude, while the account's credentials and Claude Code are unchanged. A credentials change (sign-in, sign-out, token refresh) or a Claude Code update still reads it again, and an owner's `invalidate()` drops the list of every account it used, for every owner.
|
|
6
7
|
- Test fixture only: the responsive assistant fixture's avatar is drawn in the theme's `primary` and `on-primary` roles instead of a raw `#6750a4` and `white`, so it follows the dark scheme like the rest of the conversation. A package test runs `jskit check m3`'s raw-colour rule over the fixture, which the check itself skips with every `fixtures/` folder.
|
|
7
8
|
- The assistant settings resource (`assistantConfigResource`) has a required boolean `canUpdate` attribute: whether the signed-in person may change the settings, as the server decides (`@jskit-ai/assistant-runtime`'s settings pane shows Save only when it is true).
|
|
@@ -8,14 +8,14 @@
|
|
|
8
8
|
"build": "vite build"
|
|
9
9
|
},
|
|
10
10
|
"dependencies": {
|
|
11
|
-
"@jskit-ai/assistant-core": "0.2.
|
|
12
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
13
|
-
"@jskit-ai/kernel": "0.2.
|
|
11
|
+
"@jskit-ai/assistant-core": "0.2.34",
|
|
12
|
+
"@jskit-ai/http-runtime": "0.2.16",
|
|
13
|
+
"@jskit-ai/kernel": "0.2.16",
|
|
14
14
|
"@jskit-ai/material-web": "0.1.4",
|
|
15
15
|
"vue": "^3.5.13",
|
|
16
16
|
"vuetify": "^4.0.0",
|
|
17
|
-
"@jskit-ai/connectors-core": "0.2.
|
|
18
|
-
"@jskit-ai/connectors-catalog": "0.2.
|
|
17
|
+
"@jskit-ai/connectors-core": "0.2.20",
|
|
18
|
+
"@jskit-ai/connectors-catalog": "0.2.20"
|
|
19
19
|
},
|
|
20
20
|
"devDependencies": {
|
|
21
21
|
"@vitejs/plugin-vue": "^6.0.7",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jskit-ai/assistant-core",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.34",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"test": "jskit test"
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"@ai-sdk/togetherai": "^3.0.49",
|
|
53
53
|
"@ai-sdk/xai": "^4.0.58",
|
|
54
54
|
"@jskit-ai/agent": "0.1.2",
|
|
55
|
-
"@jskit-ai/resource-core": "0.1.
|
|
55
|
+
"@jskit-ai/resource-core": "0.1.207",
|
|
56
56
|
"@openrouter/ai-sdk-provider": "^3.0.0",
|
|
57
57
|
"ai": "^7.0.152",
|
|
58
58
|
"json-rest-schema": "^1.0.17",
|
|
@@ -60,8 +60,8 @@
|
|
|
60
60
|
"ws": "^8.20.1"
|
|
61
61
|
},
|
|
62
62
|
"peerDependencies": {
|
|
63
|
-
"@jskit-ai/http-runtime": "0.2.
|
|
64
|
-
"@jskit-ai/kernel": "0.2.
|
|
63
|
+
"@jskit-ai/http-runtime": "0.2.16",
|
|
64
|
+
"@jskit-ai/kernel": "0.2.16",
|
|
65
65
|
"vue": "^3.5.13",
|
|
66
66
|
"vuetify": "^4.3.0"
|
|
67
67
|
},
|
|
@@ -2,6 +2,7 @@ import { AppError } from "@jskit-ai/kernel/server/runtime/errors";
|
|
|
2
2
|
import { claudeMessageBlocks } from "./claudeHistory.js";
|
|
3
3
|
import { stopClaudeCodeProcess } from "./claudeProcess.js";
|
|
4
4
|
import { createClaudeConversationAdapter } from "./providers/claude.js";
|
|
5
|
+
import { createClaudeSteeringBackground } from "./claudeSteeringBackground.js";
|
|
5
6
|
|
|
6
7
|
// messageCode names the sentence, since one turn-failure code covers several.
|
|
7
8
|
export function claudeTurnFailure(message, { code = "assistant_claude_turn_failed", messageCode } = {}) {
|
|
@@ -143,6 +144,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
143
144
|
const waitingSteers = new Set();
|
|
144
145
|
let steeringClient = null;
|
|
145
146
|
const tasks = new Set();
|
|
147
|
+
const steeringBackground = createClaudeSteeringBackground({ publish, running: taskId => tasks.has(taskId) && !stopped && !cancelling,
|
|
148
|
+
counts: () => ({ pendingCommands: inFlight.size, backgroundTasks: tasks.size }) });
|
|
146
149
|
let turnId = "";
|
|
147
150
|
let messageId = "";
|
|
148
151
|
let currentCommandId = "";
|
|
@@ -181,6 +184,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
181
184
|
assistantRoles.clear();
|
|
182
185
|
result = "";
|
|
183
186
|
outcome = null;
|
|
187
|
+
steeringBackground.clear();
|
|
184
188
|
invalidateFinalAssistantResult();
|
|
185
189
|
}
|
|
186
190
|
|
|
@@ -189,13 +193,8 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
189
193
|
// and subagents to the background: they keep running and report completion,
|
|
190
194
|
// and the blocked tool call returns so Claude reads the steer now.
|
|
191
195
|
function backgroundForegroundWork() {
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
void client.request({ subtype: "background_tasks" }).then(
|
|
195
|
-
() => publish({ type: "diagnostic", stage: "steering_backgrounded", pendingCommands: inFlight.size, backgroundTasks: tasks.size }),
|
|
196
|
-
// An older CLI still reads the steer when its tool call returns.
|
|
197
|
-
error => publish({ type: "diagnostic", stage: "steering_background_failed", error: error.message })
|
|
198
|
-
).catch(() => {});
|
|
196
|
+
if (!waitingSteers.size || !steeringClient?.request || stopped || cancelling) return;
|
|
197
|
+
steeringBackground.background(steeringClient);
|
|
199
198
|
}
|
|
200
199
|
|
|
201
200
|
function currentReceiptOwner(owner) {
|
|
@@ -479,6 +478,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
479
478
|
}
|
|
480
479
|
const previousTaskCount = tasks.size;
|
|
481
480
|
updateClaudeBackgroundTasks(tasks, frame);
|
|
481
|
+
steeringBackground.received(frame);
|
|
482
482
|
// A command Claude started before reading a waiting steer would hold it too.
|
|
483
483
|
if (frame.subtype === "task_started") backgroundForegroundWork();
|
|
484
484
|
// Inventory removal can precede the task notification and the main model's
|
|
@@ -528,6 +528,7 @@ export function createClaudeConversationTurn({ conversationId, onEvent, process,
|
|
|
528
528
|
if (apiMessageId && frame.message.content?.some(block => block.type === "tool_use")) {
|
|
529
529
|
await flushAssistantMessages(apiMessageId, "commentary");
|
|
530
530
|
}
|
|
531
|
+
for (const block of frame.message?.content || []) steeringBackground.called(block);
|
|
531
532
|
for (const block of claudeMessageBlocks(frame)) {
|
|
532
533
|
block.userId = currentCommandId;
|
|
533
534
|
checkOutput(block.text, block.role);
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Claude Code's limit for a Bash call that names none (its BASH_DEFAULT_TIMEOUT_MS default).
|
|
2
|
+
const CLAUDE_BASH_DEFAULT_TIMEOUT_MS = 120_000;
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Steering's request that Claude move its foreground work to the background
|
|
6
|
+
* (Claude's Ctrl+B), and the time limit each Bash call it moves keeps.
|
|
7
|
+
*
|
|
8
|
+
* A moved call loses Claude's own limit, so a wait loop that never ends would
|
|
9
|
+
* hold the turn for good. Each foreground Bash call keeps its limit (its
|
|
10
|
+
* `timeout`, or Claude's default), counted from when it started: once steering
|
|
11
|
+
* has moved it, its task is stopped with Claude's `stop_task` control when that
|
|
12
|
+
* limit passes, as Claude would have stopped it in the foreground. Claude then
|
|
13
|
+
* answers the task notification and the turn settles. A call Claude ran with
|
|
14
|
+
* `run_in_background` has no limit and is never tracked.
|
|
15
|
+
* @param {{
|
|
16
|
+
* publish: (event: Record<string, any>) => unknown,
|
|
17
|
+
* running: (taskId: string) => boolean,
|
|
18
|
+
* counts: () => { pendingCommands: number, backgroundTasks: number }
|
|
19
|
+
* }} dependencies
|
|
20
|
+
*/
|
|
21
|
+
export function createClaudeSteeringBackground({ publish, running, counts }) {
|
|
22
|
+
const calls = new Map();
|
|
23
|
+
const timers = new Map();
|
|
24
|
+
/** @type {{ request: (request: Record<string, any>) => Promise<unknown> } | null} */
|
|
25
|
+
let client = null;
|
|
26
|
+
|
|
27
|
+
// One control request; its diagnostic reports Claude's answer.
|
|
28
|
+
function control(request, stage, failedStage) {
|
|
29
|
+
void client.request(request).then(
|
|
30
|
+
() => publish({ type: "diagnostic", stage, ...counts() }),
|
|
31
|
+
error => publish({ type: "diagnostic", stage: failedStage, error: error.message })
|
|
32
|
+
).catch(() => {});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function schedule(call) {
|
|
36
|
+
if (!call.steered || !call.taskId || timers.has(call.taskId) || !running(call.taskId)) return;
|
|
37
|
+
const timer = setTimeout(() => {
|
|
38
|
+
timers.delete(call.taskId);
|
|
39
|
+
if (running(call.taskId)) control({ subtype: "stop_task", task_id: call.taskId }, "steered_command_limit", "steered_command_stop_failed");
|
|
40
|
+
}, Math.max(0, call.startedAt + call.limitMs - Date.now()));
|
|
41
|
+
timer.unref();
|
|
42
|
+
timers.set(call.taskId, timer);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Asks Claude to move every running foreground call to the background. An
|
|
47
|
+
* older CLI refuses, and still reads the steer when its tool call returns.
|
|
48
|
+
* @param {{ request: (request: Record<string, any>) => Promise<unknown> }} steeringClient
|
|
49
|
+
*/
|
|
50
|
+
function background(steeringClient) {
|
|
51
|
+
client = steeringClient;
|
|
52
|
+
for (const call of calls.values()) {
|
|
53
|
+
call.steered = true;
|
|
54
|
+
schedule(call);
|
|
55
|
+
}
|
|
56
|
+
control({ subtype: "background_tasks" }, "steering_backgrounded", "steering_background_failed");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// A content block of an assistant frame: a foreground Bash call starts now.
|
|
60
|
+
function called(block) {
|
|
61
|
+
if (block.type !== "tool_use" || block.name !== "Bash" || !block.id || calls.has(block.id) ||
|
|
62
|
+
block.input?.run_in_background === true) return;
|
|
63
|
+
const timeout = block.input?.timeout;
|
|
64
|
+
calls.set(block.id, { startedAt: Date.now(), taskId: "", steered: false,
|
|
65
|
+
limitMs: Number.isSafeInteger(timeout) && timeout > 0 ? timeout : CLAUDE_BASH_DEFAULT_TIMEOUT_MS });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// A system frame, after the turn has counted its tasks: task_started names the
|
|
69
|
+
// call its task runs, and a task that has finished needs no limit.
|
|
70
|
+
function received(frame) {
|
|
71
|
+
const call = frame.subtype === "task_started" ? calls.get(frame.tool_use_id) : null;
|
|
72
|
+
if (call) {
|
|
73
|
+
call.taskId = frame.task_id;
|
|
74
|
+
schedule(call);
|
|
75
|
+
}
|
|
76
|
+
for (const [taskId, timer] of timers) {
|
|
77
|
+
if (!running(taskId)) {
|
|
78
|
+
clearTimeout(timer);
|
|
79
|
+
timers.delete(taskId);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function clear() {
|
|
85
|
+
for (const timer of timers.values()) clearTimeout(timer);
|
|
86
|
+
timers.clear();
|
|
87
|
+
calls.clear();
|
|
88
|
+
client = null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return Object.freeze({
|
|
92
|
+
background,
|
|
93
|
+
called,
|
|
94
|
+
received,
|
|
95
|
+
clear
|
|
96
|
+
});
|
|
97
|
+
}
|
package/test/claudeTurn.test.js
CHANGED
|
@@ -2035,6 +2035,80 @@ test("a steer during Claude's follow-up to a finished background task moves that
|
|
|
2035
2035
|
turn.stop();
|
|
2036
2036
|
});
|
|
2037
2037
|
|
|
2038
|
+
// Frame shapes from Claude Code 2.1.308/2.1.319 stream-json: a person wrote while
|
|
2039
|
+
// a foreground wait loop with `timeout: 150000` ran. Steering backgrounded it, the
|
|
2040
|
+
// call lost its limit, and the wait held the turn in its background phase for
|
|
2041
|
+
// thirty minutes. The moved call stops at its own limit, as in the foreground.
|
|
2042
|
+
const flushControls = () => new Promise(setImmediate);
|
|
2043
|
+
for (const [timeout, limitMs] of [[150_000, 150_000], [undefined, 120_000]]) {
|
|
2044
|
+
test(`a command steering moves to the background still stops at its own limit, timeout=${timeout}`, async t => {
|
|
2045
|
+
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
|
2046
|
+
const { turn, send, read, requests, events } = steeringFixture();
|
|
2047
|
+
await send();
|
|
2048
|
+
await read("first");
|
|
2049
|
+
await turn.receive({ type: "assistant", uuid: "wait-call", message: { id: "wait-api", stop_reason: "tool_use",
|
|
2050
|
+
content: [{ type: "tool_use", id: "toolu_wait", name: "Bash", input: {
|
|
2051
|
+
command: "until grep -q ready status.txt; do sleep 5; done", description: "Wait for the status file",
|
|
2052
|
+
...(timeout ? { timeout } : {}) } }] } });
|
|
2053
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "b0wait", tool_use_id: "toolu_wait",
|
|
2054
|
+
description: "Wait for the status file", task_type: "local_bash", is_backgrounded: false });
|
|
2055
|
+
t.mock.timers.tick(60_000);
|
|
2056
|
+
await turn.prepareSteering();
|
|
2057
|
+
await send("question", { steer: true });
|
|
2058
|
+
await flushControls();
|
|
2059
|
+
assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
|
|
2060
|
+
await turn.receive({ type: "system", subtype: "task_updated", task_id: "b0wait", patch: { is_backgrounded: true } });
|
|
2061
|
+
await turn.receive({ type: "user", uuid: "wait-result", message: { content: [{ type: "tool_result", tool_use_id: "toolu_wait",
|
|
2062
|
+
content: "Command was manually backgrounded by user with ID: b0wait. Output is being written to: /tmp/b0wait.output" }] } });
|
|
2063
|
+
await read("question");
|
|
2064
|
+
await turn.receive({ type: "result", subtype: "success", result: "Here is the answer." });
|
|
2065
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: claudeNativeMessageId("first"), state: "completed" });
|
|
2066
|
+
await turn.receive({ type: "command_lifecycle", command_uuid: claudeNativeMessageId("question"), state: "completed" });
|
|
2067
|
+
assert.deepEqual([turn.read().active, turn.read().phase], [true, "background"]);
|
|
2068
|
+
|
|
2069
|
+
t.mock.timers.tick(limitMs - 60_001);
|
|
2070
|
+
await flushControls();
|
|
2071
|
+
assert.deepEqual(requests, [{ subtype: "background_tasks" }], "The call still has time left");
|
|
2072
|
+
t.mock.timers.tick(1);
|
|
2073
|
+
await flushControls();
|
|
2074
|
+
assert.deepEqual(requests, [{ subtype: "background_tasks" }, { subtype: "stop_task", task_id: "b0wait" }],
|
|
2075
|
+
"The moved call is stopped when its own limit passes");
|
|
2076
|
+
assert.equal(events.filter(event => event.type === "diagnostic" && event.stage === "steered_command_limit").length, 1);
|
|
2077
|
+
// Claude stops the task, reports it, and answers that notification.
|
|
2078
|
+
await turn.receive({ type: "system", subtype: "task_notification", task_id: "b0wait", status: "stopped" });
|
|
2079
|
+
await turn.receive({ type: "user", uuid: "stopped-notice", origin: { kind: "task-notification" } });
|
|
2080
|
+
await turn.receive({ type: "result", subtype: "success", result: "The wait hit its time limit." });
|
|
2081
|
+
assert.deepEqual([turn.read().active, turn.read().phase, turn.read().status], [false, "", "completed"]);
|
|
2082
|
+
assert.equal(events.filter(event => event.type === "settled").length, 1);
|
|
2083
|
+
});
|
|
2084
|
+
}
|
|
2085
|
+
|
|
2086
|
+
test("steering leaves deliberate background commands and finished commands to run their course", async t => {
|
|
2087
|
+
t.mock.timers.enable({ apis: ["setTimeout", "Date"] });
|
|
2088
|
+
const { turn, send, read, requests } = steeringFixture();
|
|
2089
|
+
await send();
|
|
2090
|
+
await read("first");
|
|
2091
|
+
await turn.receive({ type: "assistant", uuid: "server-call", message: { id: "server-api", stop_reason: "tool_use",
|
|
2092
|
+
content: [{ type: "tool_use", id: "toolu_server", name: "Bash", input: { command: "npm run dev", timeout: 60_000, run_in_background: true } }] } });
|
|
2093
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "b0server", tool_use_id: "toolu_server",
|
|
2094
|
+
description: "Start the dev server", task_type: "local_bash", is_backgrounded: true });
|
|
2095
|
+
await turn.receive({ type: "assistant", uuid: "build-call", message: { id: "build-api", stop_reason: "tool_use",
|
|
2096
|
+
content: [{ type: "tool_use", id: "toolu_build", name: "Bash", input: { command: "npm run build", timeout: 90_000 } }] } });
|
|
2097
|
+
await turn.receive({ type: "system", subtype: "task_started", task_id: "b0build", tool_use_id: "toolu_build",
|
|
2098
|
+
description: "Build", task_type: "local_bash", is_backgrounded: false });
|
|
2099
|
+
await turn.prepareSteering();
|
|
2100
|
+
await send("question", { steer: true });
|
|
2101
|
+
await flushControls();
|
|
2102
|
+
assert.deepEqual(requests, [{ subtype: "background_tasks" }]);
|
|
2103
|
+
t.mock.timers.tick(30_000);
|
|
2104
|
+
await turn.receive({ type: "system", subtype: "task_notification", task_id: "b0build", status: "completed" });
|
|
2105
|
+
t.mock.timers.tick(30 * 60_000);
|
|
2106
|
+
await flushControls();
|
|
2107
|
+
assert.deepEqual(requests, [{ subtype: "background_tasks" }], "Neither the dev server nor the finished build is stopped");
|
|
2108
|
+
assert.equal(turn.read().backgroundTasks, 1);
|
|
2109
|
+
turn.stop();
|
|
2110
|
+
});
|
|
2111
|
+
|
|
2038
2112
|
test("a refused background request leaves the steer delivered and reports it", async () => {
|
|
2039
2113
|
const { turn, send, read, requests, client, events } = steeringFixture();
|
|
2040
2114
|
await send();
|