@jskit-ai/assistant-core 0.1.227 → 0.1.229

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.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.227",
11
+ "@jskit-ai/assistant-core": "0.1.229",
12
12
  "@jskit-ai/http-runtime": "0.1.242",
13
13
  "@jskit-ai/kernel": "0.1.244",
14
14
  "@mdi/js": "^7.4.47",
@@ -70,7 +70,7 @@ const models = reactive({
70
70
  const goalEnabled = ref(new URLSearchParams(location.search).has("goal"));
71
71
  const goal = ref(null);
72
72
  const goalState = reactive({
73
- enabled: goalEnabled, goal, working: computed(() => phase.value === "active"),
73
+ enabled: goalEnabled, goal, pauseBlocked: true, working: computed(() => phase.value === "active"),
74
74
  set(input) { phase.value = "active"; goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
75
75
  pause() { goal.value = { ...goal.value, status: "paused", elapsedSeconds: goal.value.elapsedSeconds + (Date.now() - goal.value.sampledAt) / 1000, sampledAt: Date.now() }; },
76
76
  resume() { phase.value = "active"; goal.value = { ...goal.value, status: "active", reason: "", sampledAt: Date.now() }; },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.227",
3
+ "version": "0.1.229",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -64,8 +64,8 @@ async function setGoal() {
64
64
  </template>
65
65
  <v-card class="assistant-goal__details pa-3" max-width="360">
66
66
  <strong role="status">{{ label }}</strong>
67
- <v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
68
- <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>
67
+ <v-btn v-if="(running || (state.pauseBlocked && goal?.status === 'blocked')) && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
68
+ <v-btn v-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>
69
69
  <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>
70
70
  <p v-if="goal" class="my-2">{{ goal.objective }}</p>
71
71
  <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
@@ -10,6 +10,21 @@ import { CLAUDE_APPLICATION_TOOL_SERVER } from "./claudeTools.js";
10
10
  import { nativeCommandHook, validateCommandWrapper } from "./commandWrapper.js";
11
11
 
12
12
  const statusReads = new WeakMap();
13
+ const CLAUDE_EXIT_WAIT_MS = 5_000;
14
+
15
+ /** Person-facing cause for a Claude process that ended without being stopped. */
16
+ export function claudeUnexpectedExitMessage(processExit = {}) {
17
+ const resume = "The conversation so far is saved; send a message to continue.";
18
+ if (Number(processExit?.taskLimit?.count) > 0) {
19
+ return "Claude stopped unexpectedly: the server refused to create more processes or threads for it " +
20
+ `(process/thread limit reached). ${resume}`;
21
+ }
22
+ if (processExit?.signal) return `Claude stopped unexpectedly: it was terminated by ${processExit.signal}. ${resume}`;
23
+ if (Number.isInteger(processExit?.code)) {
24
+ return `Claude stopped unexpectedly with exit code ${processExit.code}. ${resume}`;
25
+ }
26
+ return `Claude stopped unexpectedly. ${resume}`;
27
+ }
13
28
 
14
29
  export async function readClaudeCodeAuthStatus({
15
30
  env = process.env, credentialHome = { home: env.HOME || homedir() }, command = "claude", commandRunner, signal
@@ -347,7 +362,20 @@ export async function createClaudeCodeProcess({
347
362
  await onStarted?.(native.id, stop);
348
363
  signal?.throwIfAborted();
349
364
  const stream = Duplex.from({ readable: native.stdout, writable: native.stdin });
350
- client = createClaudeJsonClient({ stream, onEvent, onFailure,
365
+ // A lost stream usually means the process ended. Its exit details arrive
366
+ // separately, so wait briefly for them before reporting the cause.
367
+ const exited = native.exited;
368
+ const describeFailure = async (failure) => {
369
+ if (failure?.code === "assistant_claude_observation_lost" && exited && !stopped && !stopping) {
370
+ const processExit = failure.processExit || await Promise.race([
371
+ exited.catch(() => null),
372
+ new Promise((resolve) => setTimeout(resolve, CLAUDE_EXIT_WAIT_MS, null).unref?.())
373
+ ]);
374
+ if (processExit) Object.assign(failure, { processExit, message: claudeUnexpectedExitMessage(processExit) });
375
+ }
376
+ return onFailure?.(failure);
377
+ };
378
+ client = createClaudeJsonClient({ stream, onEvent, onFailure: describeFailure,
351
379
  onControlRequest(request, context) {
352
380
  if (request.subtype === "hook_callback" && commandWrapper && request.callback_id === "jskit-command-wrapper") {
353
381
  return nativeCommandHook(commandWrapper, request.input);
@@ -816,12 +816,14 @@ test("optional goals show running and paused lights, count only active time, and
816
816
  await expect(light).toHaveCSS("animation-name", "none");
817
817
  await page.getByRole("button", { name: "Goal blocked", exact: true }).click();
818
818
  await page.clock.runFor(250);
819
- for (const name of ["Resume goal", "Cancel goal"]) {
819
+ for (const name of ["Pause goal", "Resume goal", "Cancel goal"]) {
820
820
  const button = page.getByRole("button", { name, exact: true });
821
821
  await expect(button).toBeInViewport();
822
822
  const bounds = await button.boundingBox();
823
823
  assert.ok(bounds.y >= 0 && bounds.y + bounds.height <= 577, `${name} is fully visible without scrolling`);
824
824
  }
825
+ await page.getByRole("button", { name: "Pause goal", exact: true }).click();
826
+ await expect(page.getByRole("button", { name: "Goal paused", exact: true })).toBeVisible();
825
827
  await page.getByRole("button", { name: "Resume goal", exact: true }).click();
826
828
  await expect(page.getByRole("button", { name: "Pause goal", exact: true })).toBeVisible();
827
829
  await page.getByRole("button", { name: "Toggle goals" }).evaluate(element => element.click());
@@ -299,10 +299,35 @@ test("Claude observes parent exit after initialization even while a child retain
299
299
  const error = await failed.promise;
300
300
  assert.equal(error.code, "assistant_claude_observation_lost");
301
301
  assert.deepEqual(error.processExit, { code: 1, signal: null });
302
+ assert.match(error.message, /^Claude stopped unexpectedly with exit code 1\. .*send a message to continue/u);
302
303
  await native.client.completion;
303
304
  assert.equal(failures, 1);
304
305
  });
305
306
 
307
+ test("Claude reports a task-limit exit that arrives after its stream ended", { timeout: 3_000 }, async t => {
308
+ const { PassThrough, Writable } = await import("node:stream");
309
+ const exited = Promise.withResolvers();
310
+ const failed = Promise.withResolvers();
311
+ const stdout = new PassThrough();
312
+ const stdin = new Writable({ write(chunk, _encoding, done) {
313
+ const frame = JSON.parse(String(chunk));
314
+ stdout.write(JSON.stringify({ type: "control_response", response: { subtype: "success", request_id: frame.request_id, response: {} } }) + "\n");
315
+ done();
316
+ } });
317
+ const native = await createClaudeCodeProcess({ execution: {
318
+ async start() { return { id: "task-limit", stdin, stdout, exited: exited.promise }; },
319
+ async stop() { return { scopeEmpty: true }; }
320
+ }, onFailure(error) { failed.resolve(error); } });
321
+ t.after(() => native.stop());
322
+ stdout.end();
323
+ const taskLimit = { source: "cgroup-pids-events", count: 3 };
324
+ setTimeout(() => exited.resolve({ code: 1, signal: null, taskLimit }), 50);
325
+ const error = await failed.promise;
326
+ assert.equal(error.code, "assistant_claude_observation_lost");
327
+ assert.deepEqual(error.processExit, { code: 1, signal: null, taskLimit });
328
+ assert.match(error.message, /process\/thread limit reached/u);
329
+ });
330
+
306
331
  test("Claude host startup budget allows slow initialization and still bounds failed startup", async t => {
307
332
  t.mock.timers.enable({ apis: ["setTimeout"] });
308
333
  const { PassThrough, Writable } = await import("node:stream");
@@ -2528,3 +2528,14 @@ test("Claude Block refuses absent reasons and stale identity before interrupting
2528
2528
  assert.equal(f.entry.blockedGoal, null);
2529
2529
  }
2530
2530
  });
2531
+
2532
+
2533
+ test("Claude explicitly pauses a blocked goal without resuming native work", async t => {
2534
+ const f = await publishedClaudeFixture(t);
2535
+ f.entry.blockedGoal = { threadId: f.entry.id, createdAt: 123, objective: "Finish checks", status: "blocked", reason: "Native loop stopped" };
2536
+ const result = await f.owner.updateGoal(f.entry, { action: "pause", threadId: f.entry.id, createdAt: 123, objective: "Finish checks" });
2537
+ assert.equal(result.goal.status, "paused");
2538
+ assert.equal(f.entry.blockedGoal, null);
2539
+ assert.equal(f.entry.pausedGoal.status, "paused");
2540
+ assert.equal(f.processes.length, 0);
2541
+ });