@fungi.computer/shiitake 0.5.0 → 0.6.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 (65) hide show
  1. package/dist/agent-control.d.ts +93 -1
  2. package/dist/agent-control.js +108 -6
  3. package/dist/client-entry.d.ts +20 -0
  4. package/dist/client-entry.js +5 -0
  5. package/dist/client-visitor.d.ts +31 -0
  6. package/dist/client-visitor.js +226 -0
  7. package/dist/client-wire.d.ts +5 -0
  8. package/dist/client-wire.js +6 -0
  9. package/dist/client.d.ts +4 -3
  10. package/dist/client.js +1 -0
  11. package/dist/delegation.d.ts +74 -53
  12. package/dist/delegation.js +4 -15
  13. package/dist/effect.d.ts +25 -0
  14. package/dist/effect.js +14 -1
  15. package/dist/extensions.d.ts +21 -2
  16. package/dist/extensions.js +1 -1
  17. package/dist/fleet.d.ts +15 -0
  18. package/dist/fleet.js +32 -6
  19. package/dist/hosted.js +3 -0
  20. package/dist/http-routes.d.ts +11 -0
  21. package/dist/internal/accepted-run-executor.js +2 -1
  22. package/dist/internal/admission-door.d.ts +5 -0
  23. package/dist/internal/admission-door.js +27 -1
  24. package/dist/internal/contracts.d.ts +14 -1
  25. package/dist/internal/coordinator.js +8 -2
  26. package/dist/internal/effect-client.js +1 -1
  27. package/dist/internal/effect-contracts.d.ts +4 -2
  28. package/dist/internal/extension-command-registry.d.ts +2 -1
  29. package/dist/internal/extension-command-registry.js +3 -1
  30. package/dist/internal/extension-runtime.d.ts +3 -1
  31. package/dist/internal/extension-runtime.js +13 -5
  32. package/dist/internal/model-acquisition.js +2 -0
  33. package/dist/internal/observation-kernel.d.ts +2 -0
  34. package/dist/internal/observation-kernel.js +17 -6
  35. package/dist/internal/run-scope.d.ts +14 -0
  36. package/dist/internal/run-scope.js +32 -9
  37. package/dist/internal/runtime-coordinator.js +8 -1
  38. package/dist/internal/runtime-reconciliation.js +119 -24
  39. package/dist/internal/woodstock/agent-reply.d.ts +5 -0
  40. package/dist/internal/woodstock/agent-reply.js +3 -0
  41. package/dist/internal/woodstock/client-projection.js +9 -1
  42. package/dist/internal/woodstock/index.d.ts +4 -0
  43. package/dist/internal/woodstock/index.js +7 -0
  44. package/dist/internal/woodstock/session-delegator.d.ts +12 -0
  45. package/dist/internal/woodstock/session-delegator.js +44 -0
  46. package/dist/internal/woodstock/session-entry.d.ts +12 -2
  47. package/dist/internal/woodstock/session-store.d.ts +2 -0
  48. package/dist/internal/woodstock/session-store.js +20 -0
  49. package/dist/internal/woodstock/work-senders.d.ts +32 -0
  50. package/dist/internal/woodstock/work-senders.js +70 -0
  51. package/dist/remote-client.js +7 -2
  52. package/dist/server-visitor-stream.d.ts +13 -0
  53. package/dist/server-visitor-stream.js +115 -0
  54. package/dist/session-attention.d.ts +30 -0
  55. package/dist/session-attention.js +14 -0
  56. package/docs/api/agent-control.md +209 -13
  57. package/docs/api/client-entry.md +3 -0
  58. package/docs/api/client.md +136 -1
  59. package/docs/api/cloudflare.md +7 -1
  60. package/docs/api/delegation.md +234 -61
  61. package/docs/api/effect.md +16 -6
  62. package/docs/api/extensions.md +65 -4
  63. package/docs/api/fleet.md +69 -16
  64. package/docs/api/index.md +6 -4
  65. package/package.json +10 -6
@@ -5,21 +5,19 @@ import * as Schema from "effect/Schema";
5
5
  import { agentTurnRecovery } from "./accepted-run-executor.js";
6
6
  import { ShiitakeError } from "../errors.js";
7
7
  import { ExtensionId } from "../extension-contract.js";
8
- import { coreConsumer } from "./extension-runtime.js";
8
+ import { coreConsumer, reportConsumer, } from "./extension-runtime.js";
9
9
  import { publishOwnerObservation } from "./observation-kernel.js";
10
- import { advanceCursor, readCursor, readExtensionAdmissionOrigin, readSettledRunFacts, readSettlementContent, readSettlementDiagnostic, readSettlementMeta, recordWorkSettlement, sessionLineageRefused, settleControls, } from "./woodstock/index.js";
10
+ import { advanceCursor, readCursor, readExtensionAdmissionOrigin, readSettledRunFacts, readSettlementContent, 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);
14
14
  }
