@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
@@ -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
+ }
@@ -7,5 +7,7 @@ export type ObservedActivity = Readonly<{
7
7
  readProgress: () => SessionProgress | null;
8
8
  toolOutput: readonly SessionToolOutput[];
9
9
  }>;
10
+ /** Project only the occupying Session's activity, without disclosing its data. */
11
+ export declare function readSessionPhase(owner: SqliteOwner, sessionId: string, head: WatchdogQueueHead): AgentRunningWork["phase"];
10
12
  /** Join Watchdog authority with Woodstock facts and process-local progress. */
11
13
  export declare function readSessionActivity(owner: SqliteOwner, sessionId: string, head: WatchdogQueueHead, runningWork: AgentRunningWork | null): ObservedActivity;
@@ -1,4 +1,20 @@
1
- import { readProcessProgressForWork, readProcessToolOutputForWork, readProcessCompaction, readLatestLiveRetryForWork, readActiveToolForWork, readLastModelChoiceSequence, } from "./woodstock/index.js";
1
+ import { readProcessProgressForWork, readProcessToolOutputForWork, readProcessCompaction, readLatestLiveRetryForWork, readActiveToolForWork, readLastModelChoiceSequence, readSessionAttention, hasProcessTextForWork, } from "./woodstock/index.js";
2
+ /** Project only the occupying Session's activity, without disclosing its data. */
3
+ export function readSessionPhase(owner, sessionId, head) {
4
+ const { facts } = readSessionActivity(owner, sessionId, head, null);
5
+ if (facts.cancellationRequested)
6
+ return "cancelling";
7
+ if (readSessionAttention(owner, sessionId).some((item) => item.inbox !== false && item.waitingOn === undefined))
8
+ return "waiting_on_you";
9
+ if (facts.compaction !== null || facts.retry !== null)
10
+ return "thinking";
11
+ if (facts.activeTool !== null)
12
+ return "running_tool";
13
+ if (head.state === "running" &&
14
+ hasProcessTextForWork(owner, sessionId, head.jobId))
15
+ return "streaming";
16
+ return "thinking";
17
+ }
2
18
  /** Join Watchdog authority with Woodstock facts and process-local progress. */
3
19
  export function readSessionActivity(owner, sessionId, head, runningWork) {
4
20
  const lastModelChoiceSequence = readLastModelChoiceSequence(owner, sessionId);
@@ -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
  });
@@ -533,10 +575,11 @@ export function recordLiveRetryEvent(owner, accepted, event, turn) {
533
575
  sameIdentity(current.event.run, event.run));
534
576
  }).pipe(Effect.mapError(listenerFailure));
535
577
  }
536
- const listenerFailure = () => ({
578
+ const listenerFailure = (cause) => ({
537
579
  _tag: "MuleError",
538
580
  phase: "listener",
539
581
  message: "Woodstock could not record the Mule event",
582
+ cause,
540
583
  });
541
584
  /**
542
585
  * Open the one scoped listener that projects Mule's exact event stream into
@@ -32,6 +32,8 @@ export declare function openToolProgress(owner: SqliteOwner, accepted: AcceptedC
32
32
  export declare function readProcessProgress(owner: SqliteOwner, sessionId: string): SessionProgress | null;
33
33
  /** Read progress only when it belongs to the current durable work head. */
34
34
  export declare function readProcessProgressForWork(owner: SqliteOwner, sessionId: string, workId: string): SessionProgress | null;
35
+ /** Read a phase fact without flattening another Session's private text tails. */
36
+ export declare function hasProcessTextForWork(owner: SqliteOwner, sessionId: string, workId: string): boolean;
35
37
  export type ProgressMessageStart = Extract<MuleEvent, {
36
38
  type: "message_start";
37
39
  }>;
@@ -189,6 +189,11 @@ export function readProcessProgressForWork(owner, sessionId, workId) {
189
189
  const current = progressFor(owner).get(sessionId);
190
190
  return snapshot(current?.workId === workId ? current.value : null);
191
191
  }
192
+ /** Read a phase fact without flattening another Session's private text tails. */
193
+ export function hasProcessTextForWork(owner, sessionId, workId) {
194
+ const current = progressByOwner.get(owner)?.get(sessionId);
195
+ return current?.workId === workId && (current.value?.text.length ?? 0) > 0;
196
+ }
192
197
  /**
193
198
  * Keep live model progress in the current process only.
194
199
  *
@@ -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;