@fungi.computer/shiitake 0.7.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.
Files changed (41) hide show
  1. package/README.md +28 -0
  2. package/dist/client-wire.js +10 -13
  3. package/dist/client.d.ts +6 -10
  4. package/dist/client.js +1 -0
  5. package/dist/delegation.d.ts +4 -4
  6. package/dist/fleet.d.ts +5 -1
  7. package/dist/fleet.js +5 -1
  8. package/dist/http-routes.d.ts +4 -11
  9. package/dist/http-routes.js +2 -1
  10. package/dist/internal/coordinator.d.ts +2 -1
  11. package/dist/internal/coordinator.js +10 -218
  12. package/dist/internal/observation-kernel.d.ts +21 -0
  13. package/dist/internal/observation-kernel.js +140 -4
  14. package/dist/internal/run-scope.d.ts +1 -0
  15. package/dist/internal/run-scope.js +5 -3
  16. package/dist/internal/runtime-coordinator.js +12 -7
  17. package/dist/internal/runtime-core.js +2 -1
  18. package/dist/internal/runtime-reconciliation.js +5 -2
  19. package/dist/internal/session-observation.d.ts +37 -0
  20. package/dist/internal/session-observation.js +440 -0
  21. package/dist/internal/session-status.d.ts +2 -0
  22. package/dist/internal/session-status.js +17 -1
  23. package/dist/internal/woodstock/client-projection.d.ts +3 -1
  24. package/dist/internal/woodstock/client-projection.js +12 -2
  25. package/dist/internal/woodstock/index.d.ts +7 -1
  26. package/dist/internal/woodstock/index.js +47 -4
  27. package/dist/internal/woodstock/progress.d.ts +2 -0
  28. package/dist/internal/woodstock/progress.js +5 -0
  29. package/dist/internal/woodstock/session-entry.d.ts +2 -2
  30. package/dist/remote-client.js +5 -1
  31. package/dist/server-visitor-stream.js +11 -1
  32. package/dist/session-liveness.d.ts +4 -0
  33. package/dist/session-liveness.js +4 -0
  34. package/dist/testing.d.ts +2 -0
  35. package/dist/testing.js +2 -0
  36. package/docs/api/agent-control.md +2 -2
  37. package/docs/api/client.md +28 -14
  38. package/docs/api/delegation.md +2 -2
  39. package/docs/api/fleet.md +33 -4
  40. package/docs/api/testing.md +11 -0
  41. package/package.json +5 -5
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
@@ -28,19 +28,15 @@ const SessionActivitySchema = Schema.Struct({
28
28
  Schema.Struct({
29
29
  state: Schema.Literal("queued"),
30
30
  commandId: Schema.NonEmptyString,
31
- runningWork: Schema.NullOr(Schema.Union([
32
- Schema.Struct({
33
- kind: Schema.Literal("session"),
34
- sessionId: Schema.NonEmptyString,
35
- commandId: Schema.NonEmptyString,
36
- title: Schema.String,
37
- }),
38
- Schema.Struct({
39
- kind: Schema.Literal("host"),
40
- lane: Schema.NonEmptyString,
41
- jobId: Schema.NonEmptyString,
42
- }),
43
- ])),
31
+ runningWork: Schema.NullOr(Schema.Struct({
32
+ phase: Schema.Literals([
33
+ "thinking",
34
+ "streaming",
35
+ "running_tool",
36
+ "waiting_on_you",
37
+ "cancelling",
38
+ ]),
39
+ })),
44
40
  }),
45
41
  Schema.Struct({
46
42
  state: Schema.Literal("running"),
@@ -168,6 +164,7 @@ export const SessionSnapshotSchema = Schema.Struct({
168
164
  context: Schema.optionalKey(SessionContextSchema),
169
165
  });
170
166
  export const SessionFrameSchema = Schema.Union([
167
+ Schema.Struct({ kind: Schema.Literal("heartbeat") }),
171
168
  Schema.Struct({
172
169
  kind: Schema.Literal("activity"),
173
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";
@@ -132,17 +133,9 @@ export type SessionTerminal = Readonly<{
132
133
  */
133
134
  meta?: Json;
134
135
  }>;
135
- /** Blocking executor identity and Session display metadata, never messages or provider data. */
136
+ /** The occupying work's current phase; never its identity or transcript. */
136
137
  export type AgentRunningWork = Readonly<{
137
- kind: "session";
138
- sessionId: string;
139
- commandId: string;
140
- /** Owner-issued display title; empty when no title is known. */
141
- title: string;
142
- }> | Readonly<{
143
- kind: "host";
144
- lane: string;
145
- jobId: string;
138
+ phase: "thinking" | "streaming" | "running_tool" | "waiting_on_you" | "cancelling";
146
139
  }>;
147
140
  /**
148
141
  * Raw facts about what a Session is doing now. Shiitake publishes facts only;
@@ -350,6 +343,9 @@ export type SessionSnapshot = Readonly<{
350
343
  }>;
351
344
  /** One bounded replay or live frame. */
352
345
  export type SessionFrame = Readonly<{
346
+ /** Process-local liveness only: never a durable cursor or activity fact. */
347
+ kind: "heartbeat";
348
+ }> | Readonly<{
353
349
  /** Process-local activity change for the Session's current queue head. */
354
350
  kind: "activity";
355
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";
package/dist/fleet.d.ts CHANGED
@@ -272,7 +272,11 @@ export declare function delegationSendMeta(sender: Readonly<{
272
272
  * while the verified reply retains the failed recipient's identity.
273
273
  */
274
274
  export declare function delegationReplyMeta(delegation: AuthenticatedDelegationReply, replierName: string): DelegationReplyMeta;
275
- /** An original one-way report can only name an ancestor of its Agent sender. */
275
+ /**
276
+ * Shape check for a one-way ancestor notice, not sender provenance. The Local
277
+ * mail receiver checks mesh membership before calling it. This addressed mail
278
+ * is still work and may steer the parent's running turn when admitted.
279
+ */
276
280
  export declare function isOneWayAncestorNotice(mail: BirdDogMail): boolean;
277
281
  /**
278
282
  * Delegated work an owner still waits on; `unreadable` says why its state
package/dist/fleet.js CHANGED
@@ -361,7 +361,11 @@ export function delegationReplyMeta(delegation, replierName) {
361
361
  const delegationMetadata = z.object({
362
362
  delegation: FungiMessageMetaSchema.shape.delegation,
363
363
  });
364
- /** An original one-way report can only name an ancestor of its Agent sender. */
364
+ /**
365
+ * Shape check for a one-way ancestor notice, not sender provenance. The Local
366
+ * mail receiver checks mesh membership before calling it. This addressed mail
367
+ * is still work and may steer the parent's running turn when admitted.
368
+ */
365
369
  export function isOneWayAncestorNotice(mail) {
366
370
  if (mail.reply !== undefined ||
367
371
  mail.replyTo.length !== 0 ||
@@ -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
  }[];
@@ -818,14 +818,7 @@ export declare function createShiitakeHttpRoutes(resolveSession: ShiitakeSession
818
818
  readonly state: "queued";
819
819
  readonly commandId: string;
820
820
  readonly runningWork: {
821
- readonly kind: "session";
822
- readonly sessionId: string;
823
- readonly commandId: string;
824
- readonly title: string;
825
- } | {
826
- readonly kind: "host";
827
- readonly lane: string;
828
- readonly jobId: string;
821
+ readonly phase: "thinking" | "streaming" | "running_tool" | "waiting_on_you" | "cancelling";
829
822
  } | null;
830
823
  } | {
831
824
  readonly state: "running";
@@ -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. */