@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.
@@ -12,7 +12,8 @@ export function openRuntimeCore(options, mailRoom) {
12
12
  const coreDigest = yield* coreProtocolDigest([]);
13
13
  yield* prepareWoodstockSchema(options.store);
14
14
  const runtime = yield* openRuntimeCoordinator(options, coreDigest, admitted, mailRoom);
15
- const coordinator = openCoordinator(options, runtime);
15
+ const ownerContext = yield* Effect.context();
16
+ const coordinator = openCoordinator(options, runtime, ownerContext);
16
17
  const agent = Object.freeze({
17
18
  session: coordinator.session,
18
19
  createSession: coordinator.createSession,
@@ -7,7 +7,7 @@ import { ShiitakeError } from "../errors.js";
7
7
  import { ExtensionId } from "../extension-contract.js";
8
8
  import { coreConsumer, reportConsumer, } from "./extension-runtime.js";
9
9
  import { publishOwnerObservation } from "./observation-kernel.js";
10
- import { advanceCursor, readCursor, readExtensionAdmissionOrigin, readSettledRunFacts, readSettlementContent, readSettlementDiagnostic, readSessionDelegator, readSettlementMeta, readWaitingOn, readWaitingOnSender, recordWorkSettlement, sessionLineageRefused, settleControls, } from "./woodstock/index.js";
10
+ import { advanceCursor, readCursor, readExtensionAdmissionOrigin, readSettledRunFacts, readSettlementContent, readSettlementStretchContent, readSettlementDiagnostic, readSessionDelegator, readSettlementMeta, readWaitingOn, readWaitingOnSender, recordWorkSettlement, sessionLineageRefused, settleControls, } from "./woodstock/index.js";
11
11
  const pageSize = 64;
12
12
  function runtimeFailure(message) {
13
13
  return new ShiitakeError("runtime", "rejected", message);
@@ -100,7 +100,10 @@ function reportToDelegator(store, watchdog, mailRoom, job) {
100
100
  catch: () => runtimeFailure("Session attention is unavailable"),
101
101
  });
102
102
  const settled = yield* settlementFacts(store, job, waiting);
103
- yield* fanOut(mailRoom, { ...settled, delegator });
103
+ const content = job.outcome === "cancelled"
104
+ ? ""
105
+ : yield* readSettlementStretchContent(store, payload.work);
106
+ yield* fanOut(mailRoom, { ...settled, content, delegator });
104
107
  });
105
108
  }
