@jini-ai/chat 0.6.22 → 0.6.23

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
@@ -1,3 +1,7 @@
1
+ ## 0.6.23
2
+
3
+ - Security hardening.
4
+
1
5
  ## 0.6.22
2
6
 
3
7
  - The full CLI access notice shows once per conversation, also after replay or remount.
@@ -1,10 +1,16 @@
1
1
  import { EventType } from "@ag-ui/core";
2
2
  import { createAgUiToAgentTranslationState, translateAgUiEventToAgentEvent } from "../ag-ui/entry.js";
3
- /** Only the originating attachment can authorize page execution. Read its token for every turn;
4
- * caller-supplied tool definitions never authorize or execute a transcript event. */
3
+ /** Only the originating attachment can authorize page execution. Read its token and tab for every
4
+ * turn; caller-supplied tool definitions never authorize or execute a transcript event.
5
+ * @complexity O(1) time and space.
6
+ * @overallScore 100 */
5
7
  function frontendBinding({ input }, _options = {}) {
6
8
  const token = input.context?.["frontendBindToken"];
7
- return typeof token === "string" && token.length > 0 ? { frontendBindToken: token } : {};
9
+ const tabId = input.context?.["frontendTabId"];
10
+ return {
11
+ ...(typeof token === "string" && token.length > 0 ? { frontendBindToken: token } : {}),
12
+ ...(typeof tabId === "string" && tabId.length > 0 ? { frontendTabId: tabId } : {}),
13
+ };
8
14
  }
9
15
  /** Bind the official client without adding its dependency to every chat consumer. */
