@jini-ai/chat 0.6.17 → 0.6.19

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 (40) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/dist/core/events.d.ts +6 -0
  3. package/dist/core/run-events/entry.js +15 -2
  4. package/dist/core/surface-recovery.d.ts +19 -0
  5. package/dist/core/surface-recovery.js +26 -0
  6. package/dist/core/tool-events.d.ts +6 -1
  7. package/dist/core/tool-events.js +74 -27
  8. package/dist/core/turn-usage.d.ts +1 -1
  9. package/dist/core/turn-usage.js +5 -3
  10. package/dist/embed-widget/browser/frontend-session-bridge.d.ts +14 -1
  11. package/dist/embed-widget/browser/frontend-session-bridge.js +120 -47
  12. package/dist/react/components/Composer.js +2 -1
  13. package/dist/react/components/ConversationList.js +2 -1
  14. package/dist/react/components/ConversationTitleEditor.js +2 -1
  15. package/dist/react/components/ExtEventErrorBoundary.js +2 -1
  16. package/dist/react/components/McpUiSurfaceCard.js +12 -5
  17. package/dist/react/components/ToolCard.js +3 -2
  18. package/dist/react/components/ToolCard.rules.js +2 -1
  19. package/dist/react/features/chat-pane/components/AgentRuntimePicker.js +5 -3
  20. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.d.ts +15 -2
  21. package/dist/react/features/chat-pane/create-mcp-ui-tool-caller.js +19 -3
  22. package/dist/react/features/chat-pane/hooks/useChatPane.hooks.js +14 -0
  23. package/dist/react/features/chat-pane/hooks/useChatPaneFileDrop.hooks.js +3 -2
  24. package/dist/react/features/chat-pane/hooks/useRuntimeDropUp.hooks.js +20 -3
  25. package/dist/react/features/chat-pane/model-options.d.ts +10 -0
  26. package/dist/react/features/chat-pane/model-options.js +22 -3
  27. package/dist/react/features/chat-pane/styles.d.ts +1 -1
  28. package/dist/react/features/chat-pane/styles.js +3 -1
  29. package/dist/react/features/chat-pane/surface-recovery-stream.d.ts +12 -0
  30. package/dist/react/features/chat-pane/surface-recovery-stream.js +47 -0
  31. package/dist/react/hooks/composer-draft-cache.d.ts +6 -6
  32. package/dist/react/hooks/composer-draft-cache.js +18 -17
  33. package/dist/react/hooks/useComposer.js +52 -4
  34. package/dist/react/hooks/useConversation.js +28 -20
  35. package/dist/react/hooks/useRunStream.js +2 -1
  36. package/dist/react/hooks/useSurfaceRecovery.d.ts +23 -0
  37. package/dist/react/hooks/useSurfaceRecovery.js +84 -0
  38. package/dist/react/styles/reference.css +4 -5
  39. package/dist/store/runtime/chat-expiry-sweep.js +3 -1
  40. package/package.json +12 -12
package/CHANGELOG.md CHANGED
@@ -1,3 +1,12 @@
1
+ ## 0.6.19
2
+
3
+ - A direct MCP call (`mcp__<server>__X`, any CLI) shows as one row even when the MCP server normalized its arguments before the bridge saw them: once the CLI's own result has arrived, the call pairs by name with the bridge's `X` row between the two. Equal-input pairing still runs first across the turn; refusals and open calls never pair by name.
4
+ - The chat model picker names raw catalog ids friendly ("Claude Opus 5.5", context and date suffixes kept), shows "Offline fallback" only once the live model list has actually failed rather than while the host is still probing, and closes on Escape from the menu or its trigger even when an ancestor stops the keydown.
5
+ - A reply's "Done · time" runs from the person's send to the run's end for every CLI; the CLI's own reported duration, which leaves out its start-up wait, is only the fallback.
6
+ - Keep failed tool receipts visible unless a matching canonical call also failed, including buffered CLI receipts.
7
+ - Remove an obsolete hook lint directive for an uninstalled rule.
8
+ - Document the two 0.6.x fetch fallback exceptions, inject fetch in repo hosts, and redact expiry/React callback exception logs.
9
+
1
10
  ## 0.6.17
2
11
 
3
12
  ### Patch Changes
