@jskit-ai/assistant-core 0.2.43 → 0.2.44

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,8 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - A Claude goal's clock counts from its first start: Resume and restart recovery send the same unfinished objective again, which Claude starts anew, so the clock went back to zero after every Pause and Resume and after a server restart. The Claude history keeps that first start, also across the clear that suspends a paused goal's checker, and the goal value carries it as `startedAt` (ISO), which `AssistantGoalControl` counts its wall-clock time from (else `createdAt`). A goal finished or cancelled and then set again starts a new clock.
6
+ - The goal menu is 360 pixels wide, or the screen's width less the menu's 12-pixel margins on a narrower phone (the menu's `width` and `max-width`), so the new-goal objective box fills it instead of shrinking to its label.
5
7
  - `assistantConversationListItemOutputValidator` (from `@jskit-ai/assistant-core/shared`) is one conversation of `assistantContract`'s `conversationsList`, the record without its `metadata`; the contract's list output is built from it, and `@jskit-ai/assistant-runtime`'s conversations list route serializes its items with it.
6
8
  - `createConversationStorage`'s `write` saves nothing when its callback called no write method: the adapter's `writeRecord` does not run, so file storage no longer rewrites and flushes the whole conversation file, or creates an empty one for a missing conversation, when a write only looked (a delivery check, a message already saved), and the saved record stays cached.
7
9
  - Checking or recovering one message's delivery (`inspectDelivery`, and a send that finds its message already saved) finds that message's turn by its id instead of reading and cloning every turn of the conversation to find it.
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.2.43",
11
+ "@jskit-ai/assistant-core": "0.2.44",
12
12
  "@jskit-ai/http-runtime": "0.2.20",
13
13
  "@jskit-ai/kernel": "0.2.20",
14
14
  "@jskit-ai/material-web": "0.1.5",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.2.43",
3
+ "version": "0.2.44",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "jskit test"
@@ -34,11 +34,16 @@ const statusLabel = computed(() => goal.value?.status === "usageLimited" && goal
34
34
  const label = computed(() => props.state.loading ? t("jskit.assistant.goal.loading") : props.state.available === false ? t("jskit.assistant.goal.unavailable")
35
35
  : starting.value ? t("jskit.assistant.goal.starting") : statusLabel.value);
36
36
  const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
37
- const createdAt = computed(() => typeof goal.value?.createdAt === "string" ? Date.parse(goal.value.createdAt) : NaN);
37
+ // A goal resumed by sending it again (Claude) keeps the time its first start
38
+ // gave it, so its clock carries on across Pause, Resume and a server restart.
39
+ const startedAt = computed(() => {
40
+ const value = goal.value?.startedAt ?? goal.value?.createdAt;
41
+ return typeof value === "string" ? Date.parse(value) : NaN;
42
+ });
38
43
  const elapsedLabel = computed(() => nativeElapsed.value ? t("jskit.assistant.goal.runningTime") : t("jskit.assistant.goal.elapsedSinceStart"));
39
44
  const elapsedText = computed(() => elapsed.value ? t("jskit.assistant.goal.elapsed", { label: elapsedLabel.value, elapsed: elapsed.value }) : "");
40
45
  const clockRunning = computed(() => nativeElapsed.value ? running.value && Number.isFinite(goal.value?.sampledAt)
41
- : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(createdAt.value)));
46
+ : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(startedAt.value)));
42
47
  const elapsed = computed(() => {
43
48
  let secondsUsed;
44
49
  if (nativeElapsed.value) {
@@ -47,8 +52,8 @@ const elapsed = computed(() => {
47
52
  secondsUsed = goal.value.elapsedSeconds + sinceSample;
48
53
  } else {
49
54
  const end = goal.value?.status === "complete" ? goal.value.sampledAt : now.value;
50
- if (!Number.isFinite(createdAt.value) || !Number.isFinite(end)) return "";
51
- secondsUsed = (end - createdAt.value) / 1000;
55
+ if (!Number.isFinite(startedAt.value) || !Number.isFinite(end)) return "";
56
+ secondsUsed = (end - startedAt.value) / 1000;
52
57
  }
53
58
  const total = Math.max(0, Math.floor(secondsUsed));
54
59
  const seconds = String(total % 60).padStart(2, "0");
@@ -64,7 +69,7 @@ const chipLabel = computed(() => props.state.loading || props.state.available ==
64
69
  const canSet = computed(() => !props.state.loading && props.state.available !== false && typeof props.state.set === "function" && (!goal.value || goal.value.status === "complete"));
65
70
  const validBudget = computed(() => props.state.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
66
71
  let timer;
67
- onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, createdAt.value], () => {
72
+ onMounted(() => watch(() => [props.state.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, startedAt.value], () => {
68
73
  clearInterval(timer);
69
74
  now.value = Date.now();
70
75
  if (props.state.enabled !== false && clockRunning.value) {
@@ -86,7 +91,7 @@ async function cancelGoal() {
86
91
  </script>
87
92
 
88
93
  <template>
89
- <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" :close-on-content-click="false">
94
+ <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" width="360" max-width="calc(100vw - 24px)" :close-on-content-click="false">
90
95
  <template #activator="{ props: menuProps }">
91
96
  <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsedText, goal?.objective].filter(Boolean).join('\n')">
92
97
  <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--working': working, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
@@ -94,7 +99,7 @@ async function cancelGoal() {
94
99
  <span v-if="elapsed && running" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
95
100
  </v-btn>
96
101
  </template>
97
- <v-card class="assistant-goal__details pa-3" max-width="360">
102
+ <v-card class="assistant-goal__details pa-3">
98
103
  <strong role="status">{{ label }}</strong>
99
104
  <v-btn v-if="(running || (state.pauseBlocked && ['blocked', 'usageLimited'].includes(goal?.status))) && state.pause" size="small" :disabled="state.stopPending" @click="state.pause()">{{ state.action === 'pause' ? t('jskit.assistant.goal.pausing') : t('jskit.assistant.goal.pause') }}</v-btn>
100
105
  <v-btn v-if="['paused', 'blocked', 'usageLimited'].includes(goal?.status) && state.resume" size="small" :disabled="state.pending" @click="state.resume()">{{ state.action === 'resume' ? t('jskit.assistant.goal.resuming') : t('jskit.assistant.goal.resume') }}</v-btn>
@@ -48,7 +48,9 @@
48
48
  * @property {boolean} [cancelInterruptsTurn]
49
49
  * @property {boolean} [tokenBudgetSupported]
50
50
  * @property {string} [error]
51
- * @property {{ status: string, objective?: string, elapsedSeconds?: number, sampledAt?: number, createdAt?: string } | null} [goal]
51
+ * @property {{ status: string, objective?: string, elapsedSeconds?: number, sampledAt?: number, createdAt?: string,
52
+ * startedAt?: string } | null} [goal] Without a native running time, its clock counts from startedAt (when the
53
+ * unfinished goal first started, through any Resume), else createdAt.
52
54
  * @property {((input: { objective: string, tokenBudget?: number }) => unknown) | null} [set]
53
55
  * Starts a goal; absent while starting one is not allowed, so Start stays disabled.
54
56
  * @property {() => unknown} [pause]
@@ -212,6 +212,9 @@ async function readClaudeHistory(options) {
212
212
  const userIds = [];
213
213
  let lastUserId = "";
214
214
  let goal = null;
215
+ // The unfinished goal a clear removed: a paused goal's checker is cleared
216
+ // before its Resume sends the same objective again.
217
+ let clearedGoal = null;
215
218
  let goalStartId = "";
216
219
  let goalCommandId = "";
217
220
  let frames = [];
@@ -260,6 +263,7 @@ async function readClaudeHistory(options) {
260
263
  !frame.parent_tool_use_id && !frame.isMeta && frame.commandRun?.command === "goal" &&
261
264
  frame.commandRun.args === "clear" && typeof frame.content === "string" &&
262
265
  /^<local-command-stdout>(?:No goal set|Goal cleared: [\s\S]+)<\/local-command-stdout>$/u.test(frame.content)) {
266
+ if (goal && goal.status !== "complete") clearedGoal = goal;
263
267
  goal = null;
264
268
  goalStartId = "";
265
269
  const command = byId.get(frame.parentUuid);
@@ -282,15 +286,22 @@ async function readClaudeHistory(options) {
282
286
  goalStartId = status.sentinel === true && typeof status.met === "boolean" ? frame.uuid || "" : "";
283
287
  goalCommandId = "";
284
288
  }
285
- if (status.met === true && status.sentinel === true) goal = null;
286
- else if (status.met === false || status.met === true) {
289
+ if (status.met === true && status.sentinel === true) {
290
+ if (goal && goal.status !== "complete") clearedGoal = goal;
291
+ goal = null;
292
+ } else if (status.met === false || status.met === true) {
287
293
  const timestamp = Date.parse(frame.timestamp) / 1000;
294
+ const createdAt = goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp;
295
+ // Resume sends the same objective again and Claude starts it anew;
296
+ // the unfinished goal still started with its first command.
297
+ const previous = goal || clearedGoal;
288
298
  goal = {
289
- threadId: options.conversationId, objective: status.condition,
290
- createdAt: goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp,
299
+ threadId: options.conversationId, objective: status.condition, createdAt,
300
+ startedAt: previous?.objective === status.condition && previous.status !== "complete" ? previous.startedAt : createdAt,
291
301
  status: status.met ? "complete" : status.failed === true ? "blocked" : "active", updatedAt: timestamp,
292
302
  reason: typeof status.reason === "string" ? status.reason : ""
293
303
  };
304
+ clearedGoal = null;
294
305
  }
295
306
  }
296
307
  // This is native terminal evidence, not an inference from the assistant's
@@ -19,6 +19,7 @@ const nativeInput = input => input.content?.length ? [...(input.text ? [{ type:
19
19
  const claudeGoalValue = (goal, conversationId) => goal ? { ...goal,
20
20
  id: hash([conversationId, goal.createdAt, goal.objective]),
21
21
  ...(Number.isFinite(goal.createdAt) ? { createdAt: new Date(goal.createdAt * 1000).toISOString() } : {}),
22
+ ...(Number.isFinite(goal.startedAt) ? { startedAt: new Date(goal.startedAt * 1000).toISOString() } : {}),
22
23
  ...(Number.isFinite(goal.updatedAt) ? { updatedAt: new Date(goal.updatedAt * 1000).toISOString() } : {})
23
24
  } : null;
24
25
  const goalCapabilities = Object.freeze({ goals: true, goalBudgets: false,
@@ -81,6 +81,14 @@ test("an active goal's chip shows its clock, and a finished goal's chip shows ne
81
81
  assert.equal(none.label, "jskit.assistant.goal.set");
82
82
  });
83
83
 
84
+ test("a resumed goal's clock counts from its first start, not from the Resume that sent it again", async () => {
85
+ const control = await importControl();
86
+ const resumed = await render(control, { objective: "Finish every check", status: "active",
87
+ createdAt: new Date(Date.now() - 5 * 60 * 1000).toISOString(), startedAt: startedTwoHoursAgo });
88
+ assert.match(resumed.chip, /<span class="assistant-goal__elapsed" aria-hidden="true">2:00:0\d<\/span>/u);
89
+ assert.match(resumed.panel, /jskit\.assistant\.goal\.elapsedSinceStart=2:00:0\d/u);
90
+ });
91
+
84
92
  test("a goal waiting at its AI account's limit names the limit and when it resets, and Resume is the person's to press", async () => {
85
93
  const control = await importControl();
86
94
  const reset = new Date();
@@ -889,10 +889,24 @@ test("long goal objectives keep Start reachable on short phone, tablet and deskt
889
889
  const { vite, browser } = await openFixture(t);
890
890
  try {
891
891
  const objective = "Complete the requested work and verify the result. ".repeat(80).slice(0, 3990);
892
- for (const width of [390, 800, 1280]) {
892
+ for (const width of [320, 390, 800, 1280]) {
893
893
  const page = await browser.newPage({ viewport: { width, height: 578 } });
894
894
  await page.goto(`${vite.baseURL}/?conversation=1&goal=1`);
895
895
  await page.getByRole("button", { name: "Set goal", exact: true }).click();
896
+ await expect(page.getByRole("textbox", { name: "Goal objective" })).toBeVisible();
897
+ // The new goal's box fills its menu, which is 360 px wide or as wide as a phone allows.
898
+ const measure = () => page.locator(".assistant-goal__details").evaluate(card => {
899
+ const style = getComputedStyle(card);
900
+ const bounds = card.getBoundingClientRect();
901
+ return { left: bounds.left, right: bounds.right, card: bounds.width, field: card.querySelector(".v-textarea").getBoundingClientRect().width,
902
+ inner: card.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight) };
903
+ });
904
+ const fills = box => box.card >= Math.min(360, width - 24) - 1 && box.left >= 0 && box.right <= width &&
905
+ Math.abs(box.field - box.inner) <= 1;
906
+ await expect.poll(async () => fills(await measure())).toBe(true).catch(async failure => {
907
+ failure.message += `\nat ${width} px: ${JSON.stringify(await measure())}`;
908
+ throw failure;
909
+ });
896
910
  await page.getByRole("textbox", { name: "Goal objective" }).fill(objective);
897
911
  const start = page.getByRole("button", { name: "Start goal", exact: true });
898
912
  await expect(start).toBeInViewport();
@@ -129,6 +129,44 @@ test("Claude successful native clear removes a failed goal without a clearing se
129
129
  assert.equal((await readClaudeHistory(options)).goal.objective, "Next checks", "a later goal still wins");
130
130
  });
131
131
 
132
+ test("a resumed Claude goal keeps the time it first started, across the clear before Resume", async t => {
133
+ const root = await mkdtemp(path.join(os.tmpdir(), "claude-goal-start-"));
134
+ t.after(() => rm(root, { recursive: true, force: true }));
135
+ const directory = path.join(root, "projects", root.replace(/[^a-zA-Z0-9]/gu, "-"));
136
+ await mkdir(directory, { recursive: true });
137
+ const conversationId = "6b1f2c9e-4d3a-4c1b-9e7f-2a5d8c0b1e3f";
138
+ const file = path.join(directory, `${conversationId}.jsonl`);
139
+ const options = { configRoot: root, workdir: root, conversationId };
140
+ const append = (...frames) => writeFile(file, frames.map(JSON.stringify).join("\n") + "\n", { flag: "a" });
141
+ const goal = (uuid, timestamp, condition, extra = {}) => ({ type: "attachment", uuid, timestamp,
142
+ attachment: { type: "goal_status", condition, met: false, sentinel: true, ...extra } });
143
+ const first = Date.parse("2026-11-11T07:00:00.000Z") / 1000;
144
+ await append(goal("set", "2026-11-11T07:00:00.000Z", "Finish all checks"));
145
+ // Restart recovery sends the same objective again while the goal still runs.
146
+ await append(goal("recovered", "2026-11-11T07:30:00.000Z", "Finish all checks"));
147
+ let read = (await readClaudeHistory(options)).goal;
148
+ assert.equal(read.createdAt, Date.parse("2026-11-11T07:30:00.000Z") / 1000);
149
+ assert.equal(read.startedAt, first);
150
+ // A paused goal's checker is cleared when its process starts; Resume sends it again.
151
+ await append({ type: "system", subtype: "local_command", uuid: "suspended", timestamp: "2026-11-11T08:00:00.000Z",
152
+ commandRun: { command: "goal", args: "clear" }, content: "<local-command-stdout>Goal cleared: Finish all checks</local-command-stdout>" });
153
+ assert.equal((await readClaudeHistory(options)).goal, null);
154
+ await append(goal("resumed", "2026-11-11T08:05:00.000Z", "Finish all checks"));
155
+ read = (await readClaudeHistory(options)).goal;
156
+ assert.equal(read.status, "active");
157
+ assert.equal(read.startedAt, first, "Resume after the clear keeps the first start");
158
+ await append({ type: "attachment", uuid: "done", timestamp: "2026-11-11T09:00:00.000Z",
159
+ attachment: { type: "goal_status", condition: "Finish all checks", met: true } });
160
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, first);
161
+ // A finished goal set again, or another objective, starts its own clock.
162
+ await append(goal("again", "2026-11-11T09:10:00.000Z", "Finish all checks"));
163
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, Date.parse("2026-11-11T09:10:00.000Z") / 1000);
164
+ await append({ type: "attachment", uuid: "cleared", timestamp: "2026-11-11T09:20:00.000Z",
165
+ attachment: { type: "goal_status", condition: "Finish all checks", met: true, sentinel: true } },
166
+ goal("other", "2026-11-11T09:30:00.000Z", "Write the release notes"));
167
+ assert.equal((await readClaudeHistory(options)).goal.startedAt, Date.parse("2026-11-11T09:30:00.000Z") / 1000);
168
+ });
169
+
132
170
  // Claude 2.1.308 history at a hosted goal's usage limit (2026-11-10), and the
133
171
  // message Claude Code writes when the account's credit balance is too low.
134
172
  test("Claude's own limit message makes its goal wait, with the reset time, until a later answer", async t => {