10
16
  export function createAgUiRunClient({ ports }, _options = {}) {
@@ -458,18 +458,25 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
458
458
  * and a spread would let any future `runContext` key silently shadow it — an identity field being
459
459
  * overwritten by a UI prop is not a failure mode worth leaving open to save one line.
460
460
  *
461
- * Returns `{}` (omitting the field entirely) when absent, which is a normal state, not an error:
461
+ * The tab id accompanies the token so the host can verify the session owner before binding.
462
+ * @complexity O(1) time and space.
463
+ * @overallScore 100
464
+ * Returns `{}` (omitting the fields entirely) when absent, which is a normal state, not an error:
462
465
  * the daemon treats a run with no bind token as one with no screen to drive
463
466
  * (`agent-daemon-server.ts`).
464
467
  */
465
- function frontendBindTokenField(input) {
468
+ function frontendBindingFields(input) {
466
469
  const frontendBindToken = input.context?.["frontendBindToken"];
467
- return typeof frontendBindToken === "string" && frontendBindToken.length > 0 ? { frontendBindToken } : {};
470
+ const frontendTabId = input.context?.["frontendTabId"];
471
+ return {
472
+ ...(typeof frontendBindToken === "string" && frontendBindToken.length > 0 ? { frontendBindToken } : {}),
473
+ ...(typeof frontendTabId === "string" && frontendTabId.length > 0 ? { frontendTabId } : {}),
474
+ };
468
475
  }
469
476
  /**
470
477
  * The Local CLI picker's live model selection, from `ChatPane`'s `runContext` prop
471
478
  * (`AssistantDock.tsx`'s `resolveRunContext`). Same "read by name, not spread" reasoning as
472
- * {@link frontendBindTokenField} above, and the same "omit when absent" convention. Forwarded as an
479
+ * {@link frontendBindingFields} above, and the same "omit when absent" convention. Forwarded as an
473
480
  * opaque string — `agent-daemon-server.ts` forwards it the same way, and `AgentExecutor.run()`'s
474
481
  * def-level `buildArgs` is what decides what an absent or `'default'` value means for a given CLI
475
482
  * (`@jini-ai/agent-runtime`'s `models.ts`/`resolveModelForAgent`).
@@ -498,7 +505,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
498
505
  * re-validated paths via `AttachmentStore.claim()`. Nothing on this side of the wire is trusted; the
499
506
  * id is inert until the daemon claims it.
500
507
  *
501
- * Returns `{}`, same convention as {@link frontendBindTokenField} above — a run with no attachments
508
+ * Returns `{}`, same convention as {@link frontendBindingFields} above — a run with no attachments
502
509
  * is the overwhelmingly common case and should not carry a key for it.
503
510
  */
504
511
  function attachmentIdsField(input) {
@@ -509,7 +516,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
509
516
  /**
510
517
  * Opaque Agent Plugin ids (`plugin.json`'s own `name`, e.g. `"ui-ux-design"`) the operator has
511
518
  * pinned as composer chips — `AssistantDock.tsx`'s `useSelectedAgentPlugins`, threaded here via
512
- * `ChatPane`'s `runContext` prop the same way {@link frontendBindTokenField}/{@link modelField}
519
+ * `ChatPane`'s `runContext` prop the same way {@link frontendBindingFields}/{@link modelField}
513
520
  * above already are. Same "read by name, not spread" and "omit when absent" conventions as those
514
521
  * two fields: this is not the plugin's CONTENT, only a reference to it —
515
522
  * `agent-daemon-server.ts`'s `onStarted` is where a ref gets resolved against the real installed
@@ -528,7 +535,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
528
535
  }
529
536
  /**
530
537
  * The active conversation id, from `ChatPane`'s `runContext` prop (`AssistantDock.tsx`'s
531
- * `chats.activeId`, the same `useRunContext` seam {@link frontendBindTokenField}/{@link modelField}/
538
+ * `chats.activeId`, the same `useRunContext` seam {@link frontendBindingFields}/{@link modelField}/
532
539
  * {@link pluginRefIdsField} above already ride). Lets `agent-daemon-server.ts`'s `onStarted` resume
533
540
  * this conversation's agent-CLI session across turns instead of spawning cold every time
534
541
  * (`server/inbound/assistant/agent-session-resume.ts`) — see that file's own doc. Returns `{}`, same
@@ -581,7 +588,7 @@ export function createAssistantTransportClient({ ports }, _options = {}) {
581
588
  // daemon JSON boundary. Replaying an old signal reports another paste on every later turn.
582
589
  ...(newestUserMessage({ history: input.history }, {})?.secretRedaction?.secretRedacted ? { secretRedacted: true } : {}),
583
590
  assistantMessageId: input.assistantMessageId,
584
- ...frontendBindTokenField(input),
591
+ ...frontendBindingFields(input),
585
592
  ...modelField(input),
586
593
  ...reasoningField(input),
587
594
  ...attachmentIdsField(input),
@@ -1,5 +1,5 @@
1
1
  import type { AgentEvent, ChatMessage } from "../index.js";
2
- import type { RunRef, RunSettlement } from "./run-contracts.js";
2
+ import type { RunProgress, RunRef, RunSettlement } from "./run-contracts.js";
3
3
  export interface AcceptedRunRequest {
4
4
  readonly agentId?: string;
5
5
  readonly contextRef: string;
@@ -122,6 +122,9 @@ export interface RecoveryPorts {
122
122
  run: DurableRun;
123
123
  request: AcceptedRunRequest;
124
124
  }, optional: {}) => Promise<void>;
125
+ /** Save a launch-response diagnostic without declaring the uncertain attempt dead. Uses the
126
+ * host's existing fenced ledger checkpoint; no new storage format or polling is required. */
127
+ readonly checkpoint?: (required: RunProgress, optional: {}) => Promise<boolean>;
125
128
  readonly cancelAttempt: (required: DurableRun, optional: {}) => Promise<void>;
126
129
  readonly verifyChildDead: (required: {
127
130
  pid: number;
@@ -109,4 +109,22 @@ export declare function openFetchFrontendStream({ url, headers }: {
109
109
  fetch?: typeof globalThis.fetch;
110
110
  reconnectMs?: number;
111
111
  }): Pick<EventSource, 'addEventListener' | 'close'>;
112
+ export interface FrontendNavigationGuard {
113
+ noteNavigation(): void;
114
+ preparePageInput(required: {
115
+ action: ChatPaneAgentToolAction;
116
+ }): Record<string, unknown>;
117
+ onBeforeResponse(required: {
118
+ action: ChatPaneAgentToolAction;
119
+ output: unknown;
120
+ }): unknown;
121
+ }
122
+ /** Fence a protected browser effect to the exact view in which its human card was proposed.
123
+ * Hosts call noteNavigation synchronously for every navigation, including a return to an old URL.
124
+ * Internal metadata is checked and removed before the page capability's public schema sees it.
125
+ * @returns Existing bridge callbacks plus the host-owned navigation notification.
126
+ * @complexity O(input keys) time/space per prepared action; O(1) retained revision counter.
127
+ * @overallScore 100
128
+ */
129
+ export declare function createFrontendNavigationGuard(_required: Record<string, never>, _optional?: Record<string, never>): FrontendNavigationGuard;
112
130
  //# sourceMappingURL=frontend-session-bridge.d.ts.map
@@ -454,4 +454,31 @@ async function assertFrontendResponse({ response, invocationId, body, scoped, re
454
454
  throw new Error('Page invocation expired or was cancelled; no action was taken');
455
455
  }
456
456
  }
457
+ /** Fence a protected browser effect to the exact view in which its human card was proposed.
458
+ * Hosts call noteNavigation synchronously for every navigation, including a return to an old URL.
459
+ * Internal metadata is checked and removed before the page capability's public schema sees it.
460
+ * @returns Existing bridge callbacks plus the host-owned navigation notification.
461
+ * @complexity O(input keys) time/space per prepared action; O(1) retained revision counter.
462
+ * @overallScore 100
463
+ */
464
+ export function createFrontendNavigationGuard(_required, _optional = {}) {
465
+ let revision = 0;
466
+ return {
467
+ noteNavigation: () => { revision += 1; },
468
+ preparePageInput: ({ action }) => {
469
+ const { __frontendNavigationRevision: expected, ...input } = action.input;
470
+ if (Object.hasOwn(action.input, '__frontendNavigationRevision') && (!Number.isSafeInteger(expected) || expected !== revision)) {
471
+ throw new Error('The page changed while confirmation was pending; propose this action again.');
472
+ }
473
+ return input;
474
+ },
475
+ onBeforeResponse: ({ action, output }) => {
476
+ if (action.capabilityId !== 'page.find_elements')
477
+ return output;
478
+ if (output === null || typeof output !== 'object' || Array.isArray(output))
479
+ return output;
480
+ return { ...output, navigationRevision: revision };
481
+ },
482
+ };
483
+ }
457
484
  //# sourceMappingURL=frontend-session-bridge.js.map
@@ -187,16 +187,23 @@ function UserMessageRow({ message, renderAttachment }) {
187
187
  const gallery = useAttachmentGallery({ attachments: message.attachments ?? NO_ATTACHMENTS });
188
188
  return (_jsxs("div", { className: "jini-message jini-message-user", "data-message-id": message.id, "data-agent-element": `chat-message-${message.id}`, "data-agent-role": "region", "data-agent-label": "A message from the user", children: [(message.attachments?.length || message.folders?.length) ? (_jsxs("div", { className: "jini-message-attachments", children: [_jsx(FolderChips, { folders: message.folders }), (message.attachments ?? []).map((a) => (_jsx(SentAttachmentButton, { attachment: a, onOpen: gallery.open, renderAttachment: renderAttachment }, a.path)))] })) : null, _jsx("div", { className: "jini-message-content", children: message.content }), _jsx("div", { className: "jini-message-actions jini-message-actions--user", children: _jsx(CopyMessageButton, { text: message.content, label: t('Copy message') }) }), gallery.opened ? (_jsx(AttachmentPreviewModal, { attachment: gallery.opened, onClose: gallery.close, navigation: gallery.navigation })) : null] }));
189
189
  }
190
+ // A validated extension may already show the explanation. Keep the generic fallback when its
191
+ // renderer is absent or refuses the payload, so malformed history never hides a failed turn.
192
+ function assistantTerminalNotice({ message, runStreaming, runSucceeded, extGroups, registry }, _options = {}) {
193
+ const extensionExplainsFailure = !runStreaming && !runSucceeded && extGroups.some(group => registry.hasTerminalNotice(group));
194
+ return extensionExplainsFailure ? null : terminalMessageNotice({ message }, {});
195
+ }
196
+ /** Only a completed reply without footer usage can own the final inline usage label. */
197
+ function lastFinishedReplyIndex({ runInProgress, usage }, _options = {}) {
198
+ return !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
199
+ }
190
200
  function AssistantMessageRow({ showAccessNotice = true, message, usageSummary, showUsageTotal, showUsageLine, toolCallDisplay, showThinking, runStreaming, runSending, sendingLabel, runSucceeded, questionFormInteractive, questionFormSubmittedAnswers, onQuestionFormSubmit, projectFileNames, onRequestOpenFile, }) {
191
201
  const registry = useExtEventRendererRegistry();
192
202
  const messageUsage = useMessageUsage({ message, ...(usageSummary === undefined ? {} : { summary: usageSummary }) });
193
203
  const timeline = useToolTimeline({ events: message.events }, { runStreaming, runSucceeded });
194
204
  const extGroups = useExtEventGroups({ events: message.events }, { slotOf: (name, data) => registry.slot({ name, data }) });
195
205
  const answeredCards = useAnsweredCards({ slotOf: registry.slot, events: message.events ?? [], groups: extGroups, runStreaming, runStatus: message.runStatus });
196
- // A validated extension may already show the explanation. Keep the generic fallback when its
197
- // renderer is absent or refuses the payload, so malformed history never hides a failed turn.
198
- const extensionExplainsFailure = !runStreaming && !runSucceeded && extGroups.some(group => registry.hasTerminalNotice(group));
199
- const terminalNotice = extensionExplainsFailure ? null : terminalMessageNotice({ message }, {});
206
+ const terminalNotice = assistantTerminalNotice({ message, runStreaming, runSucceeded, extGroups, registry });
200
207
  const visibleContent = stripArtifact({ content: message.content });
201
208
  const displayBlocks = messageBlocks({ message, rows: timeline.rows, registry }, { toolCallDisplay, includeThinking: showThinking });
202
209
  // Artifact stripping trims/replaces text, invalidating the event offsets for rendering. Keep the
@@ -209,7 +216,7 @@ function AssistantMessageRow({ showAccessNotice = true, message, usageSummary, s
209
216
  runStreaming: runStreaming && !isTerminalRunStatus({ status: message.runStatus }),
210
217
  });
211
218
  const footerUsage = showUsageLine ? usage.footer : undefined;
212
- const lastFinishedReply = !runInProgress && !usage.footer ? usage.replies.findLastIndex(value => value !== undefined) : -1;
219
+ const lastFinishedReply = lastFinishedReplyIndex({ runInProgress, usage });
213
220
  const retriedRows = retriedToolRowIds({ rows: timeline.rows });
214
221
  const renderToolCard = (row) => (_jsx(ToolCard, { retried: retriedRows.has(row.id), use: row.use, result: row.result, runStreaming: runStreaming, runSucceeded: runSucceeded, ...(projectFileNames !== undefined ? { projectFileNames } : {}), ...(onRequestOpenFile !== undefined ? { onRequestOpenFile } : {}) }, row.id));
215
222
  // Hidden mode reuses the same disclosure and card renderer for unrecovered failures.
@@ -34,7 +34,7 @@ function NativeToolPolicyWarning({ agent, policy, isApi }) {
34
34
  if (!agent || isApi)
35
35
  return null;
36
36
  const warning = policy ? assessNativeToolPolicy({ def: agent, policy }) : null;
37
- return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, t: t }) : null, warning ? _jsx("p", { role: "alert", children: t(warning) }) : null] });
37
+ return _jsxs(_Fragment, { children: [agent.fullCliAccessNote ? _jsx(FullAccessNoteTip, { note: agent.fullCliAccessNote, t: t }) : null, warning ? _jsx(InfoTip, { label: t(warning), triggerLabel: t('Agent permissions required'), placement: "top", icon: _jsx(RemixIcon, { name: "alert-line", size: 16 }) }) : null] });
38
38
  }
39
39
  function RuntimeModeButtons({ executionMode, daemonOnline, apiModeAvailable, onExecutionModeChange, t, }) {
40
40
  const localModeLabel = executionMode === 'local' ? t('active') : '';
@@ -363,6 +363,7 @@ export function ChatPane(props) {
363
363
  uploadAttachments: p.uploadAttachments,
364
364
  onActivityChange: p.onActivityChange,
365
365
  onMessagesChange: p.onMessagesChange,
366
+ subscribeToSurfaces: p.subscribeToSurfaces,
366
367
  redactUserText: p.redactUserText,
367
368
  onSecretRedacted: p.onSecretRedacted,
368
369
  workingDirectory: p.workingDirectory,
@@ -0,0 +1,23 @@
1
+ import type { SurfaceEmission } from '@jini-ai/core';
2
+ export interface FrontendRequestCallerRequired {
3
+ readonly path: string;
4
+ readonly fetch: typeof globalThis.fetch;
5
+ readonly bindToken: () => string | undefined;
6
+ readonly onSurface: (emission: SurfaceEmission) => void;
7
+ }
8
+ export interface FrontendRequestInput {
9
+ readonly capabilityId: string;
10
+ readonly input: Record<string, unknown>;
11
+ readonly signal: AbortSignal;
12
+ }
13
+ /** Execute one browser-requested capability through the host's authenticated gate, never retrying.
14
+ * The current bind token and serialized arguments are captured before the first asynchronous wait.
15
+ * @returns A caller that renders cards immediately and resolves only after the tool settles.
16
+ * @throws On absent attachment, HTTP refusal, cancellation or an incomplete execution stream.
17
+ * @complexity O(request + response bytes) time; O(largest response frame) space, capped at 5 MiB.
18
+ * @overallScore 100
19
+ */
20
+ export declare function createFrontendRequestCaller({ path, fetch, bindToken, onSurface }: FrontendRequestCallerRequired, { headers }?: {
21
+ headers?: () => Record<string, string>;
22
+ }): (required: FrontendRequestInput, optional?: Record<string, never>) => Promise<unknown>;
23
+ //# sourceMappingURL=create-frontend-request-caller.d.ts.map
@@ -0,0 +1,56 @@
1
+ import { readSurfaceFrames } from './surface-recovery-stream.js';
2
+ /** Execute one browser-requested capability through the host's authenticated gate, never retrying.
3
+ * The current bind token and serialized arguments are captured before the first asynchronous wait.
4
+ * @returns A caller that renders cards immediately and resolves only after the tool settles.
5
+ * @throws On absent attachment, HTTP refusal, cancellation or an incomplete execution stream.
6
+ * @complexity O(request + response bytes) time; O(largest response frame) space, capped at 5 MiB.
7
+ * @overallScore 100
8
+ */
9
+ export function createFrontendRequestCaller({ path, fetch, bindToken, onSurface }, { headers = () => ({}) } = {}) {
10
+ return async ({ capabilityId, input, signal }, _optional = {}) => {
11
+ const token = bindToken();
12
+ if (!token)
13
+ throw new Error('Page control has not attached to this browser tab. Reopen the assistant to retry.');
14
+ const response = await fetch(path, { method: 'POST', credentials: 'same-origin',
15
+ headers: { ...headers(), 'content-type': 'application/json' },
16
+ body: JSON.stringify({ capabilityId, input, bindToken: token }), signal,
17
+ });
18
+ if (!response.ok) {
19
+ const body = await response.json();
20
+ throw new Error(body.error ?? `The page request was refused (HTTP ${response.status}).`);
21
+ }
22
+ let completed = false;
23
+ let output;
24
+ await readSurfaceFrames({ response, onFrame: frame => {
25
+ switch (frame.type) {
26
+ case 'surface':
27
+ if (frame.emission)
28
+ onSurface(frame.emission);
29
+ return;
30
+ case 'throw':
31
+ throw new Error(frame.error?.message ?? 'The page action could not be executed.');
32
+ case 'result':
33
+ if (completed)
34
+ throw new Error('The page action returned more than one result.');
35
+ output = completedOutput(frame.result);
36
+ completed = true;
37
+ return;
38
+ default: return;
39
+ }
40
+ } });
41
+ if (!completed)
42
+ throw new Error('The page action ended without a result. Its outcome is unknown; it was not retried.');
43
+ return output;
44
+ };
45
+ }
46
+ /** Only a completed gated execution has an output; refusals keep the host's specific message.
47
+ * @complexity O(1) time and space.
48
+ * @overallScore 100
49
+ */
50
+ function completedOutput(value) {
51
+ const result = value;
52
+ if (result?.status !== 'completed')
53
+ throw new Error(result?.error ?? `The page action was ${result?.status ?? 'not completed'}.`);
54
+ return result.output;
55
+ }
56
+ //# sourceMappingURL=create-frontend-request-caller.js.map
@@ -1,4 +1,5 @@
1
1
  import { type AcceptedChatInput } from '../../../../core/accepted-input.js';
2
+ import type { SurfaceEmission } from '@jini-ai/core';
2
3
  import type { UserTextRedactionOptions } from '../../../../core/user-text-redaction.js';
3
4
  import { type ChatPaneSendBlocker } from '../rules.js';
4
5
  import type { ChatPaneActivity, ChatPaneAgent, ChatPaneAttachmentUploadOptions, ChatPaneAgentSelection, ChatPaneRunContext, ChatPaneWorkingDirectoryAccess, DeliverMidRunMessage, DeliverTypedAnswer, TypedAnswerNotice } from '../types.js';
@@ -28,6 +29,7 @@ export interface UseChatPaneOptions extends UserTextRedactionOptions {
28
29
  uploadAttachments?: (files: File[], options?: ChatPaneAttachmentUploadOptions) => Promise<ChatAttachment[]>;
29
30
  onActivityChange?: (activity: ChatPaneActivity) => void;
30
31
  onMessagesChange?: (messages: ChatMessage[]) => void;
32
+ subscribeToSurfaces?: (listener: (emission: SurfaceEmission) => void) => () => void;
31
33
  workingDirectory?: string | null;
32
34
  initialWorkingDirectory?: string | null;
33
35
  onChangeWorkingDirectory?: (workingDirectory: string | null) => void;
@@ -246,6 +246,14 @@ export function useChatPane(options) {
246
246
  // absent), so only the falsy "no agent selected" case should omit the key.
247
247
  agentId: selection.agentId || undefined,
248
248
  } }));
249
+ useEffect(() => options.subscribeToSurfaces?.(emission => {
250
+ // Browser-requested cards are not agent turns and cannot supply run/answer authority.
251
+ const data = emission.channel === 'mcp-ui' ? emission.payload.resource : emission.payload;
252
+ conversation.setMessages(messages => [...messages, {
253
+ id: createChatLocalId({}), role: 'assistant', content: '', createdAt: Date.now(),
254
+ events: [{ kind: 'ext', name: emission.channel, data }],
255
+ }]);
256
+ }), [options.subscribeToSurfaces, conversation.setMessages]);
249
257
  const composer = useComposer(definedProps({ source: {
250
258
  initialDraft: options.initialDraft,
251
259
  historyMessages: conversation.messages,
@@ -1,7 +1,15 @@
1
1
  import type { SurfaceEmission } from '@jini-ai/core';
2
- /** Read bounded NDJSON frames as they arrive: waiting for response.text() would hide the very
3
- * card the held request is waiting on. The final frame is value-free, never the tool result.
4
- * @complexity O(response bytes), O(largest frame) space, capped at 5 MiB per frame.
2
+ /** Read bounded NDJSON frames as they arrive; waiting for response.text() would hide a held card.
3
+ * @complexity O(response bytes) time; O(largest frame) space, capped at 5 MiB per frame.
4
+ * @overallScore 100
5
+ */
6
+ export declare function readSurfaceFrames({ response, onFrame }: {
7
+ response: Response;
8
+ onFrame: (frame: Record<string, unknown>) => void;
9
+ }, _options?: {}): Promise<void>;
10
+ /** Restore cards through the same bounded stream reader used by runless frontend requests.
11
+ * @complexity O(response bytes) time; O(largest frame) space.
12
+ * @overallScore 100
5
13
  */
6
14
  export declare function readSurfaceRecoveryStream({ response, onSurface }: {
7
15
  response: Response;
@@ -1,22 +1,22 @@
1
- /** Read bounded NDJSON frames as they arrive: waiting for response.text() would hide the very
2
- * card the held request is waiting on. The final frame is value-free, never the tool result.
3
- * @complexity O(response bytes), O(largest frame) space, capped at 5 MiB per frame.
1
+ /** Read bounded NDJSON frames as they arrive; waiting for response.text() would hide a held card.
2
+ * @complexity O(response bytes) time; O(largest frame) space, capped at 5 MiB per frame.
3
+ * @overallScore 100
4
4
  */
5
- export async function readSurfaceRecoveryStream({ response, onSurface }, _options = {}) {
5
+ export async function readSurfaceFrames({ response, onFrame }, _options = {}) {
6
6
  const reader = response.body?.getReader();
7
7
  if (!reader)
8
- throw new Error('The restored card stream is unavailable.');
8
+ throw new Error('The interactive page response stream is unavailable.');
9
9
  const decoder = new TextDecoder();
10
10
  let buffer = '';
11
- let restored = false;
12
11
  const accept = (text) => {
13
12
  if (!text.trim())
14
13
  return;
14
+ if (text.length > 5 * 1024 * 1024)
15
+ throw new Error('The interactive card is too large.');
15
16
  const frame = JSON.parse(text);
16
- if (frame.type === 'surface' && frame.emission)
17
- onSurface?.(frame.emission);
18
- if (frame.type === 'restored')
19
- restored = frame.ok === true;
17
+ if (!frame || typeof frame !== 'object' || Array.isArray(frame))
18
+ throw new Error('The interactive response contains an invalid frame.');
19
+ onFrame(frame);
20
20
  };
21
21
  try {
22
22
  for (;;) {
@@ -24,24 +24,34 @@ export async function readSurfaceRecoveryStream({ response, onSurface }, _option
24
24
  buffer += decoder.decode(chunk.value, { stream: !chunk.done });
25
25
  const lines = buffer.split('\n');
26
26
  buffer = lines.pop();
27
- for (const line of lines) {
28
- if (line.length > 5 * 1024 * 1024)
29
- throw new Error('The restored card is too large.');
27
+ for (const line of lines)
30
28
  accept(line);
31
- }
32
29
  if (buffer.length > 5 * 1024 * 1024)
33
- throw new Error('The restored card is too large.');
30
+ throw new Error('The interactive card is too large.');
34
31
  if (chunk.done)
35
32
  break;
36
33
  }
37
34
  accept(buffer);
38
- if (!restored)
39
- throw new Error('The card could not be restored.');
40
- return { delivered: true };
41
35
  }
42
36
  finally {
43
37
  await reader.cancel();
44
38
  reader.releaseLock();
45
39
  }
46
40
  }
41
+ /** Restore cards through the same bounded stream reader used by runless frontend requests.
42
+ * @complexity O(response bytes) time; O(largest frame) space.
43
+ * @overallScore 100
44
+ */
45
+ export async function readSurfaceRecoveryStream({ response, onSurface }, _options = {}) {
46
+ let restored = false;
47
+ await readSurfaceFrames({ response, onFrame: frame => {
48
+ if (frame.type === 'surface' && frame.emission)
49
+ onSurface?.(frame.emission);
50
+ if (frame.type === 'restored')
51
+ restored = frame.ok === true;
52
+ } });
53
+ if (!restored)
54
+ throw new Error('The card could not be restored.');
55
+ return { delivered: true };
56
+ }
47
57
  //# sourceMappingURL=surface-recovery-stream.js.map
@@ -1,3 +1,4 @@
1
+ import type { SurfaceEmission } from '@jini-ai/core';
1
2
  import type { UserTextRedactionOptions } from '../../../core/user-text-redaction.js';
2
3
  import type { NativeToolPolicyInput } from '@jini-ai/agent-runtime/native-tool-policy';
3
4
  import type { CSSProperties, ReactNode, RefObject } from 'react';
@@ -226,6 +227,8 @@ export interface ChatPaneProps extends UserTextRedactionOptions {
226
227
  onActivityChange?: (activity: ChatPaneActivity) => void;
227
228
  /** Fires with the full message list whenever it changes — the read side of an otherwise write-only pane, for a host that needs to inspect or test what the conversation actually contains. */
228
229
  onMessagesChange?: (messages: ChatMessage[]) => void;
230
+ /** Host-only request surfaces enter the current transcript through its existing sandbox renderer. */
231
+ subscribeToSurfaces?: (listener: (emission: SurfaceEmission) => void) => () => void;
229
232
  onRescanAgents?: () => void;
230
233
  scanningAgents?: boolean;
231
234
  daemonOnline?: boolean;
@@ -154,8 +154,8 @@ export type { JiniChatProviderProps, JiniChatSlots } from './components/JiniChat
154
154
  * `createDomPageDriver`/`currentAgentPage` moved to `@jini-ai/agentic/dom` in the 2026-07-26
155
155
  * extraction — re-exported here so existing `@jini-ai/chat-react` importers keep working.
156
156
  */
157
- export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
158
- export type { FrontendSessionBridge, FrontendSessionBridgeArgs, FrontendSessionBridgeOptions, } from './agent-bridge/frontend-session-bridge.js';
157
+ export { createFrontendSessionBridge, createFrontendNavigationGuard } from './agent-bridge/frontend-session-bridge.js';
158
+ export type { FrontendSessionBridge, FrontendNavigationGuard, FrontendSessionBridgeArgs, FrontendSessionBridgeOptions, } from './agent-bridge/frontend-session-bridge.js';
159
159
  export { createDomPageDriver, currentAgentPage, type DomPageDriverOptions } from '@jini-ai/agentic/dom';
160
160
  export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
161
161
  export type { ComposerHistoryStoragePort } from '../core/composer-history.js';
@@ -175,4 +175,6 @@ export type { AgentPluginUsage, AgentPluginUsage as Usage } from '../core/agent-
175
175
  export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
176
176
  export type { AcceptedChatInput } from '../core/accepted-input.js';
177
177
  export { isAwaitingAnswer } from '../core/run-activity.js';
178
+ export { createFrontendRequestCaller } from './features/chat-pane/create-frontend-request-caller.js';
179
+ export type { FrontendRequestCallerRequired, FrontendRequestInput } from './features/chat-pane/create-frontend-request-caller.js';
178
180
  //# sourceMappingURL=index.d.ts.map
@@ -133,7 +133,7 @@ export { JiniChatProvider, useJiniChatSlots, useOnFeedback } from './components/
133
133
  * `createDomPageDriver`/`currentAgentPage` moved to `@jini-ai/agentic/dom` in the 2026-07-26
134
134
  * extraction — re-exported here so existing `@jini-ai/chat-react` importers keep working.
135
135
  */
136
- export { createFrontendSessionBridge } from './agent-bridge/frontend-session-bridge.js';
136
+ export { createFrontendSessionBridge, createFrontendNavigationGuard } from './agent-bridge/frontend-session-bridge.js';
137
137
  export { createDomPageDriver, currentAgentPage } from '@jini-ai/agentic/dom';
138
138
  export { createBrowserComposerHistoryStorage } from './hooks/composer-history-storage.js';
139
139
  // ChatPane consumers share the same redaction and draft/preview state owners.
@@ -147,4 +147,5 @@ export { isTerminalRunStatus } from '../core/messages.js';
147
147
  export { AGENT_PLUGIN_STATE_CHANGED, readAgentPluginChatState, writeAgentPluginChatState } from '../core/agent-plugin-chat-state.js';
148
148
  export { acceptedInputEvent, acceptedInputUserEvent, projectAcceptedInputEvents } from '../core/accepted-input.js';
149
149
  export { isAwaitingAnswer } from '../core/run-activity.js';
150
+ export { createFrontendRequestCaller } from './features/chat-pane/create-frontend-request-caller.js';
150
151
  //# sourceMappingURL=index.js.map
@@ -1,5 +1,5 @@
1
1
  import { recoveredRunEvents } from "../../core/index.js";
2
- import { classifyAgentServiceFailure } from "@jini-ai/agent-runtime";
2
+ import { classifyAgentServiceFailure, startupFailureMessage } from "@jini-ai/agent-runtime";
3
3
  import { continuationRequest } from "../../core/durable-runs/continuation.js";
4
4
  import { isAwaitingAnswer } from "../../core/run-activity.js";
5
5
  import { RECOVERY_LIMIT, RUN_STALENESS_MS, SAVED_WORK_NOTICE, AWAITING_ANSWER_NOTICE, EXHAUSTED_NOTICE } from "../../core/durable-runs/policies.js";
@@ -73,6 +73,11 @@ function attemptFailure({ diagnostics }, _optional = {}) {
73
73
  }
74
74
  /** Keep the notice bounded and scoped to the same executor diagnostics as failure classification. */
75
75
  function lastDiagnosticLine({ diagnostics }, _optional = {}) {
76
+ // Startup evidence is already bounded, but redact again for older saved events. Keep the
77
+ // complete stage/cause block; a last-line/300-character tail erased its useful context.
78
+ const startup = diagnostics.lastIndexOf('Startup stage:');
79
+ if (startup >= 0)
80
+ return startupFailureMessage({ stage: 'saved startup failure', error: diagnostics.slice(startup) }, {});
76
81
  return diagnostics.split(/\r?\n/).map((line) => line.trim()).filter(Boolean).at(-1)?.slice(0, 300);
77
82
  }
78
83
  function attemptFailureDetail({ run }, _optional = {}) {
@@ -130,6 +135,23 @@ function continuesNow({ run, trigger, now }, _optional = {}) {
130
135
  /** Recovery coordinator behind explicit host ports. One CAS owns both the attempt and its conversation slot.
131
136
  * Each recovery decision logs one `logger.info` line (default console) of run metadata. */
132
137
  export function createDurableRecovery(ports, { logger = console } = {}) {
138
+ /** Replay starts from attemptBase. Carry this attempt's saved launch notice into it too, so
139
+ * reconnect/reload cannot replace that evidence with the daemon's shorter replay prefix.
140
+ * O(e) in saved events; no additional store read, write, or polling. */
141
+ function attach(run) {
142
+ const events = run.message.events ?? [];
143
+ const noticeIndex = lastIndexWhere({ events, match: event => event.kind === 'status' && event.code === 'run_launch_unconfirmed' });
144
+ const marker = lastIndexWhere({ events, match: event => event.kind === 'status' && event.code === 'run_recovering' });
145
+ if (noticeIndex <= marker) {
146
+ ports.attach(run, {});
147
+ return;
148
+ }
149
+ const notice = events[noticeIndex];
150
+ const base = [...run.attemptBase];
151
+ if (!base.includes(notice))
152
+ base.push(notice);
153
+ ports.attach({ ...run, attemptBase: base }, {});
154
+ }
133
155
  async function finalize(run, notice, { canceled = false, detail } = {}) {
134
156
  await ports.cancelAttempt(run, {}).catch(() => undefined);
135
157
  // Persist the notice as an event: hook errors are live-only and would leave a reloaded
@@ -145,15 +167,30 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
145
167
  return false;
146
168
  return ports.verifyChildDead(run.child, {}).catch(() => false);
147
169
  }
170
+ /** Persist rejection evidence but leave liveness and retry decisions to the existing probe. */
148
171
  async function launchNext({ run, next, native }, _optional = {}) {
172
+ let stage = 'recovery launch';
149
173
  try {
150
174
  await ports.launch({ run: next, request: continuationRequest({ run: { ...run, message: next.message, transcript: next.transcript }, native }, {}) }, {});
175
+ stage = 'recovery clock';
151
176
  await ports.store.recoveryClock({ runId: next.runId, now: ports.now(), active: false }, {});
152
177
  }
153
- catch {
178
+ catch (error) {
154
179
  // A rejected/lost start response is uncertain: the daemon may already be executing. The
155
180
  // same idempotent attempt is probed by the attached watcher before another CAS is possible.
181
+ const diagnostic = { kind: 'status', code: 'run_launch_unconfirmed',
182
+ label: 'The assistant start response was not confirmed. Checking whether this attempt is running.',
183
+ detail: startupFailureMessage({ stage, error }, {
184
+ notice: 'The assistant start response was not confirmed. Wait while this attempt is checked.',
185
+ }) };
186
+ const events = [...(next.message.events ?? []), diagnostic];
187
+ // Preserve the saved answer and all prior events in the existing projection write. The
188
+ // checkpoint's generation fence rejects a concurrent stop or successor attempt.
189
+ await ports.checkpoint?.({ conversationId: next.conversationId, messageId: next.messageId,
190
+ runId: next.runId, content: next.message.content, events }, {}).catch(() => false);
191
+ return { ...next, message: { ...next.message, events } };
156
192
  }
193
+ return next;
157
194
  }
158
195
  async function continueDead(run) {
159
196
  await ports.cancelAttempt(run, {}).catch(() => undefined);
@@ -172,8 +209,8 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
172
209
  return "superseded";
173
210
  if (next.cancelReason)
174
211
  return finalize(next, STOPPED_NOTICE, { canceled: true });
175
- await launchNext({ run, next, native });
176
- ports.attach(next, {});
212
+ const launched = await launchNext({ run, next, native });
213
+ attach(launched);
177
214
  return "continued";
178
215
  }
179
216
  async function probeAttempt({ run, trigger, liveRunId }, _optional = {}) {
@@ -185,7 +222,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
185
222
  }
186
223
  async function reattach(run) {
187
224
  await ports.store.recoveryClock({ runId: run.runId, now: ports.now(), active: false }, {});
188
- ports.attach(run, {});
225
+ attach(run);
189
226
  return "reattached";
190
227
  }
191
228
  async function finalizeFailure({ run, failure, diagnostics }, _optional = {}) {
@@ -259,7 +296,7 @@ export function createDurableRecovery(ports, { logger = console } = {}) {
259
296
  // Give that idempotent launch time to appear before a 404 can advance its generation.
260
297
  if (continuesNow({ run, trigger, now: ports.now() }))
261
298
  return continueDead(run);
262
- ports.attach(run, {});
299
+ attach(run);
263
300
  return "waiting";
264
301
  }
265
302
  return {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jini-ai/chat",
3
- "version": "0.6.22",
3
+ "version": "0.6.23",
4
4
  "description": "Jini's chat/artifact frontend, consolidated into one package with two subpaths: framework-free chat vocabulary and pure parsers in `/core`, headless hooks + presentational components + slots in `/react`. Import only the layer you need.",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -214,8 +214,8 @@
214
214
  },
215
215
  "dependencies": {
216
216
  "kysely": "^0.29.6",
217
- "@jini-ai/core": "^0.6.22",
218
- "@jini-ai/agentic": "^0.6.19",
217
+ "@jini-ai/core": "^0.6.23",
218
+ "@jini-ai/agentic": "^0.6.23",
219
219
  "@jini-ai/diagnostics": "^0.6.22",
220
220
  "@jini-ai/db": "^0.6.19"
221
221
  },
@@ -261,12 +261,12 @@
261
261
  "pg": "8.23.0",
262
262
  "@ag-ui/core": "0.0.58",
263
263
  "fast-check": "4.3.0",
264
- "@jini-ai/agent-runtime": "0.6.22",
265
- "@jini-ai/agentic": "^0.6.19",
264
+ "@jini-ai/agentic": "^0.6.23",
265
+ "@jini-ai/agent-runtime": "0.6.23",
266
+ "@jini-ai/ui": "^0.6.23",
267
+ "@jini-ai/core": "^0.6.23",
266
268
  "@jini-ai/protocol": "^0.6.19",
267
- "@jini-ai/core": "^0.6.22",
268
269
  "@jini-ai/diagnostics": "^0.6.22",
269
- "@jini-ai/ui": "^0.6.22",
270
270
  "@jini-ai/db": "0.6.19"
271
271
  },
272
272
  "typesVersions": {