@fungi.computer/shiitake 0.8.0 → 0.9.0

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
@@ -22,6 +22,34 @@ To add your own behaviour, start the extension guide at
22
22
  We don't take VC money. Fungi is built in the open and proudly backed by its
23
23
  community.
24
24
 
25
+ ## Observe a Session
26
+
27
+ `session.watch()` returns an owner-atomic snapshot and live `changes`; close the
28
+ watch when the consumer leaves. `session.subscribe({ after })` replays strictly
29
+ after an opaque Woodstock cursor, then joins the same per-Session live feed.
30
+ Woodstock remains the only durable history owner.
31
+
32
+ Watchers share one projection producer and encoded frame per Session. Each
33
+ watcher can retain at most 256 frames or 1 MiB of encoded UTF-8, whichever comes
34
+ first. Overflow drops that backlog and delivers one `reset` with a fresh
35
+ snapshot, even if no later write arrives. There are at most 64 live watchers per
36
+ Agent; closing one releases its capacity. Closing the last watcher stops the
37
+ Session's producer and heartbeat.
38
+
39
+ The byte limit also applies to a single frame: an encoded frame over 1 MiB
40
+ cannot enter even an empty watcher queue. Every watcher whose frame exceeds that
41
+ limit resets, including an otherwise fast watcher. This is a per-watcher check,
42
+ not a reset of other Sessions or of watchers whose filtered frame and backlog
43
+ fit. The fresh Woodstock baseline is the recovery path; silently exceeding the
44
+ queue bound or adding a second chunked wire protocol is not.
45
+
46
+ A typed `{ kind: "heartbeat" }` arrives every 15 seconds. It is not durable and
47
+ never carries or advances a cursor. Durable rows deduplicate by owner sequence;
48
+ message progress uses message identity and revision, not the durable cursor. The
49
+ HTTP client treats 45 seconds without a delivered event as an unavailable,
50
+ possibly busy feed. Silence does not prove the service stopped: Local clients
51
+ must check the service owner's database lease before claiming that.
52
+
25
53
  ## Recover an uncertain send
26
54
 
27
55
  Both local and HTTP Sessions expose
@@ -164,6 +164,7 @@ export const SessionSnapshotSchema = Schema.Struct({
164
164
  context: Schema.optionalKey(SessionContextSchema),
165
165
  });
