@jskit-ai/assistant-core 0.1.226 → 0.1.227

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.
Files changed (37) hide show
  1. package/examples/conversation/package.json +1 -1
  2. package/fixtures/responsive-assistant/ConversationFixture.vue +5 -3
  3. package/fixtures/responsive-assistant/DeliveryFixture.vue +9 -0
  4. package/package.json +1 -1
  5. package/src/client/conversation/AssistantConversationElement.vue +7 -4
  6. package/src/client/conversation/AssistantGoalControl.vue +30 -16
  7. package/src/client/conversation/AssistantTranscript.vue +14 -7
  8. package/src/client/conversation/useAssistantAttachments.js +6 -3
  9. package/src/server/conversation/claudeClient.js +22 -5
  10. package/src/server/conversation/claudeHistory.js +62 -5
  11. package/src/server/conversation/claudeProcess.js +7 -6
  12. package/src/server/conversation/claudeTurn.js +284 -57
  13. package/src/server/conversation/codexConfiguration.js +24 -1
  14. package/src/server/conversation/codexGoalCommands.js +8 -3
  15. package/src/server/conversation/codexTurnRecovery.js +18 -8
  16. package/src/server/conversation/continuity.js +54 -17
  17. package/src/server/conversation/providers/claudeDriver.js +6 -5
  18. package/src/server/conversation/providers/codexDriver.js +3 -3
  19. package/src/server/conversation/providers/scoped.js +31 -10
  20. package/src/server/conversation/runtime.js +48 -13
  21. package/src/server/conversation/streams.js +7 -3
  22. package/src/server/conversation/transcript.js +2 -1
  23. package/src/server/hosts/codexConfiguration.js +1 -0
  24. package/src/shared/conversation/longTextBlocks.js +0 -4
  25. package/test/assistantScroll.browser.test.js +74 -3
  26. package/test/claudeClient.test.js +73 -0
  27. package/test/claudeConversationSession.test.js +120 -4
  28. package/test/claudeProcess.test.js +61 -0
  29. package/test/claudeTurn.test.js +1315 -10
  30. package/test/codexConversationSession.test.js +9 -1
  31. package/test/codexGoal.test.js +47 -0
  32. package/test/codexModelCatalog.test.js +55 -0
  33. package/test/codexTurn.test.js +228 -0
  34. package/test/conversationAttachments.test.js +17 -0
  35. package/test/conversationRuntime.test.js +40 -0
  36. package/test/conversationStreaming.test.js +18 -0
  37. package/test/messageDelivery.browser.test.js +30 -0
@@ -8,7 +8,7 @@
8
8
  "build": "vite build"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/assistant-core": "0.1.226",
11
+ "@jskit-ai/assistant-core": "0.1.227",
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,10 +70,11 @@ 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,
74
- set(input) { goal.value = { ...input, status: "active", elapsedSeconds: 0, sampledAt: Date.now() }; },
73
+ enabled: goalEnabled, goal, working: computed(() => phase.value === "active"),
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
- resume() { goal.value = { ...goal.value, status: "active", sampledAt: Date.now() }; }
76
+ resume() { phase.value = "active"; goal.value = { ...goal.value, status: "active", reason: "", sampledAt: Date.now() }; },
77
+ cancel() { goal.value = null; phase.value = "idle"; }
77
78
  });
