@theoremjs/web 0.3.4-next.747.g0909061 → 0.3.4-next.755.g88665bd

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/README.md CHANGED
@@ -270,6 +270,7 @@ const transport = withPageToolDispatch(
270
270
  | `pageStatesTurn` | Let the page say which prompt lines a turn gets, through `stated` on a turn or a live call. Set it only for a page you trust as yourself, such as a builder's own tool. Without it, a request with `stated` is refused. |
271
271
  | `conversationStore` | Where the conversation is kept. Required, except for a stateless profile, which must not have one. See [Keep the conversation](#keep-the-conversation). |
272
272
  | `sessionStore`, `steerInbox` | Where open gates are kept, and where steers and stops reach a running turn. Default: process memory, which reaches only turns this process runs. On more than one instance, give a shared `SteerInbox`. |
273
+ | `notifier`, `notifyRetryMs` | Tell an open page that a background call ended, and how long the page waits to reopen a cut stream. Unset, the page reads the end on its next turn or reload. See [the background section](#share-session-authority). |
273
274
  | `credentialStore` | Where tool credentials are kept, by session. Default: process memory. |
274
275
  | `authorizationUrl` | Returns the sign-in URL for an OAuth gate. Without it, an OAuth gate has no URL. |
275
276
  | `gateTtlMs` | How long a gate waits for an answer. A later answer fails with `session.gate_expired`. Unset, a gate waits until it is answered. |
@@ -456,7 +457,13 @@ To retain execution claims across connections, supply a durable coordinator as `
456
457
  Browser dispatch uses a host-owned `PageToolExecution` service beside the execution coordinator.
457
458
  `createTheoremHandler` supplies both services. Live relays put the session boundary's `pageExecution` and `toolExecution` on `SessionRequest`.
458
459
 
459
- A tool with a `background` setting runs inline until you pass the `background` option to `createTheoremHandler`. The option takes `{ request, sessionId }` and returns a `ToolBackgroundService`, which receives each detached call and its late events. The session keeps the call as `background` until it settles, and a late settlement does not invalidate a checkpoint. A conversation read shows the call as it stands: running, then its result, or an error for a marked call. An open page reads it on its next turn or reload. When the session has dropped a settled operation, the block reads an error with the code `outcome_not_retained`. On a durable store, a worker that dies leaves the operation as `background`, so your sweep marks it `uncertain` with `worker_lost` through `compareAndSwap`. Keep the service's values valid after the request ends. A live relay passes its service to `runSession`.
460
+ A tool with a `background` setting runs inline until you pass the `background` option to `createTheoremHandler`. The option takes `{ request, sessionId }` and returns a `ToolBackgroundService`, which receives each detached call and its late events. The session keeps the call as `background` until it settles, and a late settlement does not invalidate a checkpoint. A conversation read shows the call as it stands: running, then its result, or an error for a marked call. An open page reads it on its next turn or reload, unless you set `notifier`. When the session has dropped a settled operation, the block reads an error with the code `outcome_not_retained`. On a durable store, a worker that dies leaves the operation as `background`, so your sweep marks it `uncertain` with `worker_lost` through `compareAndSwap`. Keep the service's values valid after the request ends. A live relay passes its service to `runSession`.
461
+ To show an open page that a background call ended, with no turn or reload, pass `notifier: createMemoryNotifier()` to `createTheoremHandler`. While a call runs, the conversation read tells the page to open `POST <base>/notify`. The handler sends one `changed` line when the stream opens, and one when each background call in the session ends. The line carries no content, so the page calls the same conversation read as before. The page closes the stream when no call runs. The model reads nothing new: a late result still reaches it through `input.lateResults`.
462
+
463
+ `createMemoryNotifier` reaches only pages connected to the process that ran the call. On more than one instance, give a shared `TheoremNotifier`. Your sweep calls `publish` with the session id after it marks a call `worker_lost`, because that write does not pass through the handler. Hosting platforms cut long responses. Set `notifyRetryMs` so that the page reopens the stream after that wait. Unset, the page does not reopen it, and reads the end on its next turn or reload.
464
+
465
+ To reach a closed tab, send a Web Push from your `background` service, when the handle's `done` resolves. Theorem does not hold push subscriptions or VAPID keys. If your service worker then reaches an open page, call `refresh()` on the `ChatController`. It runs the same read.
466
+
460
467
  Forward live page results to `answerPageResult`. Preserve checkpoint ownership through `beforeEvent` when using the Studio bridge.
461
468
  The default `PageToolBroker` keeps active result waiters in process memory. Several workers need a shared broker or request affinity.
462
469
  Use `pageToolBroker` to supply that broker. It carries results; the atomic session store retains execution authority.
package/client/chat.d.ts CHANGED
@@ -120,6 +120,12 @@ export type ChatController = {
120
120
  * `conversationView` is read then, and a restored page gate answered then.
121
121
  */
122
122
  subscribe(listener: () => void): () => void;
123
+ /**
124
+ * Reads the conversation from the host and draws it. For a page told by its own channel that
125
+ * something ended, such as a service worker that receives a Web Push. A run in flight reads it
126
+ * anyway when it ends, and a failed read leaves the chat as it stands.
127
+ */
128
+ refresh(): Promise<void>;
123
129
  /** The latest options. `conversation`, `conversationView` and `initialText` are read only at creation. */
124
130
  update(options: ChatOptions): void;
125
131
  setDraftText(value: ChatUpdate<string>): void;
package/client/chat.js CHANGED
@@ -112,6 +112,11 @@ class Chat {
112
112
  #pageToolsWarned;
113
113
  #frame = null;
114
114
  #frameBlocks = null;
115
+ /** What the last view said about the host's push; unset, nothing is listened for. */
116
+ #notify;
117
+ #listening = null;
118
+ #reading = null;
119
+ #readAgain = false;
115
120
  constructor(options) {
116
121
  this.#options = options;
117
122
  this.#conversation = options.conversation;
@@ -144,8 +149,95 @@ class Chat {
144
149
  this.#live = true;
145
150
  this.#settle();
146
151
  }
147
- return () => this.#listeners.delete(listener);
152
+ this.#followPush();
153
+ return () => {
154
+ this.#listeners.delete(listener);
155
+ this.#followPush();
156
+ };
157
+ };
158
+ refresh = () => {
159
+ if (this.#reading) {
160
+ this.#readAgain = true;
161
+ return this.#reading;
162
+ }
163
+ this.#reading = this.#readHost().finally(() => {
164
+ this.#reading = null;
165
+ });
166
+ return this.#reading;
148
167
  };
168
+ /** invariant: Reads never overlap, so an older view never draws over a newer one. */
169
+ async #readHost() {
170
+ do {
171
+ this.#readAgain = false;
172
+ await this.#readOnce();
173
+ } while (this.#readAgain);
174
+ }
175
+ async #readOnce() {
176
+ const { iface, transport } = this.#options;
177
+ const ref = this.#conversation;
178
+ if (!iface || ref === undefined || !keepsConversation(iface) || this.#busy)
179
+ return;
180
+ const before = this.#state.blocks;
181
+ let view;
182
+ try {
183
+ view = await transport.conversation({ conversation: ref });
184
+ }
185
+ catch (err) {
186
+ // why: Nobody asked for this read, and the next one heals a passing failure.
187
+ if (isUnknownConversation(err))
188
+ this.#setPush(undefined);
189
+ return;
190
+ }
191
+ // why: A run reads the conversation once it ends, later than this.
192
+ if (this.#busy)
193
+ return;
194
+ // why: Another read drew first. It may have started before the call ended, so read again.
195
+ if (this.#state.blocks !== before || this.#conversation !== ref) {
196
+ this.#readAgain = true;
197
+ return;
198
+ }
199
+ this.#draw(view, this.#state.session, []);
200
+ }
201
+ #setPush(notify) {
202
+ this.#notify = notify;
203
+ this.#followPush();
204
+ }
205
+ /** Holds the host's push open while the last view asks for it and someone is here to see it. */
206
+ #followPush() {
207
+ if (!this.#notify || this.#listeners.size === 0 || !this.#options.transport.notify) {
208
+ this.#listening?.abort();
209
+ this.#listening = null;
210
+ return;
211
+ }
212
+ if (this.#listening)
213
+ return;
214
+ const listening = new AbortController();
215
+ this.#listening = listening;
216
+ void this.#listen(listening);
217
+ }
218
+ async #listen(listening) {
219
+ const { signal } = listening;
220
+ while (!signal.aborted) {
221
+ try {
222
+ await this.#options.transport.notify?.(() => void this.refresh(), signal);
223
+ }
224
+ catch {
225
+ // why: The push is a convenience; the page still reads on its next turn or reload.
226
+ }
227
+ const retryMs = this.#notify?.retryMs;
228
+ if (signal.aborted || retryMs === undefined)
229
+ break;
230
+ await new Promise((resolve) => {
231
+ const timer = setTimeout(resolve, retryMs);
232
+ signal.addEventListener('abort', () => {
233
+ clearTimeout(timer);
234
+ resolve();
235
+ });
236
+ });
237
+ }
238
+ if (this.#listening === listening)
239
+ this.#listening = null;
240
+ }
149
241
  update = (options) => {
150
242
  this.#options = options;
151
243
  this.#warnPageTools();
@@ -435,6 +527,7 @@ class Chat {
435
527
  session: this.#sessionAfter(session, view.paused?.events ?? events),
436
528
  chatStarted: view.transcript.length > 0 || view.paused !== undefined,
437
529
  });
530
+ this.#setPush(view.notify);
438
531
  }
439
532
  /**
440
533
  * Keeps the model and effort valid as the interface changes: a pick still on the old defaults
package/client/index.d.ts CHANGED
@@ -33,5 +33,5 @@ export { hasLead, layoutResult, type ResultChart, type ResultFigure, type Result
33
33
  export type { AnsweringGate, ToolDecisionAction, ToolGateResolution, } from './tool-resume.ts';
34
34
  export { continueGatedToolInvocation, type PersonGateResolution } from './tool-resume.ts';
35
35
  export { createTraceFeed, recordedFeed, type TraceFeed } from './trace-feed.ts';
36
- export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, postJson, postNdjson, readNdjsonStream, type StatedTurn, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
36
+ export { CHAT_LINES, type ChatLine, type ChatLineSink, type ClientTurnEvent, type ConversationLine, type ConversationView, createHttpTransport, fetchConversation, fetchRewound, fetchStarted, type HostErrorBody, type HttpOptions, type HttpTransportOptions, hostError, isTheoremStreamError, type MalformedEvent, type NotifyLine, postJson, postNdjson, readNdjsonStream, type StatedTurn, type TheoremConversationRequest, type TheoremInvokeRequest, type TheoremReplay, type TheoremRewindRequest, type TheoremRewound, type TheoremStartRequest, type TheoremSteerRequest, type TheoremStopRequest, TheoremStreamError, type TheoremTransport, type TheoremTurnInput, type TheoremTurnRequest, type TurnEventSink, type UnsupportedEvent, type WireLines, } from './transport.ts';
37
37
  export { buildInvokeRequest, buildTurnRequest, foldAssistantTurn, issuedOperationId, type RunScope, type WalkAway, } from './turn-client.ts';
@@ -156,6 +156,10 @@ export type ConversationView = {
156
156
  events: TurnEvent[];
157
157
  blocks: TranscriptBlock[];
158
158
  };
159
+ /** The host pushes the end of a call that still runs. `retryMs` is its wait to reopen the push. */
160
+ notify?: {
161
+ retryMs?: number;
162
+ };
159
163
  };
160
164
  /** Read a conversation: a `/conversation` body. */
161
165
  export type TheoremConversationRequest = {
@@ -163,6 +167,14 @@ export type TheoremConversationRequest = {
163
167
  };
164
168
  /** A `/conversation` body as `createTheoremHandler` reads it. */
165
169
  export declare const theoremConversationRequestSchema: z.ZodType<TheoremConversationRequest>;
170
+ /** Listen for the ends of this session's background calls: a `/notify` body. */
171
+ export type TheoremNotifyRequest = Record<string, never>;
172
+ /** A `/notify` body as `createTheoremHandler` reads it. */
173
+ export declare const theoremNotifyRequestSchema: z.ZodType<TheoremNotifyRequest>;
174
+ /** A line of `/notify`: something in the session changed, so read the conversation again. */
175
+ export type NotifyLine = {
176
+ type: 'changed';
177
+ };
166
178
  /**
167
179
  * Cut the conversation after transcript block `at` (-1 empties it): a
168
180
  * `/rewind` body. A reply waiting on gates is walked away from first.
@@ -200,6 +212,8 @@ export interface TheoremTransport {
200
212
  stop(request: TheoremStopRequest): Promise<void>;
201
213
  /** The conversation under `ref`, as the host shows it. */
202
214
  conversation(request: TheoremConversationRequest, signal?: AbortSignal): Promise<ConversationView>;
215
+ /** Calls `changed` for each line of the host's push, and settles when the push ends. */
216
+ notify?(changed: () => void, signal: AbortSignal): Promise<void>;
203
217
  /** The conversation cut after a transcript block, under the ref the host issued for it. */
204
218
  rewind(request: TheoremRewindRequest, signal?: AbortSignal): Promise<TheoremRewound>;
205
219
  pageResult?(result: PageDispatchResult, signal?: AbortSignal): Promise<void>;
@@ -111,12 +111,17 @@ const conversationView = z.object({
111
111
  paused: z
112
112
  .object({ events: z.array(turnEventSchema), blocks: z.array(transcriptBlockSchema) })
113
113
  .optional(),
114
+ notify: z.object({ retryMs: z.number().optional() }).optional(),
114
115
  });
115
116
  true;
116
117
  const theoremConversationRequest = z.object({ conversation: z.string().min(1) });
117
118
  true;
118
119
  /** A `/conversation` body as `createTheoremHandler` reads it. */
119
120
  export const theoremConversationRequestSchema = theoremConversationRequest;
121
+ const theoremNotifyRequest = z.strictObject({});
122
+ /** A `/notify` body as `createTheoremHandler` reads it. */
123
+ export const theoremNotifyRequestSchema = theoremNotifyRequest;
124
+ const NOTIFY_LINES = { changed: z.object({ type: z.literal('changed') }) };
120
125
  const theoremRewindRequest = z.object({
121
126
  conversation: z.string().min(1),
122
127
  at: z.number().int().min(-1),
@@ -348,6 +353,10 @@ export function createHttpTransport(options = {}) {
348
353
  turn: ({ replay: _replay, ...body }, onEvent, signal) => postNdjson(`${base}/turn`, body, CHAT_LINES, onEvent, { ...options, signal }),
349
354
  invoke: ({ replay: _replay, ...body }, onEvent, signal) => postNdjson(`${base}/invoke`, body, CHAT_LINES, onEvent, { ...options, signal }),
350
355
  conversation: (body, signal) => fetchConversation(`${base}/conversation`, body, { ...options, signal }),
356
+ notify: (changed, signal) => postNdjson(`${base}/notify`, {}, NOTIFY_LINES, (line) => {
357
+ if (line.type === 'changed')
358
+ changed();
359
+ }, { ...options, signal }),
351
360
  rewind: (body, signal) => fetchRewound(`${base}/rewind`, body, { ...options, signal }),
352
361
  pageResult: (body, signal) => postJson(`${base}/page-result`, body, { ...options, signal }),
353
362
  steer: (body) => postJson(`${base}/steer`, body, options),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theoremjs/web",
3
- "version": "0.3.4-next.747.g0909061",
3
+ "version": "0.3.4-next.755.g88665bd",
4
4
  "description": "Theorem on the web platform, with no UI framework: Web-standard server handlers that serve a profile, and the browser client that talks to them.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -25,6 +25,6 @@
25
25
  },
26
26
  "peerDependencies": {
27
27
  "zod": "^4.6.5",
28
- "@theoremjs/agents": "^0.3.4-next.747.g0909061"
28
+ "@theoremjs/agents": "^0.3.4-next.755.g88665bd"
29
29
  }
30
30
  }
@@ -363,8 +363,7 @@ function requireWalkAway(conversation, abandon) {
363
363
  function requireWaitingOn(conversation, reference) {
364
364
  if (waitingOn(conversation, reference))
365
365
  return;
366
- // labels-exempt: internal diagnostic; the user reads the copy key's wording
367
- throw new TheoremError('request', `the reply is not waiting on ${reference.callId}`, {
366
+ throw new TheoremError('request', labelText('session.gate_expired'), {
368
367
  copy: { key: 'session.gate_expired' },
369
368
  });
370
369
  }
@@ -13,6 +13,8 @@ import { type PageToolBroker } from './page-dispatch.ts';
13
13
  * - `POST <base>/stop` → stop a running turn where it stands
14
14
  * - `POST <base>/page-result` → the page's result for a tool the turn ran in the page
15
15
  * - `POST <base>/conversation` → the transcript and the paused reply, to draw
16
+ * - `POST <base>/notify` → NDJSON that stays open: one line now, and one each time a background
17
+ * call in this session ends. Set `notifier` to serve it
16
18
  * - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
17
19
  *
18
20
  * `createTheoremHostHandler` serves a host profile the same way, with no model:
@@ -39,6 +41,7 @@ import { type ClientTurnOptions } from '@theoremjs/agents/host';
39
41
  import { type ToolBackgroundService } from '@theoremjs/agents/kernel';
40
42
  import { type TheoremConversationStore } from './conversation-store.ts';
41
43
  import { type TheoremCredentialStore } from './credential-store.ts';
44
+ import type { TheoremNotifier } from './notifier.ts';
42
45
  export { theoremSession, theoremSessionId } from './session-identity.ts';
43
46
  import { type SessionToolOperation, type TheoremSessionStore } from './session-store.ts';
44
47
  import { type SteerInbox } from './steer-inbox.ts';
@@ -127,6 +130,17 @@ export type TheoremHandlerOptions = {
127
130
  request: Request;
128
131
  sessionId: string;
129
132
  }) => ToolBackgroundService;
133
+ /**
134
+ * Tells an open page that a background call ended, so it redraws with no turn or reload. Unset,
135
+ * the page reads the end on its next turn or reload. A sweep that marks a call itself calls
136
+ * `publish` too. `createMemoryNotifier` for one instance.
137
+ */
138
+ notifier?: TheoremNotifier;
139
+ /**
140
+ * How long an open page waits to reopen `notifier`'s stream after it ends, in milliseconds.
141
+ * Hosting platforms cut long responses. Unset, the page does not reopen it.
142
+ */
143
+ notifyRetryMs?: number;
130
144
  /** Check current resource access before disclosing a saved tool result. This callback must be read-only. */
131
145
  authorizeToolReplay?: (context: {
132
146
  request: Request;
@@ -167,7 +181,7 @@ export type TheoremHandlerOptions = {
167
181
  }) => void;
168
182
  };
169
183
  /** A host profile runs no model, and takes no turns to steer. */
170
- export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore' | 'prepare'> & {
184
+ export type TheoremHostHandlerOptions = Omit<TheoremHandlerOptions, 'profile' | 'provider' | 'steerInbox' | 'conversationStore' | 'prepare' | 'notifier' | 'notifyRetryMs'> & {
171
185
  /** Host profile to serve. Registered with the kernel when the handler is created. */
172
186
  profile: Extract<Profile, {
173
187
  type: 'host';
package/server/handler.js CHANGED
@@ -14,6 +14,8 @@ import { checkPageToolTimeout, createMemoryPageToolBroker, pageToolResultSchema,
14
14
  * - `POST <base>/stop` → stop a running turn where it stands
15
15
  * - `POST <base>/page-result` → the page's result for a tool the turn ran in the page
16
16
  * - `POST <base>/conversation` → the transcript and the paused reply, to draw
17
+ * - `POST <base>/notify` → NDJSON that stays open: one line now, and one each time a background
18
+ * call in this session ends. Set `notifier` to serve it
17
19
  * - `POST <base>/rewind` → `{ conversation, view }` the conversation cut after one block, and its new ref
18
20
  *
19
21
  * `createTheoremHostHandler` serves a host profile the same way, with no model:
@@ -40,7 +42,7 @@ import { caughtStatus, clientError, forClient, HTTP_NOT_FOUND, } from '@theoremj
40
42
  import { clientProfileInterface, gatedToolsFromEvents, interfaceFromProfile, promotedToolIdsFromEvents, toolSnapshotFromEvents, } from '@theoremjs/agents/interface';
41
43
  import { answerGatedCall, resolveGateTtlMs, } from '@theoremjs/agents/kernel';
42
44
  import { hostInterface, theoremHostCallRequestSchema, } from "../client/host-transport.js";
43
- import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
45
+ import { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremRewindRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
44
46
  import { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, requiredOperationId, rewindConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
45
47
  import { unknownConversation, } from "./conversation-store.js";
46
48
  import { createMemoryCredentialStore, } from "./credential-store.js";
@@ -94,6 +96,7 @@ function routeOf(request) {
94
96
  last === 'steer' ||
95
97
  last === 'stop' ||
96
98
  last === 'conversation' ||
99
+ last === 'notify' ||
97
100
  last === 'rewind' ||
98
101
  last === 'call' ||
99
102
  last === 'page-result'
@@ -304,8 +307,7 @@ function answerPendingGate(state, request, profileId) {
304
307
  ? state.gates[request.operationId]
305
308
  : undefined;
306
309
  if (!pending || pending.profileId !== profileId || pending.callId !== request.callId) {
307
- // labels-exempt: internal diagnostic; the user reads the error kind's (or copy key's) wording
308
- throw new TheoremError('request', `gate ${request.callId} is not pending`, {
310
+ throw new TheoremError('request', labelText('session.gate_expired'), {
309
311
  copy: { key: 'session.gate_expired' },
310
312
  });
311
313
  }
@@ -400,9 +402,23 @@ async function* invokeAnswered(ctx, request, sessionId, { callId, pending, answe
400
402
  }
401
403
  /** The host's service for a call in this session, for a request that runs a tool. */
402
404
  function backgroundWiring(ctx, request, sessionId) {
403
- return ctx.options.background
404
- ? { background: ctx.options.background({ request, sessionId }) }
405
- : {};
405
+ const service = ctx.options.background?.({ request, sessionId });
406
+ if (!service)
407
+ return {};
408
+ const { notifier } = ctx.options;
409
+ if (!notifier)
410
+ return { background: service };
411
+ return {
412
+ background: {
413
+ detached(call) {
414
+ service.detached(call);
415
+ void call.done
416
+ .then(() => notifier.publish(sessionId))
417
+ .catch((err) => ctx.options.onError?.(err, { request }));
418
+ },
419
+ event: (event, call) => service.event(event, call),
420
+ },
421
+ };
406
422
  }
407
423
  /** A settled call's answer, streamed again. */
408
424
  async function* once(event, labels) {
@@ -525,32 +541,94 @@ async function conversation(ctx, request, session) {
525
541
  if (!host)
526
542
  throw statelessRefusal('read');
527
543
  const opened = await openConversation(host, body.conversation, { kind: 'read' });
528
- return jsonResponse(200, await currentView(ctx, session, host, opened.conversation));
544
+ return jsonResponse(200, await currentView(ctx, request, session, host, opened.conversation));
545
+ }
546
+ const CHANGED_LINE = new TextEncoder().encode('{"type":"changed"}\n');
547
+ /**
548
+ * One line now, then one for each background call that ends. The stream names no conversation,
549
+ * because a store's ref can change with each write; the page reads under the ref it holds.
550
+ * invariant: The first line follows the subscribe, so a call that ends before the page's first
551
+ * read is never missed.
552
+ */
553
+ async function notify(ctx, request, session, notifier) {
554
+ await readBody(request, theoremNotifyRequestSchema, ctx.options.maxRequestBytes);
555
+ let send = () => { };
556
+ const unsubscribe = await notifier.subscribe(session.id, () => send());
557
+ let open = true;
558
+ const end = () => {
559
+ if (!open)
560
+ return;
561
+ open = false;
562
+ unsubscribe();
563
+ };
564
+ const stream = new ReadableStream({
565
+ start(controller) {
566
+ send = () => {
567
+ if (open)
568
+ controller.enqueue(CHANGED_LINE);
569
+ };
570
+ send();
571
+ const leave = () => {
572
+ if (!open)
573
+ return;
574
+ end();
575
+ controller.close();
576
+ };
577
+ if (request.signal.aborted)
578
+ leave();
579
+ else
580
+ request.signal.addEventListener('abort', leave, { once: true });
581
+ },
582
+ cancel: end,
583
+ });
584
+ return new Response(stream, { headers: NDJSON_HEADERS });
529
585
  }
530
586
  /**
531
587
  * why: The transcript keeps a detached call as the turn left it. The session holds what became of
532
588
  * it, so a call that has ended does not read as running.
533
589
  */
534
- async function currentView(ctx, session, host, conversation) {
590
+ async function currentView(ctx, request, session, host, conversation) {
535
591
  const view = conversationView(host, conversation);
536
592
  if (!view.transcript.some(isDetachedBlock))
537
593
  return view;
538
594
  const { operations } = await ctx.sessions.read(session.id);
595
+ const transcript = await Promise.all(view.transcript.map((block) => isDetachedBlock(block) ? lateBlock(ctx, request, block, operations, host) : block));
596
+ const { notifier, notifyRetryMs } = ctx.options;
539
597
  return {
540
598
  ...view,
541
- transcript: view.transcript.map((block) => isDetachedBlock(block) ? lateBlock(block, operations, host, ctx.profile) : block),
599
+ transcript,
600
+ ...(notifier && transcript.some(isDetachedBlock)
601
+ ? { notify: notifyRetryMs === undefined ? {} : { retryMs: notifyRetryMs } }
602
+ : {}),
542
603
  };
543
604
  }
544
605
  function isDetachedBlock(block) {
545
606
  return block.kind === 'tool' && block.tool.state?.phase === 'background';
546
607
  }
547
608
  /** A detached call's block as its operation stands. One the session no longer holds has no known end. */
548
- function lateBlock(block, operations, host, profile) {
609
+ async function lateBlock(ctx, request, block, operations, host) {
549
610
  const { tool } = block;
550
611
  const id = tool.operationId;
551
612
  const operation = id && Object.hasOwn(operations, id) ? operations[id] : undefined;
552
613
  if (operation?.status === 'running' || operation?.status === 'background')
553
614
  return block;
615
+ const { labels } = ctx.profile;
616
+ if (operation?.status === 'settled' &&
617
+ ctx.options.authorizeToolReplay &&
618
+ !(await ctx.options.authorizeToolReplay({ request, operation }))) {
619
+ const message = labelText('error.blocked', {}, labels);
620
+ const failure = { code: 'outcome_withheld', kind: 'blocked', message };
621
+ const { name, callId } = tool;
622
+ const at = operation.settledAt;
623
+ return {
624
+ ...block,
625
+ tool: {
626
+ ...tool,
627
+ state: { name, callId, at, phase: 'error', failure, readBack: message },
628
+ endedAt: at,
629
+ },
630
+ };
631
+ }
554
632
  if (operation?.status === 'settled') {
555
633
  const settled = host.forReader(operation.outcome.event);
556
634
  if (settled.type !== 'tool')
@@ -560,7 +638,7 @@ function lateBlock(block, operations, host, profile) {
560
638
  return block;
561
639
  return { ...block, tool: { ...tool, state, endedAt: operation.settledAt } };
562
640
  }
563
- const message = labelText('tool.execution_uncertain', {}, profile.labels);
641
+ const message = labelText('tool.execution_uncertain', {}, labels);
564
642
  const failure = {
565
643
  code: operation?.status === 'uncertain' ? operation.reason : 'outcome_not_retained',
566
644
  kind: 'internal',
@@ -598,7 +676,7 @@ async function rewind(ctx, request, session) {
598
676
  throw new TheoremError('internal', labelText('error.internal'));
599
677
  const rewound = {
600
678
  conversation: next.ref,
601
- view: await currentView(ctx, session, host, next.conversation),
679
+ view: await currentView(ctx, request, session, host, next.conversation),
602
680
  };
603
681
  return jsonResponse(200, rewound);
604
682
  }
@@ -792,6 +870,12 @@ async function route(ctx, request, session) {
792
870
  return invoke(ctx, request, session);
793
871
  if (target === 'conversation')
794
872
  return conversation(ctx, request, session);
873
+ if (target === 'notify') {
874
+ const { notifier } = ctx.options;
875
+ if (!notifier || !chat.conversations)
876
+ return refused(ctx);
877
+ return notify(ctx, request, session, notifier);
878
+ }
795
879
  if (target === 'rewind')
796
880
  return rewind(ctx, request, session);
797
881
  if (target === 'stop')
@@ -850,6 +934,13 @@ export function hostTool(name) {
850
934
  const registered = defaultKernelScope.tools.get(name);
851
935
  return registered?.type === 'builtin' ? undefined : registered;
852
936
  }
937
+ function checkNotifyRetry(ms) {
938
+ if (ms === undefined || (Number.isFinite(ms) && ms >= 0))
939
+ return;
940
+ throw new TheoremError('config', labelText('error.config'), {
941
+ cause: { code: 'notify_retry' },
942
+ });
943
+ }
853
944
  function serve(name, profile, options, chatOptions = {}) {
854
945
  const gateTtlMs = resolveGateTtlMs(name, options.gateTtlMs);
855
946
  const operationRetentionMs = options.operationRetentionMs ?? DEFAULT_OPERATION_RETENTION_MS;
@@ -860,6 +951,7 @@ function serve(name, profile, options, chatOptions = {}) {
860
951
  }
861
952
  requestByteLimit(options.maxRequestBytes);
862
953
  checkPageToolTimeout(options.pageToolTimeoutMs);
954
+ checkNotifyRetry(options.notifyRetryMs);
863
955
  registerProfile(profile);
864
956
  const ctx = {
865
957
  options,
package/server/mod.d.ts CHANGED
@@ -7,7 +7,7 @@
7
7
  */
8
8
  export { theoremHostCallRequestSchema } from '../client/host-transport.ts';
9
9
  export type { LiveClientMessage, LiveOpenMessage } from '../client/live-messages.ts';
10
- export { type StatedTurn, theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
10
+ export { type StatedTurn, theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from '../client/transport.ts';
11
11
  export { answerReply, type ConversationHost, type ConversationUse, conversationView, type IssuedWalkAway, messageReply, type OpenedConversation, openConversation, type PostedMessage, postedMessage, type ReplyLine, type ReplyRunner, type ReplyTurn, readerConversationHost, rewindConversation, startConversation, statelessRefusal, type WalkedAwayCall, waitingCalls, } from './conversation.ts';
12
12
  export { type ConversationSnapshot, type ConversationWriteResult, createMemoryConversationStore, createSealedConversationStore, emptyConversation, type PausedReply, type SealedConversationStoreOptions, type StoredConversation, storedConversationSchema, type TheoremConversationStore, unknownConversation, } from './conversation-store.ts';
13
13
  export { createMemoryCredentialStore, type TheoremCredentialStore, type TheoremCredentials, } from './credential-store.ts';
@@ -15,6 +15,7 @@ export { createTheoremDecisionHandler, type DecisionQuestionsBuilder, isQuestion
15
15
  export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, type TheoremEmbeddingHandlerOptions, } from './embedding-handler.ts';
16
16
  export { createTheoremHandler, createTheoremHostHandler, readBody, type TheoremHandlerOptions, type TheoremHostHandlerOptions, type TheoremRequestContext, theoremSession, theoremSessionId, } from './handler.ts';
17
17
  export { createTheoremMcpHandler, type TheoremMcpHandlerOptions } from './mcp-handler.ts';
18
+ export { createMemoryNotifier, type TheoremNotifier } from './notifier.ts';
18
19
  export { createMemoryPageToolBroker, type PageToolBroker, type PageToolResult, pageToolResultSchema, sessionPageDispatch, } from './page-dispatch.ts';
19
20
  export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from './request-check.ts';
20
21
  export { sessionStateStore } from './session-authority.ts';
package/server/mod.js CHANGED
@@ -6,7 +6,7 @@
6
6
  * @module
7
7
  */
8
8
  export { theoremHostCallRequestSchema } from "../client/host-transport.js";
9
- export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
9
+ export { theoremConversationRequestSchema, theoremInvokeRequestSchema, theoremNotifyRequestSchema, theoremReplaySchema, theoremRewindRequestSchema, theoremStartRequestSchema, theoremSteerRequestSchema, theoremStopRequestSchema, theoremTurnRequestSchema, } from "../client/transport.js";
10
10
  export { answerReply, conversationView, messageReply, openConversation, postedMessage, readerConversationHost, rewindConversation, startConversation, statelessRefusal, waitingCalls, } from "./conversation.js";
11
11
  export { createMemoryConversationStore, createSealedConversationStore, emptyConversation, storedConversationSchema, unknownConversation, } from "./conversation-store.js";
12
12
  export { createMemoryCredentialStore, } from "./credential-store.js";
@@ -14,6 +14,7 @@ export { createTheoremDecisionHandler, isQuestionsBuilder, } from "./decision-ha
14
14
  export { createTheoremEmbeddingHandler, embedBodySchema, embedRequest, } from "./embedding-handler.js";
15
15
  export { createTheoremHandler, createTheoremHostHandler, readBody, theoremSession, theoremSessionId, } from "./handler.js";
16
16
  export { createTheoremMcpHandler } from "./mcp-handler.js";
17
+ export { createMemoryNotifier } from "./notifier.js";
17
18
  export { createMemoryPageToolBroker, pageToolResultSchema, sessionPageDispatch, } from "./page-dispatch.js";
18
19
  export { checkRequest, parseLiveClientMessage, parseLiveOpenMessage } from "./request-check.js";
19
20
  export { sessionStateStore } from "./session-authority.js";
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Where a session's open pages hear that a background call ended. The line carries no content: a
3
+ * page that hears it reads the conversation, so one read decides what a reader sees.
4
+ *
5
+ * @module
6
+ */
7
+ /**
8
+ * Keyed by session id. The default lives in process memory, which reaches only pages connected to
9
+ * the process that ran the call. Bring a shared one (Redis, a Durable Object) for several instances.
10
+ */
11
+ export interface TheoremNotifier {
12
+ /** Calls `changed` for each `publish` under `sessionId`, until the returned function runs. */
13
+ subscribe(sessionId: string, changed: () => void): (() => void) | Promise<() => void>;
14
+ publish(sessionId: string): void | Promise<void>;
15
+ }
16
+ export declare function createMemoryNotifier(): TheoremNotifier;
@@ -0,0 +1,25 @@
1
+ /**
2
+ * Where a session's open pages hear that a background call ended. The line carries no content: a
3
+ * page that hears it reads the conversation, so one read decides what a reader sees.
4
+ *
5
+ * @module
6
+ */
7
+ export function createMemoryNotifier() {
8
+ const listeners = new Map();
9
+ return {
10
+ subscribe(sessionId, changed) {
11
+ const listening = listeners.get(sessionId) ?? new Set();
12
+ listeners.set(sessionId, listening);
13
+ listening.add(changed);
14
+ return () => {
15
+ listening.delete(changed);
16
+ if (listening.size === 0 && listeners.get(sessionId) === listening)
17
+ listeners.delete(sessionId);
18
+ };
19
+ },
20
+ publish(sessionId) {
21
+ for (const changed of [...(listeners.get(sessionId) ?? [])])
22
+ changed();
23
+ },
24
+ };
25
+ }