106
109
  /**
@@ -0,0 +1,37 @@
1
+ import * as Effect from "effect/Effect";
2
+ import type * as Context from "effect/Context";
3
+ import * as Stream from "effect/Stream";
4
+ import type { SessionContext, SessionFrame, SessionSnapshot, SubscribeOptions, WatchOptions } from "../client.js";
5
+ import { ShiitakeError } from "../errors.js";
6
+ import type { ShiitakeEffectOptions } from "./effect-contracts.js";
7
+ import { type ObservationKernel } from "./observation-kernel.js";
8
+ import type { RuntimeCoordinator } from "./runtime-coordinator.js";
9
+ import { readSession } from "./woodstock/index.js";
10
+ /** Shared live observation; Woodstock remains the bounded baseline and replay owner. */
11
+ export declare function openSessionObservation(options: ShiitakeEffectOptions, runtime: RuntimeCoordinator, observation: ObservationKernel, ownerContext: Context.Context<never>): Readonly<{
12
+ read: (sessionId: string, input: Parameters<typeof readSession>[3]) => Effect.Effect<Readonly<{
13
+ sessionId: string;
14
+ cursor: string | null;
15
+ hasMore: boolean;
16
+ oldestCursor?: string | null;
17
+ messages: readonly import("../client.js").SessionMessage[];
18
+ terminals: readonly import("../client.js").SessionTerminal[];
19
+ compactions?: readonly import("../client.js").SessionCompactionMarker[];
20
+ compactionNotices?: readonly import("../client.js").SessionCompactionNotice[];
21
+ sandboxFailures?: readonly import("@fungi.computer/mycelium").SandboxFailureEvent[];
22
+ pending: import("../client.js").SessionPendingPrompts;
23
+ activity: import("../client.js").SessionActivity;
24
+ progress: import("../client.js").SessionProgress | null;
25
+ toolOutput: readonly import("../client.js").SessionToolOutput[];
26
+ extensions: import("../client.js").SessionExtensionSlots;
27
+ attention: readonly import("../session-attention.js").SessionAttention[];
28
+ context?: SessionContext;
29
+ }>, ShiitakeError, never>;
30
+ subscribe: (sessionId: string, input: SubscribeOptions) => Stream.Stream<SessionFrame, ShiitakeError, never>;
31
+ watch: (sessionId: string, input: WatchOptions | undefined) => Effect.Effect<Readonly<{
32
+ snapshot: SessionSnapshot;
33
+ changes: Stream.Stream<SessionFrame, ShiitakeError>;
34
+ close: Effect.Effect<void, ShiitakeError>;
35
+ }>, ShiitakeError, never>;
36
+ close: Effect.Effect<void, never, never>;
37
+ }>;
@@ -0,0 +1,440 @@
1
+ import * as Effect from "effect/Effect";
2
+ import * as Option from "effect/Option";
3
+ import * as Stream from "effect/Stream";
4
+ import * as Semaphore from "effect/Semaphore";
5
+ import * as Scope from "effect/Scope";
6
+ import * as Exit from "effect/Exit";
7
+ import { ShiitakeError } from "../errors.js";
8
+ import { decodeClientReadOptions, decodeClientSessionId, decodeClientSubscribeOptions, } from "./client-boundary.js";
9
+ import { mergeObservationSignals, openSessionFrameFeed, } from "./observation-kernel.js";
10
+ import { readSessionActivity, } from "./session-status.js";
11
+ import { prepareSessionContext } from "./session-context.js";
12
+ import { compactionEntrySequence, decodeWoodstockCursor, progressPosition, readProcessProgressDelta, readSession, readSessionReplayPage, } from "./woodstock/index.js";
13
+ import { sessionHeartbeatIntervalMs } from "../session-liveness.js";
14
+ function currentSessionWork(runtime, sessionId) {
15
+ return runtime
16
+ .readQueueHead(sessionId)
17
+ .pipe(Effect.flatMap((head) => (head.state === "queued"
18
+ ? runtime.runningWork(head.jobId)
19
+ : Effect.succeed(null)).pipe(Effect.map((runningWork) => ({ head, runningWork })))));
20
+ }
21
+ function currentSessionActivity(options, runtime, sessionId) {
22
+ return currentSessionWork(runtime, sessionId).pipe(Effect.map(({ head, runningWork }) => readSessionActivity(options.store, sessionId, head, runningWork)));
23
+ }
24
+ /** Carry the Session's context use on a baseline, when it has one. */
25
+ function withContext(snapshot, context) {
26
+ return context === undefined
27
+ ? snapshot
28
+ : Object.freeze({ ...snapshot, context });
29
+ }
30
+ /** Read the baseline and its context use for one Session. */
31
+ function readSessionWithContext(options, runtime, sessionId, input, checkpoint = {}) {
32
+ return currentSessionWork(runtime, sessionId).pipe(Effect.flatMap(({ head, runningWork }) => {
33
+ checkpoint.beforeRead?.();
34
+ return readSession(options.store, sessionId, () => readSessionActivity(options.store, sessionId, head, runningWork), input, () => runtime.extensions.project(sessionId), (snapshot) => ({
35
+ snapshot,
36
+ context: prepareSessionContext(options, sessionId, snapshot.cursor, input?.before === undefined),
37
+ }));
38
+ }), Effect.flatMap(({ snapshot, context }) => {
39
+ checkpoint.afterRead?.();
40
+ return context.pipe(Effect.map((value) => withContext(snapshot, value)));
41
+ }));
42
+ }
43
+ function decodeSubscriptionInput(sessionId, input) {
44
+ return Effect.try({
45
+ try: () => Object.freeze({
46
+ sessionId: decodeClientSessionId(sessionId),
47
+ options: decodeClientSubscribeOptions(input),
48
+ }),
49
+ catch: (error) => error instanceof ShiitakeError
50
+ ? error
51
+ : new ShiitakeError("contract", "invalid", "Subscribe options are invalid"),
52
+ });
53
+ }
54
+ function resetFrame(options, runtime, sessionId, includeSandboxFailures = false, checkpoint = {}) {
55
+ return runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, sessionId, { includeSandboxFailures }, checkpoint)), Effect.map((baseline) => Object.freeze({
56
+ kind: "reset",
57
+ reason: "history_unavailable",
58
+ cursor: baseline.cursor,
59
+ baseline,
60
+ })));
61
+ }
62
+ function continueFromReset(frame) {
63
+ return [
64
+ [frame],
65
+ Option.some({
66
+ after: frame.cursor ?? undefined,
67
+ delivery: frame.baseline.hasMore ? "replay" : "live",
68
+ awaitSignal: !frame.baseline.hasMore,
69
+ }),
70
+ ];
71
+ }
72
+ function subscriptionFrames(options, runtime, sessionId, after, source, includeSandboxFailures = false) {
73
+ const registration = source.kind === "live" ? source.registration : undefined;
74
+ let position = source.kind === "live" ? progressPosition(source.progress) : null;
75
+ let toolOutput = [];
76
+ const reset = () => resetFrame(options, runtime, sessionId, includeSandboxFailures, source.kind === "replay"
77
+ ? { beforeRead: source.checkpoint }
78
+ : { afterRead: source.stamp }).pipe(Effect.map((frame) => {
79
+ position = progressPosition(frame.baseline.progress);
80
+ toolOutput = frame.baseline.toolOutput;
81
+ return source.kind === "replay"
82
+ ? [[frame], Option.none()]
83
+ : continueFromReset(frame);
84
+ }));
85
+ const liveFrames = (activity) => {
86
+ const queue = activity.facts.queue;
87
+ const delta = queue.state !== "running"
88
+ ? null
89
+ : readProcessProgressDelta(options.store, sessionId, queue.commandId, position);
90
+ if (delta === undefined)
91
+ return undefined;
92
+ const frames = [];
93
+ if (delta !== null) {
94
+ frames.push({
95
+ kind: "progress",
96
+ progress: delta,
97
+ activity: activity.facts,
98
+ });
99
+ position = {
100
+ messageId: delta.messageId,
101
+ revision: delta.revision,
102
+ text: delta.text.offset + delta.text.append.length,
103
+ thinking: delta.thinking.offset + delta.thinking.append.length,
104
+ };
105
+ }
106
+ if (toolOutput.length !== activity.toolOutput.length ||
107
+ toolOutput.some((slot, index) => slot !== activity.toolOutput[index])) {
108
+ toolOutput = activity.toolOutput;
109
+ frames.push({
110
+ kind: "tool_output",
111
+ toolOutput,
112
+ activity: activity.facts,
113
+ });
114
+ }
115
+ return frames;
116
+ };
117
+ const initial = {
118
+ after,
119
+ delivery: source.kind,
120
+ // Live registration precedes its baseline; pending hints cover any join
121
+ // writes. Replaying it unconditionally adds an unprompted idle read.
122
+ awaitSignal: source.kind === "live",
123
+ };
124
+ return Stream.paginate(initial, (state) => (registration === undefined
125
+ ? Effect.succeed(undefined)
126
+ : state.awaitSignal
127
+ ? registration.awaitSignal.pipe(Effect.flatMap((signal) => registration.pollSignal.pipe(Effect.map((pending) => pending === undefined
128
+ ? signal
129
+ : mergeObservationSignals(signal, pending)))))
130
+ : registration.pollSignal).pipe(Effect.flatMap((signal) => {
131
+ if (signal === "closed")
132
+ return Effect.succeed([[], Option.none()]);
133
+ if (signal === "reset")
134
+ return reset();
135
+ return runtime.assertOpen.pipe(Effect.andThen(currentSessionActivity(options, runtime, sessionId)), Effect.flatMap((activity) => {
136
+ if (source.kind === "replay")
137
+ source.checkpoint();
138
+ registration?.setQueued(activity.facts.queue.state === "queued");
139
+ if ((signal === "progress" || signal === "activity") &&
140
+ state.delivery === "live") {
141
+ const frames = liveFrames(activity);
142
+ if (frames === undefined)
143
+ return reset();
144
+ // This path reads no durable page; stamp the live projection.
145
+ if (source.kind === "live")
146
+ source.stamp();
147
+ return Effect.succeed([
148
+ [
149
+ ...(signal === "activity"
150
+ ? [
151
+ {
152
+ kind: "activity",
153
+ activity: activity.facts,
154
+ },
155
+ ]
156
+ : []),
157
+ ...frames,
158
+ ],
159
+ Option.some({ ...state, awaitSignal: true }),
160
+ ]);
161
+ }
162
+ return readSessionReplayPage(options.store, sessionId, state.after, state.delivery, activity, () => runtime.extensions.project(sessionId), includeSandboxFailures, (page) => {
163
+ const latestCursor = page.kind === "page"
164
+ ? page.frames
165
+ .flatMap((frame) => frame.kind === "progress" ||
166
+ frame.kind === "activity" ||
167
+ frame.kind === "tool_output" ||
168
+ frame.kind === "heartbeat"
169
+ ? []
170
+ : [frame.cursor])
171
+ .at(-1)
172
+ : undefined;
173
+ // Capture the final moving page's stored context in the
174
+ // same read transaction as its cursor. Model description
175
+ // remains deferred until after that transaction.
176
+ const context = page.kind === "page" &&
177
+ !page.hasMore &&
178
+ page.frames.some((frame) => frame.kind === "update" &&
179
+ frame.cursor !== state.after)
180
+ ? prepareSessionContext(options, sessionId, latestCursor ?? null)
181
+ : Effect.succeed(undefined);
182
+ return { page, latestCursor, context };
183
+ }).pipe(Effect.flatMap(({ page, latestCursor, context, }) => {
184
+ if (page.kind === "reset")
185
+ return reset();
186
+ if (source.kind === "live")
187
+ source.stamp();
188
+ if (!page.hasMore)
189
+ toolOutput = activity.toolOutput;
190
+ const progressFrames = page.hasMore
191
+ ? []
192
+ : liveFrames(activity);
193
+ if (progressFrames === undefined)
194
+ return reset();
195
+ const next = {
196
+ after: latestCursor ?? state.after,
197
+ delivery: page.hasMore ? state.delivery : "live",
198
+ awaitSignal: !page.hasMore,
199
+ };
200
+ return context.pipe(Effect.map((context) => [
201
+ [
202
+ ...(context === undefined
203
+ ? page.frames
204
+ : page.frames.map((frame) => frame.kind === "update" &&
205
+ frame.cursor === latestCursor
206
+ ? Object.freeze({ ...frame, context })
207
+ : frame)),
208
+ ...progressFrames,
209
+ ],
210
+ source.kind === "replay" && !page.hasMore
211
+ ? Option.none()
212
+ : Option.some(next),
213
+ ]));
214
+ }));
215
+ }));
216
+ })));
217
+ }
218
+ /** One projection producer and heartbeat clock per watched Session. */
219
+ function openLiveSessionFeeds(options, runtime, observation, ownerContext) {
220
+ const lanes = new Map();
221
+ const scope = Scope.makeUnsafe();
222
+ let watchers = 0;
223
+ const acquire = (sessionId, includeSandboxFailures) => Effect.suspend(() => {
224
+ let lane = lanes.get(sessionId);
225
+ if (lane === undefined) {
226
+ lane = { lock: Semaphore.makeUnsafe(1), feed: undefined };
227
+ lanes.set(sessionId, lane);
228
+ }
229
+ const current = lane;
230
+ return current.lock.withPermit(Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
231
+ if (lanes.get(sessionId) !== current)
232
+ return yield* acquire(sessionId, includeSandboxFailures);
233
+ let feed = current.feed;
234
+ if (feed === undefined) {
235
+ const registration = yield* observation.register(sessionId).pipe(Effect.onError(() => Effect.sync(() => {
236
+ if (lanes.get(sessionId) === current)
237
+ lanes.delete(sessionId);
238
+ })));
239
+ const lifecycle = { stopped: false };
240
+ const frames = openSessionFrameFeed(() => {
241
+ stop();
242
+ });
243
+ const stop = () => {
244
+ if (lifecycle.stopped)
245
+ return;
246
+ lifecycle.stopped = true;
247
+ registration.close();
248
+ lifecycle.producer?.interruptUnsafe();
249
+ lifecycle.heartbeat?.interruptUnsafe();
250
+ frames.end();
251
+ current.feed = undefined;
252
+ if (lanes.get(sessionId) === current)
253
+ lanes.delete(sessionId);
254
+ };
255
+ feed = { registration, frames, stop };
256
+ current.feed = feed;
257
+ const baseline = yield* restore(readSessionWithContext(options, runtime, sessionId, {
258
+ includeSandboxFailures: true,
259
+ })).pipe(Effect.onError(() => Effect.sync(stop)));
260
+ registration.setQueued(baseline.activity.queue.state === "queued");
261
+ let revision = registration.revision;
262
+ // Replace the joining request's context while forking, so the
263
+ // long-lived children inherit only the Agent's services/span.
264
+ lifecycle.producer = yield* Effect.forkIn(subscriptionFrames(options, runtime, sessionId, baseline.cursor ?? undefined, {
265
+ kind: "live",
266
+ registration,
267
+ progress: baseline.progress,
268
+ stamp: () => {
269
+ revision = registration.revision;
270
+ },
271
+ }, true).pipe(Stream.runForEach((frame) => Effect.sync(() => {
272
+ frames.publish(frame, revision);
273
+ })), Effect.catch((error) => Effect.sync(() => {
274
+ frames.end(error);
275
+ })), Effect.ensuring(Effect.sync(stop))), scope, { uninterruptible: false }).pipe(Effect.setContext(ownerContext));
276
+ if (!lifecycle.stopped)
277
+ lifecycle.heartbeat = yield* Effect.forkIn(Stream.fromEffectRepeat(Effect.sleep(sessionHeartbeatIntervalMs).pipe(Effect.as({ kind: "heartbeat" }))).pipe(Stream.runForEach((frame) => Effect.sync(() => {
278
+ frames.publish(frame);
279
+ }))), scope, { uninterruptible: false }).pipe(Effect.setContext(ownerContext));
280
+ }
281
+ const queue = feed.frames.register(includeSandboxFailures);
282
+ return { feed, queue };
283
+ })));
284
+ });
285
+ return {
286
+ acquire: (sessionId, includeSandboxFailures) => Effect.uninterruptibleMask((restore) => Effect.suspend(() => {
287
+ if (watchers >= 64)
288
+ return Effect.fail(new ShiitakeError("runtime", "rejected", "Observation capacity is exhausted"));
289
+ watchers += 1;
290
+ let released = false;
291
+ const release = () => {
292
+ if (released)
293
+ return;
294
+ released = true;
295
+ watchers -= 1;
296
+ };
297
+ return restore(acquire(sessionId, includeSandboxFailures)).pipe(Effect.map(({ feed, queue }) => ({
298
+ feed,
299
+ queue: Object.freeze({
300
+ ...queue,
301
+ close: () => {
302
+ release();
303
+ queue.close();
304
+ },
305
+ }),
306
+ })), Effect.onError(() => Effect.sync(release)));
307
+ })),
308
+ close: Effect.sync(() => {
309
+ for (const lane of lanes.values())
310
+ lane.feed?.stop();
311
+ lanes.clear();
312
+ }).pipe(Effect.andThen(Scope.close(scope, Exit.void))),
313
+ };
314
+ }
315
+ function queuedSessionFrames(queue) {
316
+ return Stream.paginate(null, () => queue.take.pipe(Effect.map((frame) => frame === "closed"
317
+ ? [[], Option.none()]
318
+ : [[frame], Option.some(null)])));
319
+ }
320
+ /** Durable rows dedup by sequence; ephemeral appends dedup only by revision. */
321
+ function reconcileSessionFrames(source, initialCursor, initialProgress, reset) {
322
+ let cursor = initialCursor;
323
+ let position = progressPosition(initialProgress);
324
+ return source.pipe(Stream.mapEffect((incoming) => Effect.gen(function* () {
325
+ let frame = incoming === "reset" ? yield* reset() : incoming;
326
+ if (frame.kind === "heartbeat")
327
+ return [frame];
328
+ if (frame.kind === "progress") {
329
+ const previous = position?.messageId === frame.progress.messageId
330
+ ? position
331
+ : null;
332
+ if (previous !== null &&
333
+ previous.revision >= frame.progress.revision)
334
+ return [];
335
+ const text = previous?.text ?? 0;
336
+ const thinking = previous?.thinking ?? 0;
337
+ if (frame.progress.text.offset > text ||
338
+ frame.progress.thinking.offset > thinking)
339
+ frame = yield* reset();
340
+ else {
341
+ position = {
342
+ messageId: frame.progress.messageId,
343
+ revision: frame.progress.revision,
344
+ text: frame.progress.text.offset +
345
+ frame.progress.text.append.length,
346
+ thinking: frame.progress.thinking.offset +
347
+ frame.progress.thinking.append.length,
348
+ };
349
+ }
350
+ }
351
+ if (frame.kind === "reset") {
352
+ cursor = frame.cursor;
353
+ position = progressPosition(frame.baseline.progress);
354
+ return [frame];
355
+ }
356
+ if (frame.kind !== "update")
357
+ return [frame];
358
+ const previous = cursor === null ? undefined : decodeWoodstockCursor(cursor);
359
+ const next = frame.cursor === null
360
+ ? undefined
361
+ : decodeWoodstockCursor(frame.cursor);
362
+ if (previous !== undefined &&
363
+ next !== undefined &&
364
+ previous.generation !== next.generation) {
365
+ const baseline = yield* reset();
366
+ cursor = baseline.cursor;
367
+ position = progressPosition(baseline.baseline.progress);
368
+ return [baseline];
369
+ }
370
+ const sequence = previous?.sequence ?? 0;
371
+ const candidateCursor = next !== undefined && next.sequence > sequence
372
+ ? frame.cursor
373
+ : cursor;
374
+ const duplicate = (rows) => rows?.some((row) => row.sequence <= sequence) === true;
375
+ if (candidateCursor === frame.cursor &&
376
+ !duplicate(frame.messages) &&
377
+ !duplicate(frame.terminals) &&
378
+ !frame.compactions?.some((marker) => compactionEntrySequence(marker) <= sequence) &&
379
+ !duplicate(frame.compactionNotices)) {
380
+ cursor = frame.cursor;
381
+ return [frame];
382
+ }
383
+ const rows = {
384
+ ...frame,
385
+ cursor: candidateCursor,
386
+ messages: frame.messages.filter((row) => row.sequence > sequence),
387
+ terminals: frame.terminals.filter((row) => row.sequence > sequence),
388
+ };
389
+ if (frame.compactions !== undefined)
390
+ rows.compactions = frame.compactions.filter((marker) => compactionEntrySequence(marker) > sequence);
391
+ if (frame.compactionNotices !== undefined)
392
+ rows.compactionNotices = frame.compactionNotices.filter((row) => row.sequence > sequence);
393
+ cursor = rows.cursor;
394
+ return [rows];
395
+ })), Stream.flattenIterable);
396
+ }
397
+ function subscribeSession(options, runtime, feeds, sessionId, input) {
398
+ return Stream.unwrap(runtime.assertOpen.pipe(Effect.andThen(decodeSubscriptionInput(sessionId, input)), Effect.flatMap((decoded) => Effect.acquireRelease(feeds.acquire(decoded.sessionId, decoded.options.includeSandboxFailures === true), ({ queue }) => Effect.sync(queue.close)).pipe(Effect.map(({ feed, queue }) => {
399
+ const checkpoint = () => {
400
+ queue.checkpoint(feed.registration.revision);
401
+ };
402
+ return reconcileSessionFrames(subscriptionFrames(options, runtime, decoded.sessionId, decoded.options.after, { kind: "replay", checkpoint }, decoded.options.includeSandboxFailures === true).pipe(Stream.concat(queuedSessionFrames(queue))), decoded.options.after ?? null, null, () => resetFrame(options, runtime, decoded.sessionId, decoded.options.includeSandboxFailures === true, { beforeRead: checkpoint }));
403
+ })))));
404
+ }
405
+ function decodeWatchInput(sessionId, input) {
406
+ return Effect.try({
407
+ try: () => Object.freeze({
408
+ sessionId: decodeClientSessionId(sessionId),
409
+ options: decodeClientReadOptions(input),
410
+ }),
411
+ catch: (error) => error instanceof ShiitakeError
412
+ ? error
413
+ : new ShiitakeError("contract", "invalid", "Watch options are invalid"),
414
+ });
415
+ }
416
+ function watchSession(options, runtime, feeds, sessionId, input) {
417
+ return runtime.assertOpen.pipe(Effect.andThen(decodeWatchInput(sessionId, input)), Effect.flatMap((decoded) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
418
+ const { feed, queue } = yield* feeds.acquire(decoded.sessionId, decoded.options.includeSandboxFailures === true);
419
+ const checkpoint = () => {
420
+ queue.checkpoint(feed.registration.revision);
421
+ };
422
+ return yield* restore(runtime.assertOpen.pipe(Effect.andThen(readSessionWithContext(options, runtime, decoded.sessionId, decoded.options, { beforeRead: checkpoint })), Effect.map((snapshot) => {
423
+ return Object.freeze({
424
+ snapshot,
425
+ changes: reconcileSessionFrames(queuedSessionFrames(queue), snapshot.cursor, snapshot.progress, () => resetFrame(options, runtime, decoded.sessionId, decoded.options.includeSandboxFailures === true, { beforeRead: checkpoint })).pipe(Stream.ensuring(Effect.sync(queue.close))),
426
+ close: Effect.sync(queue.close),
427
+ });
428
+ }))).pipe(Effect.onError(() => Effect.sync(queue.close)));
429
+ }))));
430
+ }
431
+ /** Shared live observation; Woodstock remains the bounded baseline and replay owner. */
432
+ export function openSessionObservation(options, runtime, observation, ownerContext) {
433
+ const feeds = openLiveSessionFeeds(options, runtime, observation, ownerContext);
434
+ return Object.freeze({
435
+ read: (sessionId, input) => readSessionWithContext(options, runtime, sessionId, input),
436
+ subscribe: (sessionId, input) => subscribeSession(options, runtime, feeds, sessionId, input),
437
+ watch: (sessionId, input) => watchSession(options, runtime, feeds, sessionId, input),
438
+ close: feeds.close,
439
+ });
440
+ }
@@ -1,4 +1,4 @@
1
- import type { SessionFrame, SessionExtensionSlots, SessionPendingPrompts, SessionSnapshot } from "../../client.js";
1
+ import type { SessionFrame, SessionCompactionMarker, SessionExtensionSlots, SessionPendingPrompts, SessionSnapshot } from "../../client.js";
2
2
  import type { ClientReadOptions } from "../client-boundary.js";
