@jskit-ai/assistant-core 0.2.32 → 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.32",
12
- "@jskit-ai/http-runtime": "0.2.15",
13
- "@jskit-ai/kernel": "0.2.15",
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",
18
- "@jskit-ai/connectors-catalog": "0.2.18"
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.32",
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.206",
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.15",
64
- "@jskit-ai/kernel": "0.2.15",
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
- const client = steeringClient;
193
- if (!waitingSteers.size || !client?.request || stopped || cancelling) return;
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
+ }
@@ -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();