15
- function fanOutSettlement(store, mailRoom, job) {
16
- if (mailRoom === undefined)
17
- return Effect.void;
15
+ /** BirdDog's settlement facts for one settled job, without its correspondents. */
16
+ function settlementFacts(store, job,
17
+ /** The open items this settlement names as waiting on its recipient. */
18
+ waitingOnYou) {
18
19
  return Effect.gen(function* () {
19
- const payload = yield* Effect.try({
20
- try: () => decodeAgentJob(job),
21
- catch: () => runtimeFailure("Agent work payload is invalid"),
22
- });
20
+ const payload = yield* decodeJob(job);
23
21
  const content = job.outcome === "cancelled"
24
22
  ? ""
25
23
  : yield* readSettlementContent(store, payload.work);
@@ -27,14 +25,103 @@ function fanOutSettlement(store, mailRoom, job) {
27
25
  const diagnostic = job.outcome === "completed" || job.outcome === "cancelled"
28
26
  ? undefined
29
27
  : yield* readSettlementDiagnostic(store, payload.work);
30
- const settled = diagnostic === undefined
31
- ? { job, content }
32
- : { job, content, diagnostic };
33
- yield* mailRoom
34
- .fanOut(meta === undefined ? settled : { ...settled, meta })
35
- .pipe(Effect.mapError(() => runtimeFailure("Agent reply fan-out is unavailable")));
28
+ let settled = { job, content };
29
+ if (diagnostic !== undefined)
30
+ settled = { ...settled, diagnostic };
31
+ // A cancelled run names nothing; what it left open is the canceller's to see.
32
+ const waiting = job.outcome === "cancelled" ? [] : yield* waitingOnYou;
33
+ if (waiting.length > 0)
34
+ settled = { ...settled, waitingOnYou: waiting };
35
+ return meta === undefined ? settled : { ...settled, meta };
36
36
  });
37
37
  }
38
+ function decodeJob(job) {
39
+ return Effect.try({
40
+ try: () => decodeAgentJob(job),
41
+ catch: () => runtimeFailure("Agent work payload is invalid"),
42
+ });
43
+ }
44
+ function fanOut(mailRoom, settled) {
45
+ return mailRoom
46
+ .fanOut(settled)
47
+ .pipe(Effect.mapError(() => runtimeFailure("Agent reply fan-out is unavailable")));
48
+ }
49
+ /** Reply to the delegating mail that admitted this work, if it asked for one. */
50
+ function replyToMail(store, mailRoom, job) {
51
+ if (mailRoom === undefined)
52
+ return Effect.void;
53
+ return Effect.gen(function* () {
54
+ const payload = yield* decodeJob(job);
55
+ if ((payload.mail?.replyTo.length ?? 0) === 0)
56
+ return;
57
+ // The reply goes to the mail's sender: name what waits on that sender.
58
+ const waiting = Effect.try({
59
+ try: () => readWaitingOnSender(store, payload.work.sessionId, payload.work.commandFactId),
60
+ catch: () => runtimeFailure("Session attention is unavailable"),
61
+ });
62
+ yield* fanOut(mailRoom, yield* settlementFacts(store, job, waiting));
63
+ });
64
+ }
65
+ /**
66
+ * Report work no delegating mail answered to the Session's delegator of
67
+ * record. It runs after the extensions have seen the settlement, so a goal
68
+ * or loop that admitted the next turn keeps the Session busy and the stretch
69
+ * of turns reports once, when the Session finally rests. Another Agent's
70
+ * original mail is that Agent's correspondence and never reported here.
71
+ */
72
+ function reportToDelegator(store, watchdog, mailRoom, job) {
73
+ if (mailRoom === undefined)
74
+ return Effect.void;
75
+ return Effect.gen(function* () {
76
+ const payload = yield* decodeJob(job);
77
+ const mail = payload.mail;
78
+ if (mail !== undefined && mail.replyTo.length > 0)
79
+ return;
80
+ const head = yield* watchdog
81
+ .readQueueHead(job.queue)
82
+ .pipe(Effect.mapError(() => runtimeFailure("Session queue head is unavailable")));
83
+ if (head.state !== "idle")
84
+ return;
85
+ const delegator = yield* Effect.try({
86
+ try: () => readSessionDelegator(store, payload.work.sessionId),
87
+ catch: () => runtimeFailure("Session delegator is unavailable"),
88
+ });
89
+ if (delegator === undefined)
90
+ return;
91
+ const from = mail?.reply === undefined ? mail?.from : undefined;
92
+ const anotherAgentsMail = from !== undefined &&
93
+ !(from.kind === "agent" &&
94
+ delegator.replyTo.some(({ agentId }) => agentId === from.agentId));
95
+ if (anotherAgentsMail)
96
+ return;
97
+ // The report goes to the delegator: name what waits on it.
98
+ const waiting = Effect.try({
99
+ try: () => readWaitingOn(store, payload.work.sessionId, delegator.replyTo),
100
+ catch: () => runtimeFailure("Session attention is unavailable"),
101
+ });
102
+ const settled = yield* settlementFacts(store, job, waiting);
103
+ yield* fanOut(mailRoom, { ...settled, delegator });
104
+ });
105
+ }
106
+ /**
107
+ * The `core-report` settled consumer: after the extensions' turn, report each
108
+ * Session's last settled job in the page, one record at a time.
109
+ */
110
+ function reportSettled(store, watchdog, mailRoom, records) {
111
+ return Effect.forEach(records, (record, index) => Effect.gen(function* () {
112
+ const later = records
113
+ .slice(index + 1)
114
+ .some(({ job }) => job.queue === record.job.queue);
115
+ if (!later && !(yield* settledInRefusedSession(store, record.job)))
116
+ yield* reportToDelegator(store, watchdog, mailRoom, record.job);
117
+ yield* Effect.try({
118
+ try: () => {
119
+ advanceCursor(store, reportConsumer, record.sequence);
120
+ },
121
+ catch: () => runtimeFailure("Settled cursor is unavailable"),
122
+ });
123
+ }), { discard: true });
124
+ }
38
125
  function repairWorkSettlement(store, job) {
39
126
  const outcome = job.outcome;
40
127
  if (outcome !== "interrupted" &&
@@ -148,7 +235,7 @@ function settleCore(store, watchdog, mailRoom, records) {
148
235
  if (!(yield* settledInRefusedSession(store, record.job))) {
149
236
  yield* repairWorkSettlement(store, record.job);
150
237
  yield* settleTargetedControls(store, watchdog, record.job);
151
- yield* fanOutSettlement(store, mailRoom, record.job);
238
+ yield* replyToMail(store, mailRoom, record.job);
152
239
  }
153
240
  yield* Effect.try({
154
241
  try: () => {
@@ -172,26 +259,34 @@ export function reconcileRuntime(input) {
172
259
  try: () => readCursor(input.store, coreConsumer) ?? 0,
173
260
  catch: () => runtimeFailure("Settled cursor is unavailable"),
174
261
  });
262
+ const reportCursor = yield* Effect.try({
263
+ try: () => readCursor(input.store, reportConsumer) ?? coreCursor,
264
+ catch: () => runtimeFailure("Settled cursor is unavailable"),
265
+ });
175
266
  const consumers = input.extensions.settledConsumers();
176
- const after = Math.min(coreCursor, ...consumers.map(({ cursor }) => cursor));
267
+ const after = Math.min(coreCursor, reportCursor, ...consumers.map(({ cursor }) => cursor));
177
268
  const page = yield* input.watchdog.readSettled(pageSize, after);
178
269
  const core = yield* Effect.exit(settleCore(input.store, input.watchdog, input.mailRoom, page.filter(({ sequence }) => sequence > coreCursor)));
270
+ const processed = yield* Effect.try({
271
+ try: () => readCursor(input.store, coreConsumer) ?? 0,
272
+ catch: () => runtimeFailure("Settled cursor is unavailable"),
273
+ });
179
274
  const runs = yield* Effect.try({
180
- try: () => {
181
- const processed = readCursor(input.store, coreConsumer) ?? 0;
182
- return page
183
- .filter(({ sequence }) => sequence <= processed)
184
- .map((record) => settledRun(input.store, record));
185
- },
275
+ try: () => page
276
+ .filter(({ sequence }) => sequence <= processed)
277
+ .map((record) => settledRun(input.store, record)),
186
278
  catch: () => runtimeFailure("Settled run facts are unavailable"),
187
279
  });
188
- yield* input.extensions.deliverSettled(runs, readCursor(input.store, coreConsumer) ?? 0);
280
+ yield* input.extensions.deliverSettled(runs, processed);
281
+ const reports = yield* Effect.exit(reportSettled(input.store, input.watchdog, input.mailRoom, page.filter(({ sequence }) => sequence > reportCursor && sequence <= processed)));
189
282
  const moreTimers = yield* input.extensions.fireDueTimers(Date.now());
190
283
  if (page.length === pageSize || moreTimers)
191
284
  yield* input.requestWake;
192
285
  yield* drainMail(input.mailRoom);
193
286
  if (Exit.isFailure(core))
194
287
  return yield* Effect.failCause(core.cause);
288
+ if (Exit.isFailure(reports))
289
+ return yield* Effect.failCause(reports.cause);
195
290
  }).pipe(Effect.mapError((failure) => failure instanceof ShiitakeError
196
291
  ? failure
197
292
  : runtimeFailure("Runtime reconciliation failed")));
@@ -9,6 +9,11 @@ export declare const VerifiedAgentReplySchema: Schema.Union<readonly [Schema.Str
9
9
  readonly commandId: Schema.String;
10
10
  readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
11
11
  readonly diagnostic: Schema.optionalKey<Schema.String>;
12
+ readonly waitingOnYou: Schema.optionalKey<Schema.$Array<Schema.Struct<{
13
+ readonly itemId: Schema.String;
14
+ readonly kind: Schema.String;
15
+ readonly summary: Schema.String;
16
+ }>>>;
12
17
  readonly agentId: Schema.String;
13
18
  readonly sessionId: Schema.String;
14
19
  }>, Schema.Struct<{
@@ -42,6 +42,9 @@ function opening(reply) {
42
42
  attributes.push(["outcome", reply.outcome]);
43
43
  if (reply.outcome !== "undeliverable" && reply.diagnostic !== undefined)
44
44
  attributes.push(["diagnostic", reply.diagnostic]);
45
+ if (reply.outcome !== "undeliverable" && reply.waitingOnYou !== undefined)
46
+ // A host fact, outside the child's text: the items the child left waiting on the recipient.
47
+ attributes.push(["waiting_on_you", JSON.stringify(reply.waitingOnYou)]);
45
48
  if (reply.outcome === "undeliverable")
46
49
  attributes.push(["reason", reply.reason]);
47
50
  const rendered = attributes
@@ -1,9 +1,11 @@
1
1
  import { messageAuthor } from "@fungi.computer/agar/agents";
2
2
  import { readSessionAttention } from "./session-attention.js";
3
+ import { readWaitingOnSender } from "./work-senders.js";
4
+ import { readSessionDelegator } from "./session-delegator.js";
3
5
  import { ShiitakeError } from "../../errors.js";
4
6
  import { decodeWoodstockCursor, encodeWoodstockCursor } from "./cursor.js";
5
7
  import { operatorClientMessage, promptCommand } from "./session-entry.js";
6
- import { lineageEntrySequence, lineageHasSequence, lineageRowLimit, readLineageWindow, readLineageWorkFacts, readSessionEntriesForWork, readSessionLineage, readPendingSessionCommands, readSessionCommandAdmission, readNewestPendingSessionCommand, } from "./session-store.js";
8
+ import { lineageEntrySequence, lineageHasSequence, lineageRowLimit, readLineageWindow, readLineageWorkFacts, hasSessionWorkTerminal, readSessionEntriesForWork, readSessionLineage, readPendingSessionCommands, readSessionCommandAdmission, readNewestPendingSessionCommand, } from "./session-store.js";
7
9
  const maximumPagePayloadBytes = 2_097_152;
8
10
  const replayPageLimit = 200;
9
11
  const maximumRowsPerPage = lineageRowLimit;
@@ -503,6 +505,10 @@ export function projectCommandSettlement(owner, sessionId, commandId) {
503
505
  const admission = readSessionCommandAdmission(owner, sessionId, commandId);
504
506
  if (admission === undefined)
505
507
  throw unavailable("Accepted Session command is unavailable");
508
+ // A live work has no settlement to project. The owner's terminal index
509
+ // answers this without decoding the work again on every polling tick.
510
+ if (!hasSessionWorkTerminal(owner, sessionId, admission.entry.workId))
511
+ return undefined;
506
512
  const rows = readSessionEntriesForWork(owner, sessionId, admission.entry.workId);
507
513
  const work = workFacts(owner, sessionId, rows);
508
514
  const terminal = rows
@@ -521,6 +527,7 @@ export function projectCommandSettlement(owner, sessionId, commandId) {
521
527
  const sender = messageAuthor(admission.entry.meta);
522
528
  return {
523
529
  acceptedSender: sender?.kind === "agent" ? sender.id : null,
530
+ delegator: readSessionDelegator(owner, sessionId)?.replyTo[0]?.agentId ?? null,
524
531
  terminal: meta === undefined ? terminal : { ...terminal, meta },
525
532
  content: terminal.outcome === "cancelled"
526
533
  ? ""
@@ -534,5 +541,6 @@ export function projectCommandSettlement(owner, sessionId, commandId) {
534
541
  usage,
535
542
  })),
536
543
  toolCalls: new Set(rows.flatMap(({ entry }) => entry.kind === "tool_intent" ? [entry.call.id] : [])).size,
544
+ waitingOnYou: readWaitingOnSender(owner, sessionId, admission.entry.workId),
537
545
  };
538
546
  }
@@ -15,6 +15,8 @@ export * from "./extension-store.js";
15
15
  export { readLatestPendingPrompt } from "./client-projection.js";
16
16
  export { createFleetDirectoryStore, createFleetDelegationStore, } from "./fleet-store.js";
17
17
  export * from "./session-attention.js";
18
+ export * from "./work-senders.js";
19
+ export * from "./session-delegator.js";
18
20
  export { deliverSessionGrants, prepareSessionGrants, readSessionGrants, type GrantsDelivery, type GrantsDeliveryResult, } from "./session-grants.js";
19
21
  export * from "./progress.js";
20
22
  export * from "./compaction-activity.js";
@@ -138,6 +140,8 @@ export declare function readSessionFactPage(owner: SqliteOwner, after?: string):
138
140
  export declare function readColdSessionPage(owner: SqliteOwner, request: ColdSessionPageRequest): Effect.Effect<ColdSessionPage, ShiitakeError>;
139
141
  /** Read one bounded inbox page in tier, then newest-activity order. */
140
142
  export declare function readSessionInbox(owner: SqliteOwner, after: string | undefined): Effect.Effect<import("../../client.js").SessionSummaryPage, ShiitakeError>;
143
+ /** Read a Session's open attention items from the store alone. */
144
+ export declare function readStoredSessionAttention(owner: SqliteOwner, sessionId: string): Effect.Effect<readonly import("../../client.js").SessionAttention[], ShiitakeError>;
141
145
  /** Read one strict-after page from Woodstock's selected lineage. */
142
146
  export declare function readSessionReplayPage(owner: SqliteOwner, sessionId: string, after: string | undefined, delivery: "replay" | "live", activity: ObservedActivity, projectExtensions?: () => SessionExtensionSlots, includeSandboxFailures?: boolean): Effect.Effect<ClientReplayPage, ShiitakeError>;
143
147
  /** Persist an operator-only sandbox diagnostic beside its accepted Session work. */
@@ -9,10 +9,13 @@ import { openProgress, openToolProgress } from "./progress.js";
9
9
  import { encodeStoredSessionEntry, } from "./session-entry.js";
10
10
  import { appendSessionEntry, appendCompactionTransitionOwned, appendCompactionNoticeOwned, placeCompactionTransitionOwned, acceptSessionAbort, acceptSessionCancellation, completeSessionLeaf, prepareSessionRun, prepareSessionStore, readSessionEntriesForWork, readSessionCommandAdmission, settleSessionControls, takePendingSessionControls, readSessionEntryById, selectSessionBranch, forkSessionAt, readStoredSessionSummary as readSessionSummaryOwned, readStoredSessionFactPage, createStoredSession, createStoredSessionWithinTransaction, deleteStoredSession, appendOperatorEntry, } from "./session-store.js";
11
11
  import { readSessionInboxPage, recordSessionInboxDeletion, } from "./session-inbox.js";
12
+ import { readSessionAttention } from "./session-attention.js";
12
13
  export * from "./extension-store.js";
13
14
  export { readLatestPendingPrompt } from "./client-projection.js";
14
15
  export { createFleetDirectoryStore, createFleetDelegationStore, } from "./fleet-store.js";
15
16
  export * from "./session-attention.js";
17
+ export * from "./work-senders.js";
18
+ export * from "./session-delegator.js";
16
19
  export { deliverSessionGrants, prepareSessionGrants, readSessionGrants, } from "./session-grants.js";
17
20
  export * from "./progress.js";
18
21
  export * from "./compaction-activity.js";
@@ -228,6 +231,10 @@ export function readColdSessionPage(owner, request) {
228
231
  export function readSessionInbox(owner, after) {
229
232
  return storeEffect(() => readSessionInboxPage(owner, after));
230
233
  }
234
+ /** Read a Session's open attention items from the store alone. */
235
+ export function readStoredSessionAttention(owner, sessionId) {
236
+ return storeEffect(() => readSessionAttention(owner, sessionId));
237
+ }
231
238
  /** Read one strict-after page from Woodstock's selected lineage. */
232
239
  export function readSessionReplayPage(owner, sessionId, after, delivery, activity, projectExtensions = () => ({}), includeSandboxFailures = false) {
233
240
  return storeEffect(() => readClientReplayPage(owner, decodeClientSessionId(sessionId), after, delivery, activity, projectExtensions, includeSandboxFailures));
@@ -0,0 +1,12 @@
1
+ import type { BirdDogDelegator, BirdDogMail } from "@fungi.computer/bird-dog";
2
+ import type { SqliteOwner } from "../contracts.js";
3
+ /** Woodstock keeps each Session's delegator of record beside the Session. */
4
+ export declare function prepareSessionDelegators(owner: SqliteOwner): void;
5
+ /**
6
+ * Record who sent a Session its work first, in the admitting transaction.
7
+ * Only an original mail that asks for replies names a delegator; the first one
8
+ * stays, so the relationship never moves once it exists.
9
+ */
10
+ export declare function recordSessionDelegator(owner: SqliteOwner, sessionId: string, mail: BirdDogMail | undefined): void;
11
+ /** The Session's delegator of record, if any Agent has sent it work. */
12
+ export declare function readSessionDelegator(owner: SqliteOwner, sessionId: string): BirdDogDelegator | undefined;
@@ -0,0 +1,44 @@
1
+ import { BirdDogAgentReferenceSchema } from "@fungi.computer/bird-dog/effect";
2
+ import * as Schema from "effect/Schema";
3
+ const decodeRow = Schema.decodeUnknownSync(Schema.Struct({
4
+ mail_key: Schema.String,
5
+ agent_id: Schema.String,
6
+ reply_to_json: Schema.fromJsonString(Schema.Array(BirdDogAgentReferenceSchema)),
7
+ }));
8
+ const encodeReplyTo = Schema.encodeSync(Schema.fromJsonString(Schema.Array(BirdDogAgentReferenceSchema)));
9
+ /** Woodstock keeps each Session's delegator of record beside the Session. */
10
+ export function prepareSessionDelegators(owner) {
11
+ owner.sql.exec(`CREATE TABLE IF NOT EXISTS session_delegators (
12
+ session_id TEXT PRIMARY KEY,
13
+ mail_key TEXT NOT NULL,
14
+ agent_id TEXT NOT NULL,
15
+ reply_to_json TEXT NOT NULL
16
+ )`);
17
+ }
18
+ /**
19
+ * Record who sent a Session its work first, in the admitting transaction.
20
+ * Only an original mail that asks for replies names a delegator; the first one
21
+ * stays, so the relationship never moves once it exists.
22
+ */
23
+ export function recordSessionDelegator(owner, sessionId, mail) {
24
+ if (mail === undefined ||
25
+ mail.reply !== undefined ||
26
+ mail.from.kind !== "agent" ||
27
+ mail.replyTo.length === 0)
28
+ return;
29
+ owner.sql.exec("INSERT OR IGNORE INTO session_delegators (session_id, mail_key, agent_id, reply_to_json) VALUES (?, ?, ?, ?)", sessionId, mail.key, mail.to.agentId, encodeReplyTo(mail.replyTo));
30
+ }
31
+ /** The Session's delegator of record, if any Agent has sent it work. */
32
+ export function readSessionDelegator(owner, sessionId) {
33
+ const [row] = owner.sql
34
+ .exec("SELECT mail_key, agent_id, reply_to_json FROM session_delegators WHERE session_id = ?", sessionId)
35
+ .toArray();
36
+ if (row === undefined)
37
+ return undefined;
38
+ const decoded = decodeRow(row);
39
+ return {
40
+ key: decoded.mail_key,
41
+ agentId: decoded.agent_id,
42
+ replyTo: decoded.reply_to_json,
43
+ };
44
+ }
@@ -74,6 +74,11 @@ declare const StoredSessionEntrySchema: Schema.Union<readonly [Schema.Struct<{
74
74
  readonly commandId: Schema.String;
75
75
  readonly outcome: Schema.Literals<readonly ["completed", "failed", "interrupted", "cancelled", "outcome_unknown"]>;
76
76
  readonly diagnostic: Schema.optionalKey<Schema.String>;
77
+ readonly waitingOnYou: Schema.optionalKey<Schema.$Array<Schema.Struct<{
78
+ readonly itemId: Schema.String;
79
+ readonly kind: Schema.String;
80
+ readonly summary: Schema.String;
81
+ }>>>;
77
82
  readonly agentId: Schema.String;
78
83
  readonly sessionId: Schema.String;
79
84
  }>, Schema.Struct<{
@@ -357,15 +362,20 @@ export declare function decodeStoredSessionEntry(value: string): Result.Result<{
357
362
  readonly delivery?: "followUp" | "steer";
358
363
  readonly meta?: import("../../extensions.js").Json;
359
364
  readonly reply?: {
365
+ readonly agentId: string;
360
366
  readonly sessionId: string;
361
367
  readonly commandId: string;
362
368
  readonly outcome: "completed" | "failed" | "cancelled" | "interrupted" | "outcome_unknown";
363
- readonly agentId: string;
364
369
  readonly diagnostic?: string;
370
+ readonly waitingOnYou?: readonly {
371
+ readonly itemId: string;
372
+ readonly kind: string;
373
+ readonly summary: string;
374
+ }[];
365
375
  } | {
376
+ readonly agentId: string;
366
377
  readonly reason: "recipient_offline" | "recipient_left";
367
378
  readonly outcome: "undeliverable";
368
- readonly agentId: string;
369
379
  readonly sessionId?: string;
370
380
  };
371
381
  readonly targetWorkId?: string;
@@ -237,6 +237,8 @@ export declare function settleSessionControls(owner: SqliteOwner, input: Readonl
237
237
  export declare function readSessionCommandAdmission(owner: SqliteOwner, sessionId: string, id: string): StoredCommandAdmission | undefined;
238
238
  /** Read one stable private command admission by accepted request identity. */
239
239
  export declare function readSessionCommandAdmissionByRequest(owner: SqliteOwner, sessionId: string, requestId: string): StoredCommandAdmission | undefined;
240
+ /** Probe the indexed terminal facts without materializing a live work's body. */
241
+ export declare function hasSessionWorkTerminal(owner: SqliteOwner, sessionId: string, workId: string): boolean;
240
242
  /** Read entries correlated to one accepted work identity in Session order. */
241
243
  export declare function readSessionEntriesForWork(owner: SqliteOwner, sessionId: string, workId: string): readonly StoredSessionRow[];
242
244
  /** Read the latest retained live-retry event for one accepted work item. */
@@ -7,6 +7,8 @@ import { compactionSummaryMessage } from "../compaction/utils.js";
7
7
  import { SessionTitleSchema, sessionTitleLimit } from "../client-boundary.js";
8
8
  import { commandTimestamp, decodeStoredSessionEntry, encodeStoredSessionEntry, isCompactCommand, commandContextMessage, operatorContextMessage, promptCommand, } from "./session-entry.js";
9
9
  import { prepareSessionAttention } from "./session-attention.js";
10
+ import { prepareWorkSenders } from "./work-senders.js";
11
+ import { prepareSessionDelegators } from "./session-delegator.js";
10
12
  import { copySessionGrants, prepareSessionGrants } from "./session-grants.js";
11
13
  import { prepareSessionInbox, touchSessionInbox } from "./session-inbox.js";
12
14
  import { sessionIds } from "./identities.js";
@@ -680,6 +682,18 @@ function readSessionCommandAdmissionByWorkId(owner, sessionId, workId) {
680
682
  export function readSessionCommandAdmissionByRequest(owner, sessionId, requestId) {
681
683
  return readCommandAdmission(owner, sessionId, { requestId });
682
684
  }
685
+ /** Probe the indexed terminal facts without materializing a live work's body. */
686
+ export function hasSessionWorkTerminal(owner, sessionId, workId) {
687
+ // Keep the readability check even when the work has no terminal rows.
688
+ readSessionRow(owner, sessionId);
689
+ return (owner.sql
690
+ .exec(`SELECT 1 AS present FROM session_entries
691
+ INDEXED BY idx_session_entries_work_terminal
692
+ WHERE session_id = ? AND work_id = ?
693
+ AND type IN ('run_terminal', 'work_terminal')
694
+ LIMIT 1`, sessionId, workId)
695
+ .toArray().length > 0);
696
+ }
683
697
  /** Read entries correlated to one accepted work identity in Session order. */
684
698
  export function readSessionEntriesForWork(owner, sessionId, workId) {
685
699
  // Refuse the Session before selecting entries: a damaged work may have no
@@ -832,6 +846,7 @@ export function deleteStoredSession(owner, input) {
832
846
  "DELETE FROM session_command_admissions WHERE session_id = ?",
833
847
  // The inbox row references the Session; it goes in the same transaction.
834
848
  "DELETE FROM session_attention WHERE session_id = ?",
849
+ "DELETE FROM session_work_senders WHERE session_id = ?",
835
850
  "DELETE FROM session_tool_grants WHERE session_id = ?",
836
851
  "DELETE FROM session_inbox WHERE session_id = ?",
837
852
  "DELETE FROM session_creation_receipts WHERE session_id = ?",
@@ -1128,6 +1143,8 @@ export function prepareSessionStore(owner) {
1128
1143
  createSessionEntryTable(owner);
1129
1144
  prepareSessionDocuments(owner);
1130
1145
  prepareSessionAttention(owner);
1146
+ prepareWorkSenders(owner);
1147
+ prepareSessionDelegators(owner);
1131
1148
  prepareSessionGrants(owner);
1132
1149
  prepareSessionInbox(owner);
1133
1150
  owner.sql.exec(`
@@ -1143,6 +1160,9 @@ export function prepareSessionStore(owner) {
1143
1160
  ON session_entries(session_id, request_id)
1144
1161
  WHERE request_id IS NOT NULL`);
1145
1162
  owner.sql.exec("CREATE INDEX IF NOT EXISTS idx_session_entries_work ON session_entries(work_id, sequence)");
1163
+ owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_work_terminal
1164
+ ON session_entries(session_id, work_id)
1165
+ WHERE type IN ('run_terminal', 'work_terminal')`);
1146
1166
  // Context and preference reads look up these rare older facts directly
1147
1167
  // rather than walking the history behind them.
1148
1168
  owner.sql.exec(`CREATE INDEX IF NOT EXISTS idx_session_entries_model_preference
@@ -0,0 +1,32 @@
1
+ import { type AttentionRecipient } from "../../session-attention.js";
2
+ import type { SqliteOwner } from "../contracts.js";
3
+ /** One open item of a Session that waits on the Agent that sent its work. */
4
+ export type WaitingOnYou = Readonly<{
5
+ itemId: string;
6
+ kind: string;
7
+ summary: string;
8
+ }>;
9
+ /**
10
+ * Woodstock records which Agent Session sent each accepted run, beside the
11
+ * run's admission. Attention that an extension raises during a run waits on
12
+ * this sender first; a run no Agent sent has no row.
13
+ */
14
+ export declare function prepareWorkSenders(owner: SqliteOwner): void;
15
+ /** Record the sender of one accepted run; a replay writes the same row. */
16
+ export declare function recordWorkSender(owner: SqliteOwner, sessionId: string, workId: string, sender: AttentionRecipient): void;
17
+ /** The Agent Session that sent this run, or undefined when none did. */
18
+ export declare function readWorkSender(owner: SqliteOwner, sessionId: string, workId: string): AttentionRecipient | undefined;
19
+ /**
20
+ * The Session's open attention that waits on the sender of this run, newest
21
+ * first as stored, bounded. Escalated items wait on someone else and are
22
+ * absent; a run no Agent sent names none.
23
+ */
24
+ export declare function readWaitingOnSender(owner: SqliteOwner, sessionId: string, workId: string): readonly WaitingOnYou[];
25
+ /**
26
+ * The Session's open attention that waits on any of these Agent Sessions,
27
+ * newest first as stored, bounded: what a reply or report to them names.
28
+ */
29
+ export declare function readWaitingOn(owner: SqliteOwner, sessionId: string, recipients: readonly Readonly<{
30
+ agentId: string;
31
+ sessionId: string;
32
+ }>[]): readonly WaitingOnYou[];
@@ -0,0 +1,70 @@
1
+ import * as Schema from "effect/Schema";
2
+ import { AttentionRecipientSchema, } from "../../session-attention.js";
3
+ import { readSessionAttention } from "./session-attention.js";
4
+ /** The most open items one settlement names as waiting on its sender. */
5
+ const maximumWaitingOnYou = 8;
6
+ const decodeRow = Schema.decodeUnknownSync(Schema.Struct({
7
+ agent_id: Schema.String,
8
+ sender_session_id: Schema.String,
9
+ name: Schema.String,
10
+ }));
11
+ const decodeRecipient = Schema.decodeUnknownSync(AttentionRecipientSchema);
12
+ /**
13
+ * Woodstock records which Agent Session sent each accepted run, beside the
14
+ * run's admission. Attention that an extension raises during a run waits on
15
+ * this sender first; a run no Agent sent has no row.
16
+ */
17
+ export function prepareWorkSenders(owner) {
18
+ owner.sql.exec(`CREATE TABLE IF NOT EXISTS session_work_senders (
19
+ session_id TEXT NOT NULL,
20
+ work_id TEXT NOT NULL,
21
+ agent_id TEXT NOT NULL,
22
+ sender_session_id TEXT NOT NULL,
23
+ name TEXT NOT NULL,
24
+ PRIMARY KEY (session_id, work_id)
25
+ )`);
26
+ }
27
+ /** Record the sender of one accepted run; a replay writes the same row. */
28
+ export function recordWorkSender(owner, sessionId, workId, sender) {
29
+ owner.sql.exec(`INSERT OR IGNORE INTO session_work_senders
30
+ (session_id, work_id, agent_id, sender_session_id, name)
31
+ VALUES (?, ?, ?, ?, ?)`, sessionId, workId, sender.agentId, sender.sessionId, sender.name);
32
+ }
33
+ /** The Agent Session that sent this run, or undefined when none did. */
34
+ export function readWorkSender(owner, sessionId, workId) {
35
+ const raw = owner.sql
36
+ .exec("SELECT agent_id, sender_session_id, name FROM session_work_senders WHERE session_id = ? AND work_id = ?", sessionId, workId)
37
+ .toArray()[0];
38
+ if (raw === undefined)
39
+ return undefined;
40
+ const row = decodeRow(raw);
41
+ return decodeRecipient({
42
+ agentId: row.agent_id,
43
+ sessionId: row.sender_session_id,
44
+ name: row.name,
45
+ });
46
+ }
47
+ /**
48
+ * The Session's open attention that waits on the sender of this run, newest
49
+ * first as stored, bounded. Escalated items wait on someone else and are
50
+ * absent; a run no Agent sent names none.
51
+ */
52
+ export function readWaitingOnSender(owner, sessionId, workId) {
53
+ const sender = readWorkSender(owner, sessionId, workId);
54
+ return sender === undefined ? [] : readWaitingOn(owner, sessionId, [sender]);
55
+ }
56
+ /**
57
+ * The Session's open attention that waits on any of these Agent Sessions,
58
+ * newest first as stored, bounded: what a reply or report to them names.
59
+ */
60
+ export function readWaitingOn(owner, sessionId, recipients) {
61
+ return readSessionAttention(owner, sessionId)
62
+ .filter((item) => recipients.some((recipient) => item.waitingOn?.agentId === recipient.agentId &&
63
+ item.waitingOn.sessionId === recipient.sessionId))
64
+ .slice(0, maximumWaitingOnYou)
65
+ .map((item) => ({
66
+ itemId: item.id,
67
+ kind: item.kind,
68
+ summary: item.summary,
69
+ }));
70
+ }
@@ -72,7 +72,12 @@ function remoteEvents(url, clientClosed, sources, eventSource) {
72
72
  closed: false,
73
73
  };
74
74
  const emit = (kind, event) => {
75
- Queue.offerUnsafe(queue, { kind, event });
75
+ if (!Queue.offerUnsafe(queue, { kind, event })) {
76
+ // A dropped frame breaks this watch's ordering. Fail instead
77
+ // of delivering an incomplete history to the client.
78
+ Queue.failCauseUnsafe(queue, Cause.fail(unavailable("Session stream client fell behind")));
79
+ Effect.runSync(closeRemoteSource(active, sources));
80
+ }
76
81
  };
77
82
  active.source.addEventListener("snapshot", (event) => {
78
83
  emit("snapshot", event);
@@ -91,7 +96,7 @@ function remoteEvents(url, clientClosed, sources, eventSource) {
91
96
  return active;
92
97
  },
93
98
  catch: () => unavailable("Session stream could not open"),
94
- }), (active) => closeRemoteSource(active, sources))).pipe(Stream.mapEffect(decodeRemoteEvent), Stream.interruptWhen(clientClosed.await));
99
+ }), (active) => closeRemoteSource(active, sources)), { bufferSize: 256, strategy: "dropping" }).pipe(Stream.mapEffect(decodeRemoteEvent), Stream.interruptWhen(clientClosed.await));
95
100
  }
96
101
  function sessionUrl(baseUrl, sessionId) {
97
102
  return `${baseUrl}/sessions/${encodeURIComponent(sessionId)}`;
@@ -0,0 +1,13 @@
1
+ import type { SessionSnapshot, SessionWatch } from "./client.js";
2
+ export type VisitorStreamError = "channel_visitor_stream_expiry_failed" | "channel_visitor_stream_close_failed" | "channel_visitor_stream_heartbeat_failed" | "channel_visitor_stream_failed";
3
+ export declare function visitorStream(options: Readonly<{
4
+ watch: SessionWatch;
5
+ visitorId: string;
6
+ filter: (snapshot: SessionSnapshot) => SessionSnapshot;
7
+ expiresAt: number;
8
+ now: () => number;
9
+ release: () => void;
10
+ canRead: () => Promise<boolean>;
11
+ read: () => Promise<SessionSnapshot>;
12
+ onError: (event: VisitorStreamError, cause: unknown) => void;
13
+ }>): ReadableStream<Uint8Array>;