78
79
  const adapter = reactive({
79
80
  delivery: previewFixture ? previewDelivery : undefined,
@@ -177,6 +178,7 @@ const adapter = reactive({
177
178
  <button v-if="supportEnabled" @click="customActivity = !customActivity">Custom activity</button>
178
179
  <button v-if="capabilitiesEnabled" @click="finishUploads">Finish uploads</button>
179
180
  <button @click="goalEnabled = !goalEnabled">Toggle goals</button>
181
+ <button v-if="goal" @click="goal = { ...goal, status: 'blocked', reason: 'An explanation of the blocker. '.repeat(200) }">Block goal with long explanation</button>
180
182
  <template v-if="previewFixture">
181
183
  <button @click="temporaryWords = 'Exact temporary words'; temporaryPresent = true; temporaryEditing = false">Add temporary message</button>
182
184
  <button @click="newerWords = 'Keep newer live words'; newerPresent = true; newerEditing = false">Add newer temporary message</button>
@@ -4,6 +4,7 @@ import AssistantConversationElement from "../../src/client/conversation/Assistan
4
4
  import { createAssistantMessageDelivery } from "../../src/client/conversation/messageDelivery.js";
5
5
 
6
6
  const recoveryFixture = new URLSearchParams(location.search).has("recovery");
7
+ const acceptedFixture = new URLSearchParams(location.search).has("accepted-history");
7
8
  const draft = ref("");
8
9
  const turns = ref([]);
9
10
  const requests = ref([]);
@@ -43,6 +44,13 @@ if (recoveryFixture) {
43
44
  ];
44
45
  delivery.state.messages.push({ id: "uncertain", text: "Discard failed attempt", status: "uncertain", error: "Lost response", createdAt: at("05") });
45
46
  }
47
+ if (acceptedFixture) {
48
+ turns.value = [{ turnId: "later", assistant: { text: "Later assistant reply", at: "2026-10-16T11:56:00.000Z" } }];
49
+ delivery.state.messages.push(
50
+ { id: "accepted-old", text: "Earlier accepted message", status: "accepted", createdAt: "2026-10-16T11:04:00.000Z" },
51
+ { id: "queued", text: "Still queued", status: "pending", createdAt: "2026-10-16T11:03:00.000Z" }
52
+ );
53
+ }
46
54
  const adapter = reactive({
47
55
  delivery, attachments,
48
56
  conversation: { cancellableDelivery: recoveryFixture, turns, visible: true, scrollKey: "delivery", welcomeMessage: "Start a conversation." },
@@ -57,6 +65,7 @@ const adapter = reactive({
57
65
  <v-app>
58
66
  <main class="fixture">
59
67
  <nav>
68
+ <button v-if="acceptedFixture" @click="turns.unshift({ turnId: 'earlier', user: { messageId: 'accepted-old', text: 'Earlier accepted message', at: '2026-10-16T11:05:00.000Z' } })">Load accepted history</button>
60
69
  <button @click="pendingRequest.reject(new Error('Delivery unavailable.'))">Fail request</button>
61
70
  <button @click="accept">Accept request</button>
62
71
  <button @click="attachments.attachments.push({ attachmentId: 'new-file', fileName: 'next.txt' }); configuration = { tone: 'detailed' }">Prepare newer input</button>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/assistant-core",
3
- "version": "0.1.226",
3
+ "version": "0.1.227",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test --test-concurrency=1"
@@ -195,6 +195,9 @@ const observedAvatarElements = new Set();
195
195
  function measureAvatarSpace() {
196
196
  const root = container.value;
197
197
  if (!root || !slots.avatar) return;
198
+ const warning = root.querySelector(".assistant-transcript__error");
199
+ const warningBottom = warning ? warning.getBoundingClientRect().bottom - root.getBoundingClientRect().top + root.scrollTop : 0;
200
+ root.style.setProperty("--assistant-conversation-warning-bottom", `${warningBottom}px`);
198
201
  const rootStyle = getComputedStyle(root);
199
202
  let occupied = parseFloat(rootStyle.paddingTop) + parseFloat(rootStyle.paddingBottom);
200
203
  for (const element of observedAvatarElements) {
@@ -216,6 +219,8 @@ function observeAvatarSpace() {
216
219
  !element.classList.contains("assistant-conversation__avatar") && !element.classList.contains("assistant-conversation__transcript"));
217
220
  const elements = new Set([container.value, ...fixedElements]);
218
221
  if (avatarControls.value) elements.add(avatarControls.value);
222
+ const warning = container.value.querySelector(".assistant-transcript__error");
223
+ if (warning) elements.add(warning);
219
224
  for (const element of observedAvatarElements) {
220
225
  if (!elements.has(element)) {
221
226
  avatarObserver.unobserve(element);
@@ -390,9 +395,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
390
395
  .assistant-conversation__preview-actions { display: flex; justify-content: flex-end; }
391
396
  .assistant-conversation { position: relative; display: flex; flex-direction: column; min-height: 0; min-width: 0; height: 100%; gap: 0; container: assistant-conversation / inline-size; }
392
397
  .assistant-conversation--error { overflow-y: auto; }
393
- .assistant-conversation__avatar { position: absolute; top: 0; right: 0; z-index: 3; max-width: 100%; min-width: 0; }
394
- .assistant-conversation--error .assistant-conversation__avatar { position: relative; align-self: flex-end; flex: 0 0 auto; }
395
- .assistant-conversation--error .assistant-conversation__avatar-controls { position: static; }
398
+ .assistant-conversation__avatar { position: absolute; top: var(--assistant-conversation-warning-bottom, 0px); right: 0; z-index: 3; max-width: 100%; min-width: 0; }
396
399
  .assistant-conversation__avatar-controls { position: absolute; top: 0; right: 100%; display: flex; align-items: center; justify-content: flex-end; min-height: 44px; }
397
400
  .assistant-conversation__avatar--hidden .assistant-conversation__avatar-controls { right: 0; }
398
401
  .assistant-conversation__avatar-size { min-width: 44px; min-height: 44px; }
@@ -400,7 +403,7 @@ defineExpose({ focus: () => input.value?.focus(), submit, stop });
400
403
  .assistant-conversation__avatar-size-disc--minimise { background: rgba(var(--v-theme-primary), .12); }
401
404
  .assistant-conversation__avatar-presentation { display: flex; flex-direction: column; align-items: center; min-width: 0; }
402
405
  .assistant-conversation__avatar-tools { display: flex; align-items: center; gap: 0; }
403
- .assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; }
406
+ .assistant-conversation__avatar-visual { display: flex; justify-content: center; min-height: 0; overflow: hidden; contain: layout paint; }
404
407
  .assistant-conversation__avatar-visual :deep(> *) { max-width: 100%; max-height: 100%; }
405
408
  .assistant-conversation__goal { display: flex; justify-content: flex-end; flex: 0 0 auto; }
406
409
  .assistant-conversation__transcript { flex: 1 1 auto; min-height: 0; }
@@ -8,15 +8,28 @@ const tokenBudget = ref("");
8
8
  const now = ref(Date.now());
9
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 working = computed(() => running.value && props.state?.working === true);
11
12
  const label = computed(() => props.state?.loading ? "Loading goal…" : props.state?.available === false ? "Goal unavailable" : ({
12
13
  active: "Goal running", paused: "Goal paused", blocked: "Goal blocked",
13
14
  usageLimited: "Goal waiting for allowance", budgetLimited: "Goal budget reached", complete: "Goal complete"
14
15
  })[goal.value?.status] || "Set goal");
16
+ const nativeElapsed = computed(() => Number.isFinite(goal.value?.elapsedSeconds));
17
+ const createdAt = computed(() => typeof goal.value?.createdAt === "string" ? Date.parse(goal.value.createdAt) : NaN);
18
+ const elapsedLabel = computed(() => nativeElapsed.value ? "Running time" : "Elapsed since goal started (including pauses)");
19
+ const clockRunning = computed(() => nativeElapsed.value ? running.value && Number.isFinite(goal.value?.sampledAt)
20
+ : Boolean(goal.value && goal.value.status !== "complete" && Number.isFinite(createdAt.value)));
15
21
  const elapsed = computed(() => {
16
- if (!Number.isFinite(goal.value?.elapsedSeconds)) return "";
17
- const sinceSample = running.value && Number.isFinite(goal.value.sampledAt)
18
- ? Math.max(0, now.value - goal.value.sampledAt) / 1000 : 0;
19
- const total = Math.max(0, Math.floor(goal.value.elapsedSeconds + sinceSample));
22
+ let secondsUsed;
23
+ if (nativeElapsed.value) {
24
+ const sinceSample = running.value && Number.isFinite(goal.value.sampledAt)
25
+ ? Math.max(0, now.value - goal.value.sampledAt) / 1000 : 0;
26
+ secondsUsed = goal.value.elapsedSeconds + sinceSample;
27
+ } else {
28
+ const end = goal.value?.status === "complete" ? goal.value.sampledAt : now.value;
29
+ if (!Number.isFinite(createdAt.value) || !Number.isFinite(end)) return "";
30
+ secondsUsed = (end - createdAt.value) / 1000;
31
+ }
32
+ const total = Math.max(0, Math.floor(secondsUsed));
20
33
  const seconds = String(total % 60).padStart(2, "0");
21
34
  const minutes = Math.floor(total / 60);
22
35
  return minutes < 60 ? `${minutes}:${seconds}` : `${Math.floor(minutes / 60)}:${String(minutes % 60).padStart(2, "0")}:${seconds}`;
@@ -24,10 +37,10 @@ const elapsed = computed(() => {
24
37
  const canSet = computed(() => !props.state?.loading && props.state?.available !== false && typeof props.state?.set === "function" && (!goal.value || goal.value.status === "complete"));
25
38
  const validBudget = computed(() => props.state?.tokenBudgetSupported === false || tokenBudget.value === "" || (Number.isSafeInteger(Number(tokenBudget.value)) && Number(tokenBudget.value) > 0));
26
39
  let timer;
27
- onMounted(() => watch(() => [props.state?.enabled, running.value, goal.value?.elapsedSeconds, goal.value?.sampledAt], () => {
40
+ onMounted(() => watch(() => [props.state?.enabled, clockRunning.value, goal.value?.elapsedSeconds, goal.value?.sampledAt, createdAt.value], () => {
28
41
  clearInterval(timer);
29
42
  now.value = Date.now();
30
- if (props.state?.enabled !== false && running.value && Number.isFinite(goal.value?.sampledAt)) {
43
+ if (props.state?.enabled !== false && clockRunning.value) {
31
44
  timer = setInterval(() => { now.value = Date.now(); }, 1000);
32
45
  }
33
46
  }, { immediate: true, flush: "sync" }));
@@ -36,31 +49,31 @@ watch(() => props.state?.enabled, enabled => { if (enabled === false) open.value
36
49
  async function setGoal() {
37
50
  if (!canSet.value || props.state.pending || !objective.value.trim() || !validBudget.value) return;
38
51
  const result = await props.state.set({ objective: objective.value.trim(), ...(props.state.tokenBudgetSupported === false || tokenBudget.value === "" ? {} : { tokenBudget: Number(tokenBudget.value) }) });
39
- if (result !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; }
52
+ if (result !== false && result?.ok !== false && !props.state.error) { objective.value = ""; tokenBudget.value = ""; open.value = false; }
40
53
  }
41
54
  </script>
42
55
 
43
56
  <template>
44
57
  <v-menu v-if="state && state.enabled !== false" v-model="open" location="top" :close-on-content-click="false">
45
58
  <template #activator="{ props: menuProps }">
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
- <span v-if="goal" class="assistant-goal__light" :class="{ 'assistant-goal__light--running': running, 'assistant-goal__light--paused': goal.status === 'paused' }" aria-hidden="true" />
59
+ <v-btn v-bind="menuProps" class="assistant-goal" size="small" variant="text" :aria-label="label" :title="[label, elapsed && `${elapsedLabel}: ${elapsed}`, goal?.objective].filter(Boolean).join('\n')">
60
+ <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" />
48
61
  <span class="assistant-goal__label">{{ state.loading ? 'Loading goal…' : state.available === false ? 'Goal unavailable' : goal ? 'Goal' : 'Set goal' }}</span>
49
62
  <span v-if="elapsed" class="assistant-goal__elapsed" aria-hidden="true">{{ elapsed }}</span>
50
63
  </v-btn>
51
64
  </template>
52
65
  <v-card class="assistant-goal__details pa-3" max-width="360">
53
66
  <strong role="status">{{ label }}</strong>
54
- <p v-if="goal" class="my-2">{{ goal.objective }}</p>
55
- <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
56
- <p v-if="elapsed" class="text-body-small my-2">Running time: {{ elapsed }}</p>
57
67
  <v-btn v-if="running && state.pause" size="small" :disabled="state.pending" @click="state.pause()">{{ state.action === 'pause' ? 'Pausing…' : 'Pause goal' }}</v-btn>
58
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>
59
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
+ <p v-if="goal" class="my-2">{{ goal.objective }}</p>
71
+ <p v-if="goal?.reason" class="text-body-small my-2">{{ goal.reason }}</p>
72
+ <p v-if="elapsed" class="text-body-small my-2">{{ elapsedLabel }}: {{ elapsed }}</p>
60
73
  <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
74
  <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
75
  <form v-if="canSet" class="assistant-goal__form mt-2" @submit.prevent="setGoal">
63
- <v-textarea v-model="objective" label="Goal objective" rows="2" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
76
+ <v-textarea v-model="objective" label="Goal objective" rows="2" max-rows="4" maxlength="4000" auto-grow :disabled="state.pending" hide-details />
64
77
  <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
78
  <v-btn type="submit" size="small" :disabled="state.pending || !objective.trim() || !validBudget">{{ state.action === 'set' ? 'Starting…' : 'Start goal' }}</v-btn>
66
79
  </form>
@@ -73,12 +86,13 @@ async function setGoal() {
73
86
  <style scoped>
74
87
  .assistant-goal { text-transform: none; flex-shrink: 0; font-variant-numeric: tabular-nums; }
75
88
  .assistant-goal__light { width: 0.625rem; height: 0.625rem; border-radius: 50%; margin-inline-end: 0.45rem; background: currentColor; }
76
- .assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; animation: assistant-goal-flash 1.5s ease-in-out infinite; }
89
+ .assistant-goal__light--running { background: #d32f2f; box-shadow: 0 0 0 3px #d32f2f20; }
90
+ .assistant-goal__light--working { animation: assistant-goal-flash 1.5s ease-in-out infinite; }
77
91
  .assistant-goal__light--paused { background: #ef6c00; box-shadow: 0 0 0 3px #ef6c0020; }
78
92
  .assistant-goal__elapsed { margin-inline-start: 0.45rem; }
79
- .assistant-goal__details { overflow-wrap: anywhere; }
93
+ .assistant-goal__details { overflow-wrap: anywhere; min-height: 0; max-height: calc(100dvh - 24px); }
80
94
  .assistant-goal__form { display: grid; gap: 0.75rem; }
81
95
  @keyframes assistant-goal-flash { 50% { opacity: 0.3; } }
82
96
  @container assistant-conversation (max-width: 26rem) { .assistant-goal__elapsed { display: none; } }
83
- @media (prefers-reduced-motion: reduce) { .assistant-goal__light--running { animation: none; } }
97
+ @media (prefers-reduced-motion: reduce) { .assistant-goal__light--working { animation: none; } }
84
98
  </style>
@@ -41,18 +41,20 @@
41
41
  <v-alert
42
42
  v-if="error"
43
43
  class="assistant-transcript__error"
44
+ :icon="false"
44
45
  density="compact"
45
46
  type="warning"
46
47
  variant="tonal"
47
48
  >
48
49
  <div class="assistant-transcript__error-content">
49
- <span>{{ error }}</span>
50
50
  <v-btn
51
- v-if="errorReloadable && !reloadable" :disabled="reloading" :min-height="48" size="small"
51
+ v-if="errorReloadable && !reloadable" :disabled="reloading" :min-height="32" size="small"
52
+ :aria-label="reloading ? 'Reloading chat' : 'Reload chat'"
52
53
  type="button" variant="text" @click="emit('reload')"
53
54
  >
54
- Reload chat
55
+ {{ reloading ? 'Reloading…' : 'Reload chat' }}
55
56
  </v-btn>
57
+ <span>{{ error }}</span>
56
58
  </div>
57
59
  </v-alert>
58
60
 
@@ -608,13 +610,15 @@ const displayEntries = computed(() => {
608
610
  }, key);
609
611
  if (!displayed) continue;
610
612
  candidates.push({ key, role, turn: { ...turn, turnId }, message: displayed,
611
- sortAt: turn.optimistic && turn.optimistic.id === message.messageId ? NaN : Date.parse(message.at),
613
+ sortAt: turn.optimistic && turn.optimistic.status !== "accepted" && turn.optimistic.id === message.messageId
614
+ ? NaN : Date.parse(message.at),
612
615
  delivery: role === "user" ? userDelivery : null });
613
616
  }
614
617
  }
615
618
  // Native streaming turns and saved authored turns can arrive independently.
616
619
  // Local queued timestamps can precede an earlier message's native admission.
617
- // Keep their supplied positions, along with undated notices.
620
+ // Keep unconfirmed messages in their supplied positions, along with undated
621
+ // notices. Accepted local copies join chronology even before history arrives.
618
622
  const dated = candidates.filter(entry => Number.isFinite(entry.sortAt))
619
623
  .sort((a, b) => a.sortAt - b.sortAt)[Symbol.iterator]();
620
624
  const entries = [];
@@ -1130,15 +1134,18 @@ watch(() => props.visible, (visible) => {
1130
1134
  }
1131
1135
 
1132
1136
  .assistant-transcript__error {
1137
+ position: relative;
1138
+ z-index: 4;
1139
+ background: rgb(var(--v-theme-surface));
1140
+ padding: 0.25rem 0.5rem;
1133
1141
  font-size: 0.85rem;
1134
1142
  min-width: 0;
1135
1143
  overflow-wrap: anywhere;
1136
1144
  }
1137
1145
 
1138
1146
  .assistant-transcript__error-content {
1139
- align-items: flex-start;
1147
+ align-items: center;
1140
1148
  display: flex;
1141
- flex-direction: column;
1142
1149
  gap: 0.5rem;
1143
1150
  }
1144
1151
 
@@ -217,6 +217,9 @@ function useAssistantAttachments({
217
217
  if (![maxBytes, maxItems, uploadConcurrency].every(value => Number.isSafeInteger(value) && value > 0)) {
218
218
  throw new TypeError("Attachment limits must be positive integers.");
219
219
  }
220
+ const maxSizeLabel = maxBytes % (1024 * 1024) === 0
221
+ ? `${maxBytes / (1024 * 1024)} MiB`
222
+ : `${maxBytes / 1_000_000} MB`;
220
223
  const attachments = ref([]);
221
224
  const queueItems = ref([]);
222
225
  const dragDepth = ref(0);
@@ -585,7 +588,7 @@ function useAssistantAttachments({
585
588
  return waiter.promise;
586
589
  }
587
590
  if (row.size > maxBytes) {
588
- row.error = `${row.fileName} is larger than the ${maxBytes / 1_000_000} MB attachment limit.`;
591
+ row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
589
592
  row.failureStage = "validation";
590
593
  row.phase = "failed";
591
594
  reportAttachmentError(row.error, "Attachment upload failed.", { notify: false });
@@ -767,7 +770,7 @@ function useAssistantAttachments({
767
770
  row.size = Number(preparedFile.size) || 0;
768
771
  row.totalBytes = row.size;
769
772
  if (row.size > maxBytes) {
770
- row.error = `${row.fileName} is larger than the ${maxBytes / 1_000_000} MB attachment limit.`;
773
+ row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
771
774
  row.failureStage = "validation";
772
775
  row.phase = "failed";
773
776
  settleWaiter(row, null, waiter);
@@ -881,7 +884,7 @@ function useAssistantAttachments({
881
884
  const waiter = row.waiter;
882
885
  waiters.push(waiter.promise);
883
886
  if (row.size > maxBytes) {
884
- row.error = `${row.fileName} is larger than the ${maxBytes / 1_000_000} MB attachment limit.`;
887
+ row.error = `${row.fileName} is larger than the ${maxSizeLabel} attachment limit.`;
885
888
  row.failureStage = "validation";
886
889
  row.phase = "failed";
887
890
  settleWaiter(row, null);
@@ -66,6 +66,7 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
66
66
  const incoming = new Map();
67
67
  let failure = null;
68
68
  let closed = false;
69
+ let supportsSendNow = false;
69
70
  let writes = Promise.resolve();
70
71
  let events = Promise.resolve();
71
72
  let queuedEventBytes = 0;
@@ -105,18 +106,23 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
105
106
  async function request(request, options = {}) {
106
107
  if (failure || closed) throw failure || claudeProtocolError("Claude connection is closed.");
107
108
  const requestId = randomUUID();
109
+ function progress(stage) {
110
+ try { options.onProgress?.(stage); }
111
+ catch { /* Diagnostic observers cannot change protocol completion. */ }
112
+ }
108
113
  let timer;
109
114
  const response = new Promise((resolve, reject) => {
110
115
  timer = setTimeout(() => {
111
116
  requests.delete(requestId);
112
117
  reject(claudeProtocolError(`Claude ${request.subtype} timed out.`, "assistant_claude_control_timeout"));
113
118
  }, options.timeoutMs ?? timeoutMs);
114
- requests.set(requestId, { resolve, reject });
119
+ requests.set(requestId, { resolve, reject, received: () => progress("response_received") });
115
120
  });
116
121
  // Install both observers before a write can fail or an immediate reply arrives.
117
122
  try {
123
+ progress("requested");
118
124
  const [, result] = await Promise.all([
119
- write({ type: "control_request", request_id: requestId, request }), response
125
+ write({ type: "control_request", request_id: requestId, request }).then(() => progress("written")), response
120
126
  ]);
121
127
  return result;
122
128
  } finally {
@@ -163,6 +169,7 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
163
169
  const pending = requests.get(response?.request_id);
164
170
  if (!pending) continue; // A timed-out request may still get a reply.
165
171
  requests.delete(response.request_id);
172
+ pending.received();
166
173
  if (response.subtype === "success") pending.resolve(response.response || {});
167
174
  else pending.reject(claudeProtocolError(String(response.error || "Claude control request failed.")));
168
175
  } else if (frame.type === "control_request") {
@@ -170,6 +177,9 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
170
177
  } else if (frame.type === "control_cancel_request") {
171
178
  incoming.get(frame.request_id)?.controller.abort(claudeProtocolError("Claude cancelled this control request."));
172
179
  } else {
180
+ if (frame.type === "system" && frame.subtype === "init") {
181
+ supportsSendNow = frame.capabilities?.includes("interrupt_send_now_v1") === true;
182
+ }
173
183
  const bytes = Buffer.byteLength(JSON.stringify(frame));
174
184
  queuedEventBytes += bytes;
175
185
  if (queuedEventBytes > 2 * CLAUDE_JSON_MAX_FRAME_BYTES) {
@@ -204,13 +214,20 @@ function createClaudeJsonClient({ stream, onEvent = async () => {}, onControlReq
204
214
 
205
215
  return Object.freeze({
206
216
  completion,
207
- initialize: ({ hooks } = {}) => request({ subtype: "initialize", ...(hooks ? { hooks } : {}) }),
217
+ supportsSendNow: () => supportsSendNow,
218
+ initialize: ({ hooks } = {}, options) => request({ subtype: "initialize", ...(hooks ? { hooks } : {}) }, options),
208
219
  interrupt: () => request({ subtype: "interrupt" }),
209
220
  request,
210
221
  whenRequestsIdle: () => Promise.all([...incoming.values()].map(pending => pending.done)),
211
- send(message, { messageId = randomUUID(), sessionId = "" } = {}) {
212
- return write({ type: "user", uuid: messageId, session_id: sessionId,
222
+ async send(message, { messageId = randomUUID(), sessionId = "", sendNow = false } = {}) {
223
+ // Older CLIs treat this control as a plain Stop, cancelling background
224
+ // workers. Require the native capability before writing either frame.
225
+ if (sendNow && !supportsSendNow) throw Object.assign(
226
+ claudeProtocolError("This Claude version does not support targeted Send now."), { delivery: "not-sent" }
227
+ );
228
+ await write({ type: "user", uuid: messageId, session_id: sessionId,
213
229
  parent_tool_use_id: null, message: { role: "user", content: message } });
230
+ if (sendNow) await request({ subtype: "interrupt", send_now: true, message_uuid: messageId });
214
231
  },
215
232
  close() {
216
233
  closed = true;
@@ -72,15 +72,25 @@ 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
+ // Claude's native placeholder classifier excludes this non-error synthetic
76
+ // carrier after an interrupted request. Real model replies and API errors
77
+ // with the same wording remain ordinary output.
78
+ if (frame.message.model === "<synthetic>" && !frame.isApiErrorMessage && content.length === 1 &&
79
+ content[0]?.type === "text" && content[0].text === "No response requested.") return [];
75
80
  // Native frames split one API message into blocks with distinct UUIDs.
76
81
  const messageId = frame.uuid || frame.message.id;
77
82
  if (!messageId) return [];
83
+ const command = frame.local_command_run?.command;
84
+ const output = /^<local-command-(stdout|stderr)>/u.exec(frame.local_command_source || "")?.[1];
78
85
  commentary ||= content.some((block) => block.type === "tool_use");
79
86
  return content.flatMap((block, index) => {
80
87
  const text = block.type === "thinking" ? block.thinking : block.type === "text" ? block.text : "";
81
88
  return typeof text === "string" && text.trim() ? [{
82
89
  id: `claude_${messageId}_${index}`, text, complete: true,
83
- role: block.type === "thinking" ? "thinking" : commentary ? "commentary" : "assistant"
90
+ role: block.type === "thinking" ? "thinking" : commentary ? "commentary" : "assistant",
91
+ ...(typeof command === "string" && command && output ? {
92
+ data: { native: { engineId: "claude", type: "assistant", command, output } }
93
+ } : {})
84
94
  }] : [];
85
95
  });
86
96
  }
@@ -89,6 +99,7 @@ async function readClaudeHistory(options) {
89
99
  const file = await claudeHistoryPath(options);
90
100
  const inspected = {
91
101
  ...(options.includeUserMessages ? { userMessages: [] } : {}),
102
+ ...(options.includeCommandMessages ? { commandMessages: [] } : {}),
92
103
  ...(options.includeFinalCarriers ? { completedResults: [], failedResults: [], structuredOutputs: [] } : {})
93
104
  };
94
105
  if (!file || file.size === 0) return { exists: false, messages: [], userIds: [], text: "", ...inspected };
@@ -97,6 +108,8 @@ async function readClaudeHistory(options) {
97
108
  const userIds = [];
98
109
  let lastUserId = "";
99
110
  let goal = null;
111
+ let goalStartId = "";
112
+ let goalCommandId = "";
100
113
  const frames = [];
101
114
  const byId = new Map();
102
115
  let leafUuid = "";
@@ -133,19 +146,60 @@ async function readClaudeHistory(options) {
133
146
  }
134
147
  for (const frame of frames) {
135
148
  if (rewound && frame.uuid && !retained.has(frame.uuid)) continue;
149
+ // The exact native local-command receipt owns clear identity. Claude may
150
+ // insert a caveat between the sentinel and authored command, or omit the
151
+ // sentinel after an impossible evaluation. Neither case changes delivery.
152
+ // Ordinary model text is never goal-state evidence.
153
+ if (frame.type === "system" && frame.subtype === "local_command" &&
154
+ !frame.parent_tool_use_id && !frame.isMeta && frame.commandRun?.command === "goal" &&
155
+ frame.commandRun.args === "clear" && typeof frame.content === "string" &&
156
+ /^<local-command-stdout>(?:No goal set|Goal cleared: [\s\S]+)<\/local-command-stdout>$/u.test(frame.content)) {
157
+ goal = null;
158
+ goalStartId = "";
159
+ const command = byId.get(frame.parentUuid);
160
+ goalCommandId = command?.type === "user" && !command.isMeta && !command.parent_tool_use_id
161
+ ? command.uuid : "";
162
+ }
163
+ if (options.includeCommandMessages && frame.type === "system" && frame.subtype === "local_command" &&
164
+ frame.uuid && !frame.parent_tool_use_id && !frame.isMeta &&
165
+ typeof frame.commandRun?.command === "string" && frame.commandRun.command) {
166
+ const output = typeof frame.content === "string"
167
+ ? /^<local-command-(stdout|stderr)>([\s\S]*)<\/local-command-\1>$/u.exec(frame.content) : null;
168
+ if (output) inspected.commandMessages.push({ id: `claude_${frame.uuid}_0`, text: output[2],
169
+ data: { native: { engineId: "claude", type: "system", subtype: "local_command",
170
+ command: frame.commandRun.command, output: output[1] } } });
171
+ }
136
172
  const status = frame.attachment;
137
- if (status?.type === "goal_status" && typeof status.condition === "string") {
173
+ if (frame.type === "attachment" && !frame.parent_tool_use_id &&
174
+ status?.type === "goal_status" && typeof status.condition === "string") {
175
+ if (status.sentinel === true || goal?.objective !== status.condition) {
176
+ goalStartId = status.sentinel === true && typeof status.met === "boolean" ? frame.uuid || "" : "";
177
+ goalCommandId = "";
178
+ }
138
179
  if (status.met === true && status.sentinel === true) goal = null;
139
180
  else if (status.met === false || status.met === true) {
140
181
  const timestamp = Date.parse(frame.timestamp) / 1000;
141
182
  goal = {
142
183
  threadId: options.conversationId, objective: status.condition,
143
184
  createdAt: goal?.objective === status.condition && !status.sentinel ? goal.createdAt : timestamp,
144
- status: status.met ? "complete" : "active", updatedAt: timestamp,
185
+ status: status.met ? "complete" : status.failed === true ? "blocked" : "active", updatedAt: timestamp,
145
186
  reason: typeof status.reason === "string" ? status.reason : ""
146
187
  };
147
188
  }
148
189
  }
190
+ // This is native terminal evidence, not an inference from the assistant's
191
+ // answer or its wording. Retain it across process and application restarts.
192
+ if (goal?.status === "active" && frame.type === "system" && frame.subtype === "informational" &&
193
+ !frame.parent_tool_use_id && typeof frame.content === "string" &&
194
+ /^A hook blocked the turn from ending \d+ consecutive times — overriding and ending turn\./u.test(frame.content)) {
195
+ goal = { ...goal, status: "blocked", updatedAt: Date.parse(frame.timestamp) / 1000,
196
+ reason: ["Claude stopped after repeated attempts to continue the unfinished goal. Resolve the blocker, then resume the goal.", goal.reason].filter(Boolean).join("\n\n") };
197
+ }
198
+ // Native /goal appends its sentinel before the authored command, whose
199
+ // parent identifies that exact attachment. A later evaluation keeps it.
200
+ if (frame.type === "user" && !frame.isMeta && frame.uuid && goalStartId && frame.parentUuid === goalStartId) {
201
+ goalCommandId = frame.uuid;
202
+ }
149
203
  if (frame.type === "user" && !frame.isMeta && frame.uuid &&
150
204
  (typeof frame.message?.content === "string" || frame.message?.content?.some((block) => block.type === "text"))) {
151
205
  userIds.push(frame.uuid);
@@ -182,10 +236,13 @@ async function readClaudeHistory(options) {
182
236
  }
183
237
  }
184
238
  for (const message of claudeMessageBlocks(frame, { commentary: toolMessages.has(frame.message?.id) })) {
185
- messages.set(message.id, { ...message, userId: lastUserId });
239
+ messages.set(message.id, { ...message, userId: lastUserId,
240
+ ...(frame.message.content.length === 1 && typeof frame.message.id === "string" && frame.message.id &&
241
+ Number.isSafeInteger(frame.apiBlockIndex) && frame.apiBlockIndex >= 0
242
+ ? { outputId: `claude_${frame.message.id}_${frame.apiBlockIndex}` } : {}) });
186
243
  }
187
244
  }
188
- return { exists: true, messages: [...messages.values()], userIds, goal, ...inspected,
245
+ return { exists: true, messages: [...messages.values()], userIds, goal, goalCommandId, ...inspected,
189
246
  text: [...messages.values()].filter((message) => message.role === "assistant" && message.userId === lastUserId)
190
247
  .map((message) => message.text).join("\n") };
191
248
  }
@@ -240,7 +240,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
240
240
  accountProcessStops.delete(native);
241
241
  }
242
242
 
243
- async function readAccountProcess(identity, expectedRevision, read) {
243
+ async function readAccountProcess(identity, expectedRevision, purpose, read) {
244
244
  assertCurrent(expectedRevision);
245
245
  for (const native of accountProcessStops) await stopAccountProcess(native);
246
246
  assertCurrent(expectedRevision);
@@ -256,7 +256,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
256
256
  cancelled.promise.catch(() => {});
257
257
  let native;
258
258
  try {
259
- native = owned?.process || await createProcess({ signal: controller.signal });
259
+ native = owned?.process || await createProcess({ signal: controller.signal, purpose });
260
260
  controller.signal.throwIfAborted();
261
261
  return await Promise.race([read(native), cancelled.promise]);
262
262
  } finally {
@@ -274,7 +274,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
274
274
  assertCurrent(expectedRevision);
275
275
  if (!catalog || catalog.identity !== identity || Date.now() - catalog.at > 600_000) {
276
276
  if (!catalogStarts.has(identity)) catalogStarts.set(identity, (async () => {
277
- const value = await readAccountProcess(identity, expectedRevision, (native) => native.initialization);
277
+ const value = await readAccountProcess(identity, expectedRevision, "catalogue", (native) => native.initialization);
278
278
  if (identity !== await accountIdentity(context)) throw error("The signed-in Claude account changed. Refresh the model list.");
279
279
  assertCurrent(expectedRevision);
280
280
  catalog = { at: Date.now(), identity, value };
@@ -292,7 +292,7 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
292
292
  if (planUsage?.identity === identity && Date.now() - planUsage.checkedAt < 60_000) return planUsage;
293
293
  if (planUsagePending.has(identity)) return planUsagePending.get(identity);
294
294
  const pending = (async () => {
295
- const value = await readAccountProcess(identity, expectedRevision, (native) => native.client.request(
295
+ const value = await readAccountProcess(identity, expectedRevision, "usage", (native) => native.client.request(
296
296
  { subtype: "get_usage", skip_behaviors: true }));
297
297
  if (identity !== await accountIdentity(context)) return { status: "unavailable", windows: [] };
298
298
  assertCurrent(expectedRevision);
@@ -317,7 +317,8 @@ export function createClaudeAccountQueries({ createProcess, accountIdentity, pro
317
317
  /** Native startup and handshake; an execution host owns processes and their identity. */
318
318
  export async function createClaudeCodeProcess({
319
319
  command = "claude", execution = createLocalConversationExecution(), env = process.env,
320
- workdir, onEvent, onControlRequest, onFailure, onStarted, executionLimits, signal, commandWrapper, ...options
320
+ workdir, onEvent, onControlRequest, onFailure, onStarted, executionLimits, signal, commandWrapper,
321
+ initializationTimeoutMs = 30_000, onInitializationProgress, ...options
321
322
  } = {}) {
322
323
  if (commandWrapper !== undefined) validateCommandWrapper(commandWrapper);
323
324
  const environment = { ...env, DISABLE_AUTOUPDATER: "1" };
@@ -358,7 +359,7 @@ export async function createClaudeCodeProcess({
358
359
  signal?.addEventListener("abort", abort, { once: true });
359
360
  const initializing = client.initialize(commandWrapper ? { hooks: {
360
361
  PreToolUse: [{ matcher: "^Bash$", hookCallbackIds: ["jskit-command-wrapper"], timeout: 30 }]
361
- } } : {});
362
+ } } : {}, { timeoutMs: initializationTimeoutMs, onProgress: onInitializationProgress });
362
363
  const initialization = await (native.exited ? Promise.race([
363
364
  initializing,
364
365
  native.exited.then(processExit => {