166
166
  export const SessionFrameSchema = Schema.Union([
167
+ Schema.Struct({ kind: Schema.Literal("heartbeat") }),
167
168
  Schema.Struct({
168
169
  kind: Schema.Literal("activity"),
169
170
  activity: SessionActivitySchema,
package/dist/client.d.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { SessionAttention } from "./session-attention.js";
2
+ export { sessionHeartbeatIntervalMs, sessionSilenceDeadlineMs, } from "./session-liveness.js";
2
3
  import type { CommandSettlement } from "./internal/contracts.js";
3
4
  export type { CommandSettlement } from "./internal/contracts.js";
4
5
  export { type SessionAttention } from "./session-attention.js";
@@ -342,6 +343,9 @@ export type SessionSnapshot = Readonly<{
342
343
  }>;
343
344
  /** One bounded replay or live frame. */
344
345
  export type SessionFrame = Readonly<{
346
+ /** Process-local liveness only: never a durable cursor or activity fact. */
347
+ kind: "heartbeat";
348
+ }> | Readonly<{
345
349
  /** Process-local activity change for the Session's current queue head. */
346
350
  kind: "activity";
347
351
  activity: SessionActivity;
package/dist/client.js CHANGED
@@ -1,3 +1,4 @@
1
+ export { sessionHeartbeatIntervalMs, sessionSilenceDeadlineMs, } from "./session-liveness.js";
1
2
  export {} from "./session-attention.js";
2
3
  export { createShiitakeClient, } from "./remote-client.js";
3
4
  export { bindAgentExtension, bindSessionExtension, } from "./extension-client.js";
@@ -103,11 +103,11 @@ export declare function agentMailRoute(namespace: PostRoute<never, never>["names
103
103
  };
104
104
  } | {
105
105
  readonly key: string;
106
+ readonly content: string;
106
107
  readonly reply: {
107
108
  readonly outcome: "undeliverable";
108
109
  readonly reason: "recipient_offline";
109
110
  };
110
- readonly content: string;
111
111
  readonly to: {
112
112
  readonly agentId: string;
113
113
  readonly kind: "agent";
@@ -162,11 +162,11 @@ export declare function agentMailRoute(namespace: PostRoute<never, never>["names
162
162
  };
163
163
  } | {
164
164
  readonly key: string;
165
+ readonly content: string;
165
166
  readonly reply: {
166
167
  readonly outcome: "undeliverable";
167
168
  readonly reason: "recipient_offline";
168
169
  };
169
- readonly content: string;
170
170
  readonly to: {
171
171
  readonly agentId: string;
172
172
  readonly kind: "agent";
@@ -221,11 +221,11 @@ export declare function agentMailRoute(namespace: PostRoute<never, never>["names
221
221
  };
222
222
  } | {
223
223
  readonly key: string;
224
+ readonly content: string;
224
225
  readonly reply: {
225
226
  readonly outcome: "undeliverable";
226
227
  readonly reason: "recipient_offline";
227
228
  };
228
- readonly content: string;
229
229
  readonly to: {
230
230
  readonly agentId: string;
231
231
  readonly kind: "agent";
@@ -280,11 +280,11 @@ export declare function agentMailRoute(namespace: PostRoute<never, never>["names
280
280
  };
281
281
  } | {
282
282
  readonly key: string;
283
+ readonly content: string;
283
284
  readonly reply: {
284
285
  readonly outcome: "undeliverable";
285
286
  readonly reason: "recipient_offline";
286
287
  };
287
- readonly content: string;
288
288
  readonly to: {
289
289
  readonly agentId: string;
290
290
  readonly kind: "agent";
@@ -513,7 +513,6 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
513
513
  readonly content: string;
514
514
  readonly usage: readonly {
515
515
  model: string;
516
- provider: import("@fungi.computer/mule/effect").ProviderId;
517
516
  usage: {
518
517
  input: number;
519
518
  output: number;
@@ -530,6 +529,7 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
530
529
  total: number;
531
530
  };
532
531
  };
532
+ provider: import("@fungi.computer/mule/effect").ProviderId;
533
533
  }[];
534
534
  readonly toolCalls: number;
535
535
  readonly waitingOnYou: readonly {
@@ -711,7 +711,7 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
711
711
  } | {
712
712
  toolCallId: string;
713
713
  role: "toolResult";
714
- timestamp: number;
714
+ toolName: string;
715
715
  content: ({
716
716
  type: "text";
717
717
  text: string;
@@ -721,7 +721,6 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
721
721
  data: string;
722
722
  mimeType: string;
723
723
  })[];
724
- toolName: string;
725
724
  usage?: {
726
725
  input: number;
727
726
  output: number;
@@ -753,6 +752,7 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
753
752
  complete: boolean;
754
753
  } | undefined;
755
754
  isError: boolean;
755
+ timestamp: number;
756
756
  durationMs?: number | undefined;
757
757
  };
758
758
  }[];
@@ -4,6 +4,7 @@ import { Hono } from "hono";
4
4
  import { streamSSE, } from "hono/streaming";
5
5
  import { validator } from "hono/validator";
6
6
  import * as Schema from "effect/Schema";
7
+ import { encodeSessionFrame } from "./internal/observation-kernel.js";
7
8
  import { ShiitakeError, toShiitakeErrorWire } from "./errors.js";
8
9
  import { JsonValueSchema } from "./internal/json-value.js";
9
10
  import { decodeClientIdentity, decodeClientEventsQuery, decodeClientReadQuery, decodeClientSettlementQuery, decodeClientSessionCommandRequest, decodeClientSessionCreationRequest, decodeClientPageCursor, decodeClientSessionId, } from "./internal/client-boundary.js";
@@ -84,7 +85,7 @@ function frameEvent(frame) {
84
85
  : frame.kind === "reset"
85
86
  ? (frame.cursor ?? "")
86
87
  : undefined;
87
- const event = { event: "frame", data: JSON.stringify(frame) };
88
+ const event = { event: "frame", data: encodeSessionFrame(frame).data };
88
89
  return id === undefined ? event : { ...event, id };
89
90
  }
90
91
  async function writeSessionEvents(stream, session, request) {
@@ -1,13 +1,14 @@
1
1
  import type { BirdDogMail } from "@fungi.computer/bird-dog";
2
2
  import type { VerifiedAgentReply } from "./woodstock/index.js";
3
3
  import * as Effect from "effect/Effect";
4
+ import type * as Context from "effect/Context";
4
5
  import { ShiitakeError } from "../errors.js";
5
6
  import type { Json } from "../extensions.js";
6
7
  import type { ShiitakeEffectOwner, ShiitakeEffectOptions } from "./effect-contracts.js";
7
8
  import type { RuntimeCoordinator } from "./runtime-coordinator.js";
8
9
  import { type GrantsDelivery } from "./woodstock/index.js";
9
10
  /** Construct the Effect-native coordinator over the private owner modules. */
10
- export declare function openCoordinator(options: ShiitakeEffectOptions, runtime: RuntimeCoordinator): ShiitakeEffectOwner;
11
+ export declare function openCoordinator(options: ShiitakeEffectOptions, runtime: RuntimeCoordinator, ownerContext: Context.Context<never>): ShiitakeEffectOwner;
11
12
  /**
12
13
  * Accept one addressed mail envelope; the door decodes it as a prompt. `reply`
13
14
  * is the host's authentication of a delegated Agent's reply; only then does
@@ -4,14 +4,12 @@ import { acceptedNotice } from "@fungi.computer/bird-dog/effect";
4
4
  import { FungiMessageMetaSchema } from "@fungi.computer/agar/agents";
5
5
  import * as Effect from "effect/Effect";
6
6
  import * as Option from "effect/Option";
7
- import * as Stream from "effect/Stream";
8
7
  import { ShiitakeError } from "../errors.js";
9
- import { decodeClientReadOptions, decodeClientSettlementRequest, requireDecoded, decodeClientSessionCommand, decodeClientSessionId, decodeClientSubscribeOptions, } from "./client-boundary.js";
10
- import { mergeObservationSignals, openObservationKernel, publishAgentExecutionChange, publishOwnerObservation, } from "./observation-kernel.js";
8
+ import { decodeClientSettlementRequest, requireDecoded, decodeClientSessionCommand, decodeClientSessionId, } from "./client-boundary.js";
9
+ import { openObservationKernel, publishAgentExecutionChange, publishOwnerObservation, } from "./observation-kernel.js";
10
+ import { openSessionObservation } from "./session-observation.js";
11
11
  import { checkHostMeta } from "./host-meta.js";
12
- import { readSessionActivity, } from "./session-status.js";
13
- import { prepareSessionContext } from "./session-context.js";
14
- import { progressPosition, readProcessProgressDelta, abortSession, cancelSession, branchSession, recordOperatorLine, forkSession, readSession, readCommandSettlement, readSessionModelPreference, readSessionReplayPage, readSessionInbox, readLatestPendingPrompt, createSession, deleteSession, deliverSessionGrants, readSessionGrants, setSessionModelPreference, setSessionTitle, readAdmissionIdentity, assertAdmissionIdentity, } from "./woodstock/index.js";
12
+ import { abortSession, cancelSession, branchSession, recordOperatorLine, forkSession, readCommandSettlement, readSessionModelPreference, readSessionInbox, readLatestPendingPrompt, createSession, deleteSession, deliverSessionGrants, readSessionGrants, setSessionModelPreference, setSessionTitle, readAdmissionIdentity, assertAdmissionIdentity, } from "./woodstock/index.js";
15
13
  /** Generic Agent-scope extension controls over the runtime's one call door. */
16
14
  function agentExtensions(runtime) {
17
15
  return Object.freeze({
@@ -20,29 +18,6 @@ function agentExtensions(runtime) {
20
18
  call: (extensionId, command, input) => runtime.assertOpen.pipe(Effect.andThen(runtime.extensions.call({ scope: "agent" }, extensionId, command, input))),
21
19
  });
22
20
  }
23
- function currentSessionWork(runtime, sessionId) {
24
- return runtime
25
- .readQueueHead(sessionId)
26
- .pipe(Effect.flatMap((head) => (head.state === "queued"
27
- ? runtime.runningWork(head.jobId)
28
- : Effect.succeed(null)).pipe(Effect.map((runningWork) => ({ head, runningWork })))));
29
- }
30
- function currentSessionActivity(options, runtime, sessionId) {
31
- return currentSessionWork(runtime, sessionId).pipe(Effect.map(({ head, runningWork }) => readSessionActivity(options.store, sessionId, head, runningWork)));
32
- }
33
- /** Carry the Session's context use on a baseline, when it has one. */
34
- function withContext(snapshot, context) {
35
- return context === undefined
36
- ? snapshot
37
- : Object.freeze({ ...snapshot, context });
38
- }
39
- /** Read the baseline and its context use for one Session. */
40
- function readSessionWithContext(options, runtime, sessionId, input) {
41
- return currentSessionWork(runtime, sessionId).pipe(Effect.flatMap(({ head, runningWork }) => readSession(options.store, sessionId, () => readSessionActivity(options.store, sessionId, head, runningWork), input, () => runtime.extensions.project(sessionId), (snapshot) => ({
42
- snapshot,
43
- context: prepareSessionContext(options, sessionId, snapshot.cursor, input?.before === undefined),
44
- }))), Effect.flatMap(({ snapshot, context }) => context.pipe(Effect.map((value) => withContext(snapshot, value)))));
45
- }
46
21
  function sessionOwnerOperation(runtime, sessionId, operation) {
47
22
  return runtime.assertOpen.pipe(Effect.andThen(Effect.try({
48
23
  try: () => operation(decodeClientSessionId(sessionId)),
@@ -67,190 +42,6 @@ function decodeSessionCommand(sessionId, input) {
67
42
  : new ShiitakeError("contract", "invalid", "Session command is invalid"),
68
43
  });
69
44
  }
70
- function decodeSubscriptionInput(sessionId, input) {
71
- return Effect.try({
72
- try: () => Object.freeze({
73
- sessionId: decodeClientSessionId(sessionId),
74
- options: decodeClientSubscribeOptions(input),
75
- }),
76
- catch: (error) => error instanceof ShiitakeError
77
- ? error
78
- : new ShiitakeError("contract", "invalid", "Subscribe options are invalid"),
79
- });
80
- }
81
- function resetFrame(options, runtime, sessionId, includeSandboxFailures = false) {
82
- return runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, sessionId, {
83
- includeSandboxFailures,
84
- })), Effect.map((baseline) => Object.freeze({
85
- kind: "reset",
86
- reason: "history_unavailable",
87
- cursor: baseline.cursor,
88
- baseline,
89
- })));
90
- }
91
- function continueFromReset(frame) {
92
- return [
93
- [frame],
94
- Option.some({
95
- after: frame.cursor ?? undefined,
96
- delivery: frame.baseline.hasMore ? "replay" : "live",
97
- awaitSignal: !frame.baseline.hasMore,
98
- }),
99
- ];
100
- }
101
- function subscriptionFrames(options, runtime, registration, sessionId, after, delivery, includeSandboxFailures = false, baselineProgress = null) {
102
- let position = progressPosition(baselineProgress);
103
- let toolOutput = [];
104
- const reset = () => resetFrame(options, runtime, sessionId, includeSandboxFailures).pipe(Effect.map((frame) => {
105
- position = progressPosition(frame.baseline.progress);
106
- toolOutput = frame.baseline.toolOutput;
107
- return continueFromReset(frame);
108
- }));
109
- const liveFrames = (activity) => {
110
- const queue = activity.facts.queue;
111
- const delta = queue.state !== "running"
112
- ? null
113
- : readProcessProgressDelta(options.store, sessionId, queue.commandId, position);
114
- if (delta === undefined)
115
- return undefined;
116
- const frames = [];
117
- if (delta !== null) {
118
- frames.push({
119
- kind: "progress",
120
- progress: delta,
121
- activity: activity.facts,
122
- });
123
- position = {
124
- messageId: delta.messageId,
125
- revision: delta.revision,
126
- text: delta.text.offset + delta.text.append.length,
127
- thinking: delta.thinking.offset + delta.thinking.append.length,
128
- };
129
- }
130
- if (toolOutput.length !== activity.toolOutput.length ||
131
- toolOutput.some((slot, index) => slot !== activity.toolOutput[index])) {
132
- toolOutput = activity.toolOutput;
133
- frames.push({
134
- kind: "tool_output",
135
- toolOutput,
136
- activity: activity.facts,
137
- });
138
- }
139
- return frames;
140
- };
141
- const initial = {
142
- after,
143
- delivery,
144
- awaitSignal: false,
145
- };
146
- return Stream.paginate(initial, (state) => (state.awaitSignal
147
- ? registration.awaitSignal.pipe(Effect.flatMap((signal) => registration.pollSignal.pipe(Effect.map((pending) => pending === undefined
148
- ? signal
149
- : mergeObservationSignals(signal, pending)))))
150
- : registration.pollSignal).pipe(Effect.flatMap((signal) => {
151
- if (signal === "closed")
152
- return Effect.succeed([[], Option.none()]);
153
- if (signal === "reset")
154
- return reset();
155
- return runtime.assertOpen.pipe(Effect.andThen(currentSessionActivity(options, runtime, sessionId)), Effect.flatMap((activity) => {
156
- registration.setQueued(activity.facts.queue.state === "queued");
157
- if ((signal === "progress" || signal === "activity") &&
158
- state.delivery === "live") {
159
- const frames = liveFrames(activity);
160
- if (frames === undefined)
161
- return reset();
162
- return Effect.succeed([
163
- [
164
- ...(signal === "activity"
165
- ? [
166
- {
167
- kind: "activity",
168
- activity: activity.facts,
169
- },
170
- ]
171
- : []),
172
- ...frames,
173
- ],
174
- Option.some({ ...state, awaitSignal: true }),
175
- ]);
176
- }
177
- return readSessionReplayPage(options.store, sessionId, state.after, state.delivery, activity, () => runtime.extensions.project(sessionId), includeSandboxFailures, (page) => {
178
- const latestCursor = page.kind === "page"
179
- ? page.frames
180
- .flatMap((frame) => frame.kind === "progress" ||
181
- frame.kind === "activity" ||
182
- frame.kind === "tool_output"
183
- ? []
184
- : [frame.cursor])
185
- .at(-1)
186
- : undefined;
187
- // Prepare a figure only for the final moving update,
188
- // while its cursor and stored context share the read.
189
- const context = page.kind === "page" &&
190
- !page.hasMore &&
191
- page.frames.some((frame) => frame.kind === "update" &&
192
- frame.cursor !== state.after)
193
- ? prepareSessionContext(options, sessionId, latestCursor ?? null)
194
- : Effect.succeed(undefined);
195
- return { page, latestCursor, context };
196
- }).pipe(Effect.flatMap(({ page, latestCursor, context, }) => {
197
- if (page.kind === "reset")
198
- return reset();
199
- if (!page.hasMore)
200
- toolOutput = activity.toolOutput;
201
- const progressFrames = page.hasMore
202
- ? []
203
- : liveFrames(activity);
204
- if (progressFrames === undefined)
205
- return reset();
206
- const next = {
207
- after: latestCursor ?? state.after,
208
- delivery: page.hasMore ? state.delivery : "live",
209
- awaitSignal: !page.hasMore,
210
- };
211
- return context.pipe(Effect.map((context) => [
212
- [
213
- ...(context === undefined
214
- ? page.frames
215
- : page.frames.map((frame) => frame.kind === "update" &&
216
- frame.cursor === latestCursor
217
- ? Object.freeze({ ...frame, context })
218
- : frame)),
219
- ...progressFrames,
220
- ],
221
- Option.some(next),
222
- ]));
223
- }));
224
- }));
225
- })));
226
- }
227
- function subscribeSession(options, runtime, observation, sessionId, input) {
228
- return Stream.unwrap(runtime.assertOpen.pipe(Effect.andThen(decodeSubscriptionInput(sessionId, input)), Effect.flatMap((decoded) => Effect.acquireRelease(observation.register(decoded.sessionId), (registration) => Effect.sync(registration.close)).pipe(Effect.map((registration) => subscriptionFrames(options, runtime, registration, decoded.sessionId, decoded.options.after, "replay", decoded.options.includeSandboxFailures === true))))));
229
- }
230
- function decodeWatchInput(sessionId, input) {
231
- return Effect.try({
232
- try: () => Object.freeze({
233
- sessionId: decodeClientSessionId(sessionId),
234
- options: decodeClientReadOptions(input),
235
- }),
236
- catch: (error) => error instanceof ShiitakeError
237
- ? error
238
- : new ShiitakeError("contract", "invalid", "Watch options are invalid"),
239
- });
240
- }
241
- function watchSession(options, runtime, observation, sessionId, input) {
242
- return runtime.assertOpen.pipe(Effect.andThen(decodeWatchInput(sessionId, input)), Effect.flatMap((decoded) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
243
- const registration = yield* observation.register(decoded.sessionId);
244
- return yield* restore(runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, decoded.sessionId, decoded.options)), Effect.map((snapshot) => {
245
- registration.setQueued(snapshot.activity.queue.state === "queued");
246
- return Object.freeze({
247
- snapshot,
248
- changes: subscriptionFrames(options, runtime, registration, decoded.sessionId, snapshot.cursor ?? undefined, "live", decoded.options.includeSandboxFailures === true, snapshot.progress).pipe(Stream.ensuring(Effect.sync(registration.close))),
249
- close: Effect.sync(registration.close),
250
- });
251
- }))).pipe(Effect.onError(() => Effect.sync(registration.close)));
252
- }))));
253
- }
254
45
  /**
255
46
  * Admit one user prompt, mail prompt or compaction request through the
256
47
  * accepted-command door.
@@ -359,8 +150,9 @@ function checkCommandMeta(options, meta) {
359
150
  });
360
151
  }
361
152
  /** Construct the Effect-native coordinator over the private owner modules. */
362
- export function openCoordinator(options, runtime) {
153
+ export function openCoordinator(options, runtime, ownerContext) {
363
154
  const observation = openObservationKernel(options.store);
155
+ const sessionObservation = openSessionObservation(options, runtime, observation, ownerContext);
364
156
  /** One Session handle; `meta` is the host metadata its prompts carry. */
365
157
  const sessionFor = (sessionId, meta) => {
366
158
  function command(input) {
@@ -407,7 +199,7 @@ export function openCoordinator(options, runtime) {
407
199
  extension: (extensionId) => Object.freeze({
408
200
  call: (command, input, caller) => runtime.assertOpen.pipe(Effect.andThen(runtime.extensions.call({ scope: "session", sessionId }, extensionId, command, input, caller))),
409
201
  }),
410
- read: (input) => runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, sessionId, input))),
202
+ read: (input) => runtime.assertOpen.pipe(Effect.andThen(sessionObservation.read(sessionId, input))),
411
203
  readSettlement: (commandId, timeoutMs = 0) => Effect.scoped(Effect.gen(function* () {
412
204
  yield* runtime.assertOpen;
413
205
  const decoded = yield* Effect.try({
@@ -437,8 +229,8 @@ export function openCoordinator(options, runtime) {
437
229
  }
438
230
  }).pipe(Effect.timeoutOption(decoded.timeoutMs), Effect.map(Option.getOrUndefined));
439
231
  })),
440
- subscribe: (input) => subscribeSession(options, runtime, observation, sessionId, input),
441
- watch: (input) => watchSession(options, runtime, observation, sessionId, input),
232
+ subscribe: (input) => sessionObservation.subscribe(sessionId, input),
233
+ watch: (input) => sessionObservation.watch(sessionId, input),
442
234
  });
443
235
  };
444
236
  const agent = Object.freeze({
@@ -450,7 +242,7 @@ export function openCoordinator(options, runtime) {
450
242
  listSessions: (after) => runtime.assertOpen.pipe(Effect.andThen(readSessionInbox(options.store, after))),
451
243
  extensions: agentExtensions(runtime),
452
244
  session: (sessionId) => sessionFor(sessionId, undefined),
453
- close: runtime.beginClose.pipe(Effect.andThen(observation.close)),
245
+ close: runtime.beginClose.pipe(Effect.andThen(sessionObservation.close), Effect.andThen(observation.close)),
454
246
  });
455
247
  return Object.freeze({
456
248
  ...agent,
@@ -1,6 +1,25 @@
1
1
  import * as Effect from "effect/Effect";
2
+ import type { SessionFrame } from "../client.js";
2
3
  import { ShiitakeError } from "../errors.js";
3
4
  import type { SqliteOwner } from "./contracts.js";
5
+ /** The feed shares one frame's JSON and byte accounting across its watchers. */
6
+ export declare function encodeSessionFrame(frame: SessionFrame): Readonly<{
7
+ data: string;
8
+ bytes: number;
9
+ }>;
10
+ /** One watcher has its own bounded backlog, not its own projection producer. */
11
+ export type SessionFrameQueue = Readonly<{
12
+ take: Effect.Effect<SessionFrame | "reset" | "closed", ShiitakeError>;
13
+ /** Record the join before reading; retain later frames for cursor deduplication. */
14
+ checkpoint: (revision?: number) => void;
15
+ close: () => void;
16
+ }>;
17
+ /** Bounded dropping fan-out; Woodstock, never this queue, owns replay. */
18
+ export declare function openSessionFrameFeed(onIdle: () => void): {
19
+ register(includeSandboxFailures?: boolean): SessionFrameQueue;
20
+ publish(frame: SessionFrame, revision?: number): void;
21
+ end(error?: ShiitakeError): void;
22
+ };
4
23
  /** Finite process-local signal; durable owner state remains authoritative. */
5
24
  export type ObservationSignal = "closed" | "reset" | "durable" | "activity" | "progress";
6
25
  export type ObservationReason = "durable" | "activity" | "progress" | "settlement";
@@ -8,6 +27,8 @@ export type ObservationReason = "durable" | "activity" | "progress" | "settlemen
8
27
  export declare function hasOwnerObservers(store: SqliteOwner, sessionId: string): boolean;
9
28
  /** One scoped process-local observation registration. */
10
29
  export type ObservationRegistration = Readonly<{
30
+ /** Owner-hint revision for the snapshot/live join, never a wire cursor. */
31
+ revision: number;
11
32
  /** Inspect and consume one pending finite signal without waiting. */
12
33
  pollSignal: Effect.Effect<ObservationSignal | undefined>;
13
34
  /** Await and consume one finite observation signal. */
@@ -1,8 +1,128 @@
1
1
  import * as Effect from "effect/Effect";
2
+ import * as Pull from "effect/Pull";
3
+ import * as Queue from "effect/Queue";
2
4
  import { ShiitakeError } from "../errors.js";
3
5
  import { reportCatchFailure } from "./failure.js";
4
6
  const MAX_OBSERVATIONS = 64;
5
7
  const MAX_PENDING_HINTS = 256;
8
+ const encodedFrames = new WeakMap();
9
+ const frameEncoder = new TextEncoder();
10
+ /** The feed shares one frame's JSON and byte accounting across its watchers. */
11
+ export function encodeSessionFrame(frame) {
12
+ const cached = encodedFrames.get(frame);
13
+ if (cached !== undefined)
14
+ return cached;
15
+ const data = JSON.stringify(frame);
16
+ const encoded = Object.freeze({
17
+ data,
18
+ bytes: frameEncoder.encode(data).byteLength,
19
+ });
20
+ encodedFrames.set(frame, encoded);
21
+ return encoded;
22
+ }
23
+ function withoutSandboxFailures(frame) {
24
+ if (frame.kind === "update" && frame.sandboxFailures !== undefined) {
25
+ const value = { ...frame };
26
+ delete value.sandboxFailures;
27
+ return value;
28
+ }
29
+ if (frame.kind === "reset" && frame.baseline.sandboxFailures !== undefined) {
30
+ const baseline = { ...frame.baseline };
31
+ delete baseline.sandboxFailures;
32
+ return { ...frame, baseline };
33
+ }
34
+ return frame;
35
+ }
36
+ /** Bounded dropping fan-out; Woodstock, never this queue, owns replay. */
37
+ export function openSessionFrameFeed(onIdle) {
38
+ const listeners = new Set();
39
+ let ended = false;
40
+ let failure;
41
+ const clear = (listener) => {
42
+ Effect.runSync(Queue.clear(listener.frames));
43
+ listener.bytes = 0;
44
+ };
45
+ return {
46
+ register(includeSandboxFailures = false) {
47
+ const listener = {
48
+ frames: Effect.runSync(Queue.dropping(256)),
49
+ bytes: 0,
50
+ lagged: false,
51
+ closed: false,
52
+ observed: -1,
53
+ includeSandboxFailures,
54
+ };
55
+ listeners.add(listener);
56
+ const take = Effect.suspend(() => {
57
+ if (listener.closed)
58
+ return Effect.succeed("closed");
59
+ if (failure !== undefined)
60
+ return Effect.fail(failure);
61
+ if (listener.lagged)
62
+ return Effect.succeed("reset");
63
+ if (ended)
64
+ return Effect.succeed("closed");
65
+ return Queue.take(listener.frames).pipe(Pull.catchDone(() => Effect.succeed("closed")), Effect.flatMap((frame) => {
66
+ if (failure !== undefined)
67
+ return Effect.fail(failure);
68
+ if (frame !== "reset" && frame !== "closed")
69
+ listener.bytes -= encodeSessionFrame(frame).bytes;
70
+ return Effect.succeed(frame);
71
+ }));
72
+ });
73
+ return Object.freeze({
74
+ take,
75
+ checkpoint: (revision = -1) => {
76
+ clear(listener);
77
+ listener.lagged = false;
78
+ listener.observed = revision;
79
+ },
80
+ close: () => {
81
+ if (listener.closed)
82
+ return;
83
+ listener.closed = true;
84
+ clear(listener);
85
+ listeners.delete(listener);
86
+ Queue.endUnsafe(listener.frames);
87
+ if (listeners.size === 0)
88
+ onIdle();
89
+ },
90
+ });
91
+ },
92
+ publish(frame, revision = Infinity) {
93
+ if (ended)
94
+ return;
95
+ const publicFrame = withoutSandboxFailures(frame);
96
+ for (const listener of listeners) {
97
+ if (listener.closed || listener.lagged || revision <= listener.observed)
98
+ continue;
99
+ const value = listener.includeSandboxFailures ? frame : publicFrame;
100
+ const { bytes } = encodeSessionFrame(value);
101
+ if (listener.bytes + bytes > 1_048_576 ||
102
+ !Queue.offerUnsafe(listener.frames, value)) {
103
+ clear(listener);
104
+ // Keep exactly one reset obligation; no incoming backlog can grow it.
105
+ listener.lagged = true;
106
+ Queue.offerUnsafe(listener.frames, "reset");
107
+ }
108
+ else {
109
+ listener.bytes += bytes;
110
+ }
111
+ }
112
+ },
113
+ end(error) {
114
+ if (ended)
115
+ return;
116
+ ended = true;
117
+ failure = error;
118
+ for (const listener of listeners) {
119
+ clear(listener);
120
+ listener.lagged = false;
121
+ Queue.endUnsafe(listener.frames);
122
+ }
123
+ },
124
+ };
125
+ }
6
126
  const ownerPublishers = new WeakMap();
7
127
  /** Actual live-view demand, excluding exact-command settlement waiters. */
8
128
  export function hasOwnerObservers(store, sessionId) {
@@ -39,7 +159,10 @@ function queueObservation(listener, reason) {
39
159
  : "progress";
40
160
  }
41
161
  function deliverObservation(listener, reason) {
42
- if (listener.closed || listener.reset)
162
+ if (listener.closed)
163
+ return;
164
+ listener.revision += 1;
165
+ if (listener.reset)
43
166
  return;
44
167
  const wake = listener.wake;
45
168
  if (wake === undefined) {
@@ -169,6 +292,7 @@ export function openObservationKernel(store) {
169
292
  if (active.size >= MAX_OBSERVATIONS)
170
293
  return Effect.fail(new ShiitakeError("runtime", "rejected", "Observation capacity is exhausted"));
171
294
  const listener = {
295
+ revision: 0,
172
296
  sessionId,
173
297
  settlementOnly: interest === "settlement",
174
298
  closed: false,
@@ -199,6 +323,9 @@ export function openObservationKernel(store) {
199
323
  });
200
324
  });
201
325
  return Effect.succeed(Object.freeze({
326
+ get revision() {
327
+ return listener.revision;
328
+ },
202
329
  pollSignal,
203
330
  awaitSignal,
204
331
  close: () => {