3
3
  import type { CommandSettlement, SqliteOwner } from "../contracts.js";
4
4
  import type { ObservedActivity } from "../session-status.js";
@@ -8,6 +8,8 @@ import { type AdmissionIdentity, type StoredSessionRow } from "./session-store.j
8
8
  * and retry text can contain private provider bodies, not safe diagnostics.
9
9
  */
10
10
  export declare function diagnosticsByWorkId(path: readonly StoredSessionRow[]): ReadonlyMap<string, string>;
11
+ /** Native entry order, not the marker's display horizon; requires no SQL read. */
12
+ export declare function compactionEntrySequence(marker: SessionCompactionMarker): number;
11
13
  /** Same payload projection as the FIFO page, selected independently of that page's bounds. */
12
14
  export declare function readLatestPendingPrompt(owner: SqliteOwner, sessionId: string, identity?: AdmissionIdentity): SessionPendingPrompts["prompts"][number] | null;
13
15
  /**
@@ -150,6 +150,14 @@ function projectTerminal(row, { commandIds, diagnostics, cancellations }) {
150
150
  : undefined;
151
151
  return Object.freeze(meta === undefined ? terminal : { ...terminal, meta });
152
152
  }
153
+ const compactionEntrySequences = new WeakMap();
154
+ /** Native entry order, not the marker's display horizon; requires no SQL read. */
155
+ export function compactionEntrySequence(marker) {
156
+ const sequence = compactionEntrySequences.get(marker);
157
+ if (sequence === undefined)
158
+ throw unavailable("Session compaction entry position is unavailable");
159
+ return sequence;
160
+ }
153
161
  /**
154
162
  * Full-history client projections show every durable entry, including what a
155
163
  * compaction summarized away. Each compaction entry in `rows` contributes one
@@ -170,14 +178,16 @@ function compactionMarkersIn(owner, lineage, rows) {
170
178
  lineageEntrySequence(owner, lineage, entry.head);
171
179
  if (kept === undefined)
172
180
  throw unavailable("Session compaction horizon is unavailable");
173
- markers.push(Object.freeze({
181
+ const marker = Object.freeze({
174
182
  id: row.id,
175
183
  sequence: kept,
176
184
  at: row.timestamp,
177
185
  tokensBefore: entry.data.tokensBefore,
178
186
  tokensAfter: entry.data.tokensAfter ?? null,
179
187
  trigger: entry.data.reason ?? null,
180
- }));
188
+ });
189
+ compactionEntrySequences.set(marker, row.sequence);
190
+ markers.push(marker);
181
191
  }
182
192
  return Object.freeze(markers);
183
193
  }
@@ -12,7 +12,8 @@ import { type ClientReplayPage } from "./client-projection.js";
12
12
  import { type LiveRetryEvent } from "./session-entry.js";
13
13
  import { type CompactionNoticeInput, type AcceptedCommand, type EnqueueAcceptedWork, type AppendCompactionInput, type PreparedSessionRun, type RunContract, type RecordAcceptedCancellation, type SessionQueueHead, type StoredSessionRow } from "./session-store.js";
14
14
  export * from "./extension-store.js";
15
- export { readLatestPendingPrompt } from "./client-projection.js";
15
+ export { decodeWoodstockCursor } from "./cursor.js";
16
+ export { compactionEntrySequence, readLatestPendingPrompt, } from "./client-projection.js";
16
17
  export { createFleetDirectoryStore, createFleetDelegationStore, } from "./fleet-store.js";
17
18
  export * from "./session-attention.js";
18
19
  export * from "./work-senders.js";
@@ -101,6 +102,11 @@ export declare function readSettlementContent(owner: SqliteOwner, input: Readonl
101
102
  sessionId: string;
102
103
  commandFactId: string;
103
104
  }>): Effect.Effect<string, ShiitakeError>;
105
+ /** Last non-empty text in an extension stretch, including its initiating work. */
106
+ export declare function readSettlementStretchContent(owner: SqliteOwner, input: Readonly<{
107
+ sessionId: string;
108
+ commandFactId: string;
109
+ }>): Effect.Effect<string, ShiitakeError>;
104
110
  /**
105
111
  * The host metadata that settles with one accepted command: the
106
112
  * cancellation's when a host-attributed cancel ended it, otherwise the
@@ -12,7 +12,8 @@ import { appendSessionEntry, appendCompactionTransitionOwned, appendCompactionNo
12
12
  import { readSessionInboxPage, recordSessionInboxDeletion, } from "./session-inbox.js";
13
13
  import { readSessionAttention } from "./session-attention.js";
14
14
  export * from "./extension-store.js";
15
- export { readLatestPendingPrompt } from "./client-projection.js";
15
+ export { decodeWoodstockCursor } from "./cursor.js";
16
+ export { compactionEntrySequence, readLatestPendingPrompt, } from "./client-projection.js";
16
17
  export { createFleetDirectoryStore, createFleetDelegationStore, } from "./fleet-store.js";
17
18
  export * from "./session-attention.js";
18
19
  export * from "./work-senders.js";
@@ -155,8 +156,49 @@ export function readSettlementContent(owner, input) {
155
156
  const entries = readSessionEntriesForWork(owner, input.sessionId, admission.entry.workId);
156
157
  for (let index = entries.length - 1; index >= 0; index -= 1) {
157
158
  const entry = entries[index]?.entry;
158
- if (entry?.kind === "message" && entry.value.role === "assistant")
159
- return assistantText(entry.value);
159
+ if (entry?.kind === "message" && entry.value.role === "assistant") {
160
+ const text = assistantText(entry.value);
161
+ if (text !== "")
162
+ return text;
163
+ }
164
+ }
165
+ return "";
166
+ });
167
+ }
168
+ /** Last non-empty text in an extension stretch, including its initiating work. */
169
+ export function readSettlementStretchContent(owner, input) {
170
+ return Effect.gen(function* () {
171
+ let commandFactId = input.commandFactId;
172
+ while (commandFactId !== undefined) {
173
+ const content = yield* readSettlementContent(owner, {
174
+ sessionId: input.sessionId,
175
+ commandFactId,
176
+ });
177
+ if (content !== "")
178
+ return content;
179
+ const currentId = commandFactId;
180
+ commandFactId = yield* storeEffect(() => {
181
+ const admission = readSessionCommandAdmission(owner, input.sessionId, currentId);
182
+ if (admission === undefined)
183
+ throw rejected("Accepted Session command is unavailable");
184
+ if (readExtensionAdmissionOrigin(owner, input.sessionId, currentId, admission.entry.workId) === null)
185
+ return undefined;
186
+ let row = readSessionEntryById(owner, currentId);
187
+ if (row === undefined)
188
+ throw rejected("Accepted Session command entry is unavailable");
189
+ // Walk only this command's ancestry. Strictly decreasing sequences
190
+ // bound the walk and exclude sibling branches or newer queued work.
191
+ while (row.parentId !== null) {
192
+ const parent = readSessionEntryById(owner, row.parentId);
193
+ if (parent?.sessionId !== input.sessionId ||
194
+ parent.sequence >= row.sequence)
195
+ throw rejected("Session stretch ancestry is unavailable");
196
+ if (parent.entry.kind === "command")
197
+ return parent.id;
198
+ row = parent;
199
+ }
200
+ return undefined;
201
+ });
160
202
  }
161
203
  return "";
162
204
  });
@@ -447,7 +447,7 @@ export declare function decodeStoredSessionEntry(value: string): Result.Result<{
447
447
  readonly run: MuleIdentity;
448
448
  readonly turn: MuleIdentity;
449
449
  readonly message: MuleIdentity;
450
- readonly role: "user" | "assistant" | "system" | "toolResult";
450
+ readonly role: "toolResult" | "system" | "user" | "assistant";
451
451
  readonly workId: string;
452
452
  } | {
453
453
  readonly kind: "message";
@@ -533,8 +533,8 @@ export declare function decodeStoredSessionEntry(value: string): Result.Result<{
533
533
  readonly workId: string;
534
534
  readonly result: "failed";
535
535
  readonly trigger: "manual" | "threshold" | "overflow";
536
- readonly diagnostic?: string;
537
536
  readonly usage?: Usage;
537
+ readonly diagnostic?: string;
538
538
  } | {
539
539
  readonly kind: "compaction_notice";
540
540
  readonly workId: string;