@@ -56,6 +56,12 @@ export type AgentEvent = {
56
56
  id: string;
57
57
  name: string;
58
58
  input: unknown;
59
+ bridgeCall?: {
60
+ name: string;
61
+ input: unknown;
62
+ buffered?: boolean;
63
+ };
64
+ toolOrigin?: 'bridge';
59
65
  } | {
60
66
  kind: 'tool_result';
61
67
  toolUseId: string;
@@ -5,8 +5,19 @@
5
5
  * Authenticated routing, ownership, boot repair and storage transactions remain host responsibilities. */
6
6
  import { decodeSseFrames, parseSseRecord } from "@jini-ai/core/primitives";
7
7
  import { assistantContentFromEvents } from "../assistant-content.js";
8
+ import { recoverableSurfaceResource } from '../surface-recovery.js';
8
9
  import { mergeAdjacentTextEvents } from "../compact-events.js";
9
10
  import { dedupeToolUsesById } from "../tool-events.js";
11
+ /** Retain executor-decoded display correlation through both live and saved chat reduction.
12
+ * Only the delegated bridge marks canonical rows; malformed optional metadata is ignored. O(1). */
13
+ function toolCallCorrelation({ payload }) {
14
+ const call = payload.bridgeCall;
15
+ return {
16
+ ...(payload.toolOrigin === 'bridge' ? { toolOrigin: 'bridge' } : {}),
17
+ ...(isRecord(call) && typeof call.name === 'string' ? { bridgeCall: { name: call.name, input: call.input,
18
+ ...(call.buffered === true ? { buffered: true } : {}) } } : {}),
19
+ };
20
+ }
10
21
  function wireString(v) {
11
22
  if (typeof v === "string")
12
23
  return v;
@@ -84,7 +95,8 @@ export function translateRunAgentPayload({ payload }, _options = {}) {
84
95
  case "thinking_delta":
85
96
  return { kind: "thinking", text: wireString(payload.delta) };
86
97
  case "tool_use":
87
- return { kind: "tool_use", id: wireString(payload.id), name: wireString(payload.name), input: payload.input };
98
+ return { kind: "tool_use", id: wireString(payload.id), name: wireString(payload.name), input: payload.input,
99
+ ...toolCallCorrelation({ payload }) };
88
100
  case "tool_result":
89
101
  return toolResultEvent(payload);
90
102
  case "usage":
@@ -126,7 +138,8 @@ function translateEnvelopePayload(payload) {
126
138
  // worst possible failure for a confirmation dialog. Unwrapping here is what makes the two ends
127
139
  // meet. `name` must stay `"mcp-ui"` to match `MCP_UI_EXT_EVENT_NAME`.
128
140
  case "mcp-ui":
129
- return { kind: "ext", name: "mcp-ui", data: payload.resource };
141
+ return { kind: "ext", name: "mcp-ui", data: recoverableSurfaceResource({ resource: payload.resource,
142
+ exchange: 'surfaceExchange' in payload ? payload.surfaceExchange : undefined }) };
130
143
  // A2UI's own agent->renderer envelope (`@jini-ai/core`'s `SurfaceEmission` with
131
144
  // `channel: "a2ui"`, injected by `@jini-ai/daemon`'s `delegated-tool-bridge.ts` as
132
145
  // `{type: "a2ui", message: <AgentToRendererMessage>}`). Unwrapped to the bare `.message` here,
@@ -0,0 +1,19 @@
1
+ /** Wire metadata belongs to the channel adapter, never to rendered HTML or model context. */
2
+ export declare const SURFACE_EXCHANGE_META = "jini/surface-exchange";
3
+ export interface SurfaceRecoveryInfo {
4
+ readonly exchangeId: string;
5
+ readonly toolId: string;
6
+ readonly runId: string;
7
+ }
8
+ /** Read only server-issued exchange identity; original tool arguments never cross this boundary.
9
+ * @complexity O(1). */
10
+ export declare function surfaceRecoveryInfo({ value }: {
11
+ value: unknown;
12
+ }, _options?: {}): SurfaceRecoveryInfo | undefined;
13
+ /** Preserve the existing bare-resource renderer contract while attaching recovery identity.
14
+ * @complexity O(1), shallow copies only. Malformed resources remain the renderer's responsibility. */
15
+ export declare function recoverableSurfaceResource({ resource, exchange }: {
16
+ resource: unknown;
17
+ exchange: unknown;
18
+ }, _options?: {}): unknown;
19
+ //# sourceMappingURL=surface-recovery.d.ts.map
@@ -0,0 +1,26 @@
1
+ /** Wire metadata belongs to the channel adapter, never to rendered HTML or model context. */
2
+ export const SURFACE_EXCHANGE_META = 'jini/surface-exchange';
3
+ function record(value) {
4
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
5
+ }
6
+ /** Read only server-issued exchange identity; original tool arguments never cross this boundary.
7
+ * @complexity O(1). */
8
+ export function surfaceRecoveryInfo({ value }, _options = {}) {
9
+ if (!record(value))
10
+ return undefined;
11
+ if (typeof value.exchangeId !== 'string' || typeof value.toolId !== 'string' || typeof value.runId !== 'string')
12
+ return undefined;
13
+ if (!value.exchangeId || !value.toolId || !value.runId)
14
+ return undefined;
15
+ return { exchangeId: value.exchangeId, toolId: value.toolId, runId: value.runId };
16
+ }
17
+ /** Preserve the existing bare-resource renderer contract while attaching recovery identity.
18
+ * @complexity O(1), shallow copies only. Malformed resources remain the renderer's responsibility. */
19
+ export function recoverableSurfaceResource({ resource, exchange }, _options = {}) {
20
+ const info = surfaceRecoveryInfo({ value: exchange });
21
+ if (!info || !record(resource) || !record(resource.resource))
22
+ return resource;
23
+ const meta = record(resource.resource._meta) ? resource.resource._meta : {};
24
+ return { ...resource, resource: { ...resource.resource, _meta: { ...meta, [SURFACE_EXCHANGE_META]: info } } };
25
+ }
26
+ //# sourceMappingURL=surface-recovery.js.map
@@ -45,9 +45,14 @@ export declare function isDelegatedWrapperToolName({ name }: {
45
45
  * Antigravity buffers CLI receipts: its adapter-described wrappers match equal inner inputs
46
46
  * anywhere in this assistant turn, in appearance order among equal keys. Pass only one turn's events;
47
47
  * no state is retained across calls. Wrapper and bridge rows are each claimed at most once.
48
+ * Receipt and canonical failure states must match: a success cannot consume a failed row,
49
+ * and an identical successful or pending call cannot account for a refusal before the bridge,
50
+ * regardless of buffered receipt ordering.
48
51
  * A legacy gateway folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`.
49
52
  * A direct call folds onto the first LATER `X` row with structurally equal JSON input that arrives
50
- * before the CLI's own result for it. No canonical row is claimed twice, and a folded wrapper's
53
+ * before the CLI's own result for it; failing that, once that result has arrived, onto the first
54
+ * unclaimed same-name row between the two, since argument normalization can change the bridge's
55
+ * input (see {@link findCanonicalRow}). No canonical row is claimed twice, and a folded wrapper's
51
56
  * `tool_result` events go with it. A wrapper with no such canonical row (refused before the
52
57
  * bridge, e.g. a read-only 403) stays, so its error is not hidden.
53
58
  *
@@ -61,15 +61,21 @@ function directMcpToolName({ name }) {
61
61
  * Antigravity's adapter-described wrappers require equal inner inputs but may arrive buffered.
62
62
  */
63
63
  function wrapperCall({ event }, _options = {}) {
64
- const wrapper = toolPresentation(event).wrapper;
64
+ const wrapper = event.bridgeCall ?? toolPresentation(event).wrapper;
65
+ const bridgeOnly = event.bridgeCall !== undefined;
66
+ const buffered = bridgeOnly ? event.bridgeCall?.buffered === true : wrapper !== undefined;
65
67
  const call = wrapper ?? event;
66
68
  if (!isDelegatedWrapperToolName({ name: call.name })) {
67
69
  const name = wrapper ? wrapper.name : directMcpToolName({ name: call.name });
68
- return name ? { name, input: call.input, matchInput: true, buffered: wrapper !== undefined } : undefined;
70
+ return name ? { name, input: call.input, matchInput: true, buffered, bridgeOnly } : undefined;
69
71
  }
70
- const args = call.input !== null && typeof call.input === 'object' ? call.input : {};
71
- const name = args.toolId;
72
- return typeof name === 'string' && name ? { name, input: args.input, matchInput: wrapper !== undefined, buffered: wrapper !== undefined } : undefined;
72
+ const inner = delegatedCall({ input: call.input });
73
+ return inner ? { ...inner, matchInput: wrapper !== undefined, buffered, bridgeOnly } : undefined;
74
+ }
75
+ /** The shared delegated gateway contract, independent of the CLI's envelope. Pure, O(1). */
76
+ function delegatedCall({ input }, _options = {}) {
77
+ const args = input !== null && typeof input === 'object' ? input : {};
78
+ return typeof args.toolId === 'string' && args.toolId ? { name: args.toolId, input: args.input } : undefined;
73
79
  }
74
80
  /**
75
81
  * Drop each delegated or direct MCP wrapper call that the daemon also reported as its own canonical row,
@@ -88,9 +94,14 @@ function wrapperCall({ event }, _options = {}) {
88
94
  * Antigravity buffers CLI receipts: its adapter-described wrappers match equal inner inputs
89
95
  * anywhere in this assistant turn, in appearance order among equal keys. Pass only one turn's events;
90
96
  * no state is retained across calls. Wrapper and bridge rows are each claimed at most once.
97
+ * Receipt and canonical failure states must match: a success cannot consume a failed row,
98
+ * and an identical successful or pending call cannot account for a refusal before the bridge,
99
+ * regardless of buffered receipt ordering.
91
100
  * A legacy gateway folds onto the first LATER canonical `tool_use` whose name equals its `input.toolId`.
92
101
  * A direct call folds onto the first LATER `X` row with structurally equal JSON input that arrives
93
- * before the CLI's own result for it. No canonical row is claimed twice, and a folded wrapper's
102
+ * before the CLI's own result for it; failing that, once that result has arrived, onto the first
103
+ * unclaimed same-name row between the two, since argument normalization can change the bridge's
104
+ * input (see {@link findCanonicalRow}). No canonical row is claimed twice, and a folded wrapper's
94
105
  * `tool_result` events go with it. A wrapper with no such canonical row (refused before the
95
106
  * bridge, e.g. a read-only 403) stays, so its error is not hidden.
96
107
  *
@@ -106,19 +117,24 @@ export function foldDelegatedWrapperCalls({ events }, _options = {}) {
106
117
  return [];
107
118
  const claimed = new Set();
108
119
  const folded = new Set();
109
- events.forEach((event, i) => {
110
- if (event.kind !== 'tool_use')
111
- return;
112
- const call = wrapperCall({ event });
113
- // A replayed wrapper (same id) must not claim a second canonical row.
114
- if (call === undefined || folded.has(event.id))
115
- return;
116
- const match = findCanonicalRow({ events, wrapper: event, wrapperIndex: i, call, claimed });
117
- if (match === -1)
118
- return;
119
- claimed.add(match);
120
- folded.add(event.id);
121
- });
120
+ const failedResults = new Set(events.flatMap(event => event.kind === 'tool_result' && event.isError ? [event.toolUseId] : []));
121
+ // Equal inputs pair first across the whole turn, so a by-name pairing never takes the row an
122
+ // exact match needed; only then may a direct call pair by name inside its closed window.
123
+ for (const byName of [false, true]) {
124
+ events.forEach((event, i) => {
125
+ if (event.kind !== 'tool_use')
126
+ return;
127
+ const call = wrapperCall({ event });
128
+ // A replayed wrapper (same id) must not claim a second canonical row.
129
+ if (call === undefined || folded.has(event.id))
130
+ return;
131
+ const match = findCanonicalRow({ events, wrapper: event, wrapperIndex: i, call, claimed, failedResults }, { byName });
132
+ if (match === -1)
133
+ return;
134
+ claimed.add(match);
135
+ folded.add(event.id);
136
+ });
137
+ }
122
138
  if (folded.size === 0)
123
139
  return events;
124
140
  return events.filter((event) => !((event.kind === 'tool_use' && folded.has(event.id)) ||
@@ -127,29 +143,60 @@ export function foldDelegatedWrapperCalls({ events }, _options = {}) {
127
143
  /**
128
144
  * Index of the first unclaimed canonical row in the wrapper's matching window, or -1. Legacy gateways match
129
145
  * on name alone; direct calls and adapter-described wrappers also require equal inner input.
146
+ *
147
+ * `byName` (the second pass) lets a direct `mcp__<server>__X` call pair with a same-name row whose
148
+ * input differs, but only inside a CLOSED window: after the call and before the CLI's own result for
149
+ * it. The MCP server normalizes arguments before the bridge sees them (an integer string read as the
150
+ * integer, an alias renamed, see `@jini-ai/mcp`'s `tool-arguments.ts`), so the bridge row's input is
151
+ * not always the CLI's (demo practice run, 2026-11-02: every such call showed twice). The CLI's result
152
+ * only returns after the bridge row its call caused, so a same-name row in that window is that call.
153
+ * An open window (no CLI result yet) and buffered or legacy wrappers never pair by name.
130
154
  * @throws CanonicalJsonError if a direct call's input contains non-JSON values.
131
155
  * @complexity O(n·s): n events, s canonical input serialization cost.
132
156
  */
133
- function findCanonicalRow({ events, wrapper, wrapperIndex, call, claimed }) {
134
- const inputKey = call.matchInput ? canonicalJson({ value: { input: call.input } }) : undefined;
135
- const [start, end] = canonicalSearchWindow({ events, wrapper, wrapperIndex, call });
157
+ function findCanonicalRow({ events, wrapper, wrapperIndex, call, claimed, failedResults }, { byName = false } = {}) {
158
+ const [start, end, closed] = canonicalSearchWindow({ events, wrapper, wrapperIndex, call });
159
+ const match = matchRule({ call, byName, closed });
160
+ if (match === undefined)
161
+ return -1;
162
+ const inputKey = match.compareInput ? canonicalJson({ value: { input: call.input } }) : undefined;
163
+ const wrapperFailed = failedResults.has(wrapper.id);
136
164
  for (let j = start; j < end; j += 1) {
137
165
  const other = events[j];
138
- if (claimed.has(j) || other.kind !== 'tool_use' || other.name !== call.name)
139
- continue;
140
- if (inputKey === undefined || canonicalJson({ value: { input: other.input } }) === inputKey)
166
+ if (!claimed.has(j) && isCandidateRow({ other, call, wrapperFailed, failedResults, inputKey }))
141
167
  return j;
142
168
  }
143
169
  return -1;
144
170
  }
171
+ /** How one pass matches `call`, or undefined when this pass must skip it. Pass two (`byName`) only
172
+ * revisits direct calls whose window is closed; everything else already had its chance. Pure; O(1). */
173
+ function matchRule({ call, byName, closed }, _options = {}) {
174
+ if (!byName)
175
+ return { compareInput: call.matchInput };
176
+ return closed && call.matchInput && !call.buffered ? { compareInput: false } : undefined;
177
+ }
178
+ /** Whether `other` is an unclaimed row's possible canonical twin: a same-name `tool_use` with the same
179
+ * outcome and, when `inputKey` is given, structurally equal input. Pure; O(s) input serialization. */
180
+ function isCandidateRow({ other, call, wrapperFailed, failedResults, inputKey }, _options = {}) {
181
+ if (other.kind !== 'tool_use' || other.name !== call.name)
182
+ return false;
183
+ // Adapter-proposed bare/native matches must be bridge rows, never independent CLI executions.
184
+ if (call.bridgeOnly && other.toolOrigin !== 'bridge')
185
+ return false;
186
+ // Outcome parity keeps successes from consuming failed rows and duplicating later refusals.
187
+ if (wrapperFailed !== failedResults.has(other.id))
188
+ return false;
189
+ return inputKey === undefined || canonicalJson({ value: { input: other.input } }) === inputKey;
190
+ }
145
191
  /** Pure matching bounds within one turn; O(n) time, O(1) space for n events.
146
192
  * Antigravity's buffered receipts cannot establish arrival/result boundaries. Direct MCP calls
147
193
  * retain their later-only window so a completed/refused call cannot consume a separate retry.
194
+ * `closed` is true only when the CLI's own result ends the window (the by-name pass needs it).
148
195
  */
149
196
  function canonicalSearchWindow({ events, wrapper, wrapperIndex, call }, _options = {}) {
150
197
  if (call.buffered)
151
- return [0, events.length];
198
+ return [0, events.length, false];
152
199
  const resultIndex = !call.matchInput ? -1 : events.findIndex(other => other.kind === 'tool_result' && other.toolUseId === wrapper.id);
153
- return [wrapperIndex + 1, resultIndex === -1 ? events.length : resultIndex];
200
+ return resultIndex === -1 ? [wrapperIndex + 1, events.length, false] : [wrapperIndex + 1, resultIndex, true];
154
201
  }
155
202
  //# sourceMappingURL=tool-events.js.map
@@ -40,7 +40,7 @@ export declare function turnUsage({ events }: {
40
40
  agentId?: string;
41
41
  }): UsageEvent | undefined;
42
42
  /** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
43
- * chats alike. Single replies without reported duration use the message's elapsed run time.
43
+ * chats alike. A single reply's duration is the message's elapsed run time, from send to finish.
44
44
  * A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
45
45
  * and m messages; no per-row transcript scans or I/O.
46
46
  * @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
@@ -113,7 +113,7 @@ function addUsage({ total, usage }, _optional = {}) {
113
113
  return next;
114
114
  }
115
115
  /** Derive per-turn charges and prefix totals from persisted events_json, for live and reloaded
116
- * chats alike. Single replies without reported duration use the message's elapsed run time.
116
+ * chats alike. A single reply's duration is the message's elapsed run time, from send to finish.
117
117
  * A message with mid-run replies also gets per-reply `segments`. O(e + m) for e events
118
118
  * and m messages; no per-row transcript scans or I/O.
119
119
  * @example conversationUsage({ messages: savedMessages }).get('assistant-2') */
@@ -157,9 +157,11 @@ function messageUsage({ message, sessions, totalCostUsd }, _optional = {}) {
157
157
  }
158
158
  return { usage, segments, totalCostUsd: prefixTotal };
159
159
  }
160
- /** Use the message's elapsed run time when no reply reported a duration; invalid timestamps stay unknown. */
160
+ /** Time one reply from the person's send (`startedAt`) to the run's end, the same way for every CLI.
161
+ * A CLI's own reported duration leaves out its start-up wait (Claude Code said 42 s for a turn that took
162
+ * 46.5 s from send to finish), so it is only the fallback when the timestamps are missing or invalid. */
161
163
  function fillRunDuration({ usage, message }, _optional = {}) {
162
- if (usage.durationMs !== undefined || message.startedAt === undefined || message.endedAt === undefined)
164
+ if (message.startedAt === undefined || message.endedAt === undefined)
163
165
  return;
164
166
  const durationMs = message.endedAt - message.startedAt;
165
167
  if (Number.isFinite(durationMs) && durationMs >= 0)
@@ -72,9 +72,22 @@ export interface FrontendSessionBridge {
72
72
  sessionId: string;
73
73
  bindToken: string;
74
74
  }>;
75
+ /**
76
+ * Ensure a connection and wait for its current attachment; an already live binding is reused.
77
+ * Call after successful host activation. Unlike `ready`, prior readiness cannot satisfy this wait.
78
+ * Cancellation rejects only this caller's wait; park/failure/close rejects all current waiters.
79
+ * @example await bridge.rebind({ signal }, {});
80
+ * @complexity O(1) per waiter; one shared stream, no automatic reconnect after park/failure.
81
+ */
82
+ rebind(required?: {
83
+ signal?: AbortSignal;
84
+ }, optional?: Record<string, never>): Promise<{
85
+ sessionId: string;
86
+ bindToken: string;
87
+ }>;
75
88
  /**
76
89
  * The token for the attachment that is live *now*, or `undefined` before the first attach and
77
- * after {@link FrontendSessionBridge.close}. Read it at run-start; see the module doc.
90
+ * after park, suspension, failure or {@link FrontendSessionBridge.close}. Read it at run-start.
78
91
  */
79
92
  bindToken(): string | undefined;
80
93
  close(): void;
@@ -27,13 +27,13 @@ import { decodeSseFrames } from '@jini-ai/core/primitives';
27
27
  * - **The bind token never leaves this module except to the host.** It arrives on the `attached`
28
28
  * event and must ride to run-start; it deliberately does not appear in any URL (see
29
29
  * `@jini-ai/daemon`'s `FrontendSessionHandle`).
30
- * - **Read the bind token at send time, not once.** `EventSource` reconnects on its own — after a
31
- * daemon restart, a sleeping laptop, or an ordinary network blip — and each reattach mints a new
30
+ * - **Read the bind token at send time, not once.** After a
31
+ * daemon restart, a sleeping laptop, or an ordinary network blip, each reattach mints a new
32
32
  * session and a new token. A host that captured `ready`'s token keeps sending a dead one, every
33
33
  * later run fails to bind, and the only trace is a daemon-side log line: the agent is simply told
34
34
  * "no frontend is bound to this run". `ready` therefore answers "has it attached at least once";
35
35
  * {@link FrontendSessionBridge.bindToken} answers "what is valid now", and only the latter
36
- * belongs in a run request.
36
+ * belongs in a run request. Park/failure closes the transport; hosts explicitly rebind after wake.
37
37
  */
38
38
  import { CHAT_CAPABILITIES } from '../../core/index.js';
39
39
  import { PAGE_CAPABILITIES, executePageCapability } from '@jini-ai/agentic';
@@ -119,6 +119,8 @@ function readFrame(data) {
119
119
  */
120
120
  export function createFrontendSessionBridge({ baseUrl: base, openStream, request, fetch, endpointPrefix = '/api/frontend-sessions', headers }, options = {}) {
121
121
  const capabilities = claimedCapabilities(options);
122
+ // 0.6.x compatibility: fetch was optional for published hosts;
123
+ // remove when fetch injection becomes required in the next minor.
122
124
  // The only caller (`respond`) never passes its own signal, so the deadline is the whole signal.
123
125
  const send = request ?? (({ url, init, timeoutMs }) => (fetch ?? globalThis.fetch)(url, { ...init, signal: AbortSignal.timeout(timeoutMs) }));
124
126
  let sessionId;
@@ -138,6 +140,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
138
140
  /** Invocation ids already run here, so a stream reconnect cannot double-click a button. */
139
141
  const executed = new Set();
140
142
  const pending = new Map();
143
+ const attachments = new Set();
144
+ let source;
145
+ let closed = false;
141
146
  // A page driver owns one live screen. Serialize its observations, effect and acknowledgement;
142
147
  // otherwise a second invocation could overwrite the first one's host-bound navigation plan.
143
148
  let pageQueue = Promise.resolve();
@@ -148,7 +153,29 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
148
153
  pending.clear();
149
154
  };
150
155
  const query = capabilities.map((id) => `capability=${encodeURIComponent(id)}`).join('&');
151
- const source = openStream({ url: `${base}${endpointPrefix}/stream?${query}`, ...(headers ? { headers: headers() } : {}) });
156
+ /** Invalidate before any reopen; queued and running page work cannot cross attachments.
157
+ * O(pending invocations + attachment waiters), with no retained settled waiters. */
158
+ function invalidate({ error }) {
159
+ abortPending();
160
+ pageQueue = Promise.resolve();
161
+ // The daemon deletes a session's bind token the instant that connection drops, so by the time
162
+ // this fires the token in hand is already gone from the registry. Holding it until the next
163
+ // `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
164
+ // started in that window fails to bind — reported to the agent only as "no frontend is bound
165
+ // to this run", with the real reason (a reconnect it never saw) nowhere in view.
166
+ // Reporting no token is the honest answer for a surface that is, right now, not attached.
167
+ currentBindToken = undefined;
168
+ sessionId = undefined;
169
+ for (const waiter of attachments)
170
+ waiter.reject(error);
171
+ attachments.clear();
172
+ }
173
+ function disconnect({ error }) {
174
+ const previous = source;
175
+ source = undefined;
176
+ invalidate({ error });
177
+ previous?.close();
178
+ }
152
179
  async function respond(invocationId, body) {
153
180
  if (sessionId === undefined)
154
181
  throw new Error('the surface is not attached yet');
@@ -169,7 +196,9 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
169
196
  const args = { capabilityId: action.capabilityId, input };
170
197
  if (options.pageApproval && !options.pageApproval.isDirect(args)) {
171
198
  await respond(action.invocationId, { phase: 'approval-pending' });
199
+ signal.throwIfAborted();
172
200
  const answer = await options.pageApproval.request({ ...args, input: structuredClone(input), signal });
201
+ signal.throwIfAborted();
173
202
  // Report the decision before effects, including decline/expiry; the server audits it.
174
203
  await respond(action.invocationId, { phase: 'execution-started', decision: answer.confirmed ? 'approved' : answer.reason });
175
204
  if (!answer.confirmed)
@@ -197,6 +226,8 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
197
226
  }
198
227
  catch (error) {
199
228
  pending.delete(action.invocationId);
229
+ if (controller.signal.aborted)
230
+ return;
200
231
  await respondError(action.invocationId, errorMessage(error)).catch(options.onError ?? (() => undefined));
201
232
  return;
202
233
  }
@@ -207,6 +238,7 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
207
238
  try {
208
239
  signal.throwIfAborted();
209
240
  const projected = await options.onBeforeResponse?.({ action, output });
241
+ signal.throwIfAborted();
210
242
  const acceptedOutput = projected ?? output;
211
243
  await respondSuccess(action.invocationId, acceptedOutput);
212
244
  signal.throwIfAborted();
@@ -250,44 +282,60 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
250
282
  else
251
283
  void serveLocally(action, controller);
252
284
  }
253
- source.addEventListener('message', (event) => {
254
- const frame = readFrame(event.data);
255
- switch (frame.kind) {
256
- case 'cancelled':
257
- pending.get(frame.invocationId)?.abort();
285
+ function connect() {
286
+ const current = openStream({ url: `${base}${endpointPrefix}/stream?${query}`, ...(headers ? { headers: headers() } : {}) });
287
+ source = current;
288
+ current.addEventListener('message', (event) => {
289
+ if (source !== current)
258
290
  return;
259
- case 'attached':
260
- abortPending();
261
- sessionId = frame.sessionId;
262
- currentBindToken = frame.bindToken;
263
- // A no-op after the first attach — which is exactly why `currentBindToken` exists.
264
- resolveReady({ sessionId, bindToken: currentBindToken });
265
- return;
266
- case 'refused':
267
- rejectReady(new Error(frame.message));
268
- return;
269
- case 'malformed':
270
- options.onError?.(frame.error);
271
- return;
272
- case 'invocation':
273
- handleInvocation(frame.action);
274
- return;
275
- case 'ignored':
291
+ const frame = readFrame(event.data);
292
+ switch (frame.kind) {
293
+ case 'cancelled':
294
+ pending.get(frame.invocationId)?.abort();
295
+ return;
296
+ case 'attached':
297
+ abortPending();
298
+ sessionId = frame.sessionId;
299
+ currentBindToken = frame.bindToken;
300
+ // A no-op after the first attach — which is exactly why `currentBindToken` exists.
301
+ resolveReady({ sessionId, bindToken: currentBindToken });
302
+ for (const waiter of attachments)
303
+ waiter.resolve({ sessionId, bindToken: currentBindToken });
304
+ attachments.clear();
305
+ return;
306
+ case 'refused': {
307
+ const error = new Error(frame.message);
308
+ rejectReady(error);
309
+ disconnect({ error });
310
+ return;
311
+ }
312
+ case 'malformed':
313
+ options.onError?.(frame.error);
314
+ return;
315
+ case 'invocation':
316
+ handleInvocation(frame.action);
317
+ return;
318
+ case 'ignored':
319
+ return;
320
+ }
321
+ });
322
+ current.addEventListener('parked', () => {
323
+ if (source === current)
324
+ disconnect({ error: new Error('the surface is parked') });
325
+ });
326
+ // Intentional visibility pauses keep their transport owner, which reopens on visibility/activity.
327
+ current.addEventListener('suspended', () => {
328
+ if (source === current)
329
+ invalidate({ error: new Error('the surface is suspended') });
330
+ });
331
+ current.addEventListener('error', (event) => {
332
+ if (source !== current)
276
333
  return;
277
- }
278
- });
279
- source.addEventListener('error', (event) => {
280
- abortPending();
281
- // The daemon deletes a session's bind token the instant that connection drops, so by the time
282
- // this fires the token in hand is already gone from the registry. Holding it until the next
283
- // `attached` frame leaves a window where `bindToken()` hands back something dead, and a run
284
- // started in that window fails to bind — reported to the agent only as "no frontend is bound
285
- // to this run", with the real reason (a reconnect it never saw) nowhere in view.
286
- // Reporting no token is the honest answer for a surface that is, right now, not attached.
287
- currentBindToken = undefined;
288
- sessionId = undefined;
289
- options.onError?.(event);
290
- });
334
+ disconnect({ error: new Error('the frontend stream failed') });
335
+ options.onError?.(event);
336
+ });
337
+ }
338
+ connect();
291
339
  const bridgeAccess = {
292
340
  subscribe(onAction) {
293
341
  chatListeners.add(onAction);
@@ -301,21 +349,46 @@ export function createFrontendSessionBridge({ baseUrl: base, openStream, request
301
349
  bridgeAccess,
302
350
  ready,
303
351
  bindToken: () => currentBindToken,
352
+ async rebind({ signal } = {}, _optional = {}) {
353
+ signal?.throwIfAborted();
354
+ if (closed)
355
+ throw new Error('the frontend bridge is closed');
356
+ if (sessionId !== undefined && currentBindToken !== undefined)
357
+ return { sessionId, bindToken: currentBindToken };
358
+ return new Promise((resolve, reject) => {
359
+ const cleanup = () => { attachments.delete(waiter); signal?.removeEventListener('abort', abort); };
360
+ const waiter = {
361
+ resolve: (value) => { cleanup(); resolve(value); },
362
+ reject: (error) => { cleanup(); reject(error); },
363
+ };
364
+ const abort = () => waiter.reject(signal?.reason);
365
+ attachments.add(waiter);
366
+ signal?.addEventListener('abort', abort, { once: true });
367
+ if (!source) {
368
+ try {
369
+ connect();
370
+ }
371
+ catch (error) {
372
+ disconnect({ error });
373
+ }
374
+ }
375
+ });
376
+ },
304
377
  close() {
305
- abortPending();
306
- source.close();
307
- chatListeners.clear();
378
+ closed = true;
308
379
  // A closed bridge serves nothing, so binding a run to it would strand that run rather than
309
380
  // fail it — this is what stops a torn-down surface (a StrictMode remount, a route change)
310
381
  // from handing its dead token to the next run.
311
- currentBindToken = undefined;
312
- sessionId = undefined;
382
+ disconnect({ error: new Error('the frontend bridge is closed') });
383
+ chatListeners.clear();
313
384
  },
314
385
  };
315
386
  }
316
387
  /** Authenticated streaming replacement for EventSource when credentials belong in headers.
317
388
  * Uses the shared SSE decoder; reconnect creates a new attachment and clears the old token.
318
389
  * O(stream bytes), bounded frame buffer; close aborts fetch/readers and the reconnect timer. */
390
+ // 0.6.x compatibility: existing hosts may omit fetch;
391
+ // remove when fetch injection becomes required in the next minor.
319
392
  export function openFetchFrontendStream({ url, headers }, { fetch: send = globalThis.fetch, reconnectMs = 1000 } = {}) {
320
393
  const events = new EventTarget();
321
394
  const abort = new AbortController();
@@ -331,14 +404,14 @@ export function openFetchFrontendStream({ url, headers }, { fetch: send = global
331
404
  for await (const frame of decodeSseFrames({ source: streamChunks(response.body) }, { maxBufferChars: 128_000 })) {
332
405
  delay = reconnectMs; // A delivered event proves the connection recovered.
333
406
  if (!abort.signal.aborted)
334
- events.dispatchEvent(new MessageEvent('message', { data: frame.data }));
407
+ events.dispatchEvent(new MessageEvent(frame.event === 'parked' ? 'parked' : 'message', { data: frame.data }));
335
408
  }
336
409
  }
337
410
  catch { /* The stream error has one value-free notification below. */ }
338
411
  if (abort.signal.aborted)
339
412
  return;
340
413
  events.dispatchEvent(new Event('error'));
341
- if (retryable) {
414
+ if (retryable && !abort.signal.aborted) {
342
415
  retry = setTimeout(() => { void connect(); }, delay);
343
416
  delay = Math.min(delay * 2, 30_000);
344
417
  }
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { formatErrorForLog } from '@jini-ai/core';
2
3
  /**
3
4
  * @module Composer
4
5
  *
@@ -21,7 +22,7 @@ import { AttachmentTray } from './AttachmentTray.js';
21
22
  import { ComposerDiscoveryMenu, ComposerSlashMenu } from './ComposerDiscovery.js';
22
23
  import { appendComposerDiscovery, composerDiscoveryMenuPosition, composerSlashMenuPosition, filterComposerDiscovery, parseComposerSlashQuery, replaceComposerSlashTrigger, resolveComposerSlashInvocation, resolveComposerSlashKeyAction, } from './composer-discovery.js';
23
24
  function reportComposerHostEffectFailure(effectName, error) {
24
- console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, error);
25
+ console.error(`[@jini-ai/chat] Composer ${effectName} host effect failed:`, formatErrorForLog({ error }));
25
26
  }
26
27
  /**
27
28
  * Invokes host code synchronously, then observes either its synchronous result or async failure.
@@ -1,4 +1,5 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { formatErrorForLog } from '@jini-ai/core';
2
3
  /**
3
4
  * @module ConversationList
4
5
  *
@@ -33,7 +34,7 @@ import { ConversationTitleEditor } from './ConversationTitleEditor.js';
33
34
  */
34
35
  const DOUBLE_CLICK_GRACE_MS = 500;
35
36
  function reportConversationListHostEffectFailure(effectName, error) {
36
- console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, error);
37
+ console.error(`[@jini-ai/chat] ConversationList ${effectName} host effect failed:`, formatErrorForLog({ error }));
37
38
  }
38
39
  /** Invokes a host callback and reports a synchronous throw or async rejection without leaving it unhandled. */
39
40
  function runConversationListHostEffect(effectName, effect) {