@fungi.computer/shiitake 0.4.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 (68) hide show
  1. package/README.md +1 -10
  2. package/dist/agent-control.d.ts +98 -5
  3. package/dist/agent-control.js +111 -8
  4. package/dist/client-entry.d.ts +20 -0
  5. package/dist/client-entry.js +5 -0
  6. package/dist/client-visitor.d.ts +31 -0
  7. package/dist/client-visitor.js +226 -0
  8. package/dist/client-wire.d.ts +5 -0
  9. package/dist/client-wire.js +6 -0
  10. package/dist/client.d.ts +4 -3
  11. package/dist/client.js +1 -0
  12. package/dist/delegation.d.ts +100 -77
  13. package/dist/delegation.js +73 -23
  14. package/dist/effect.d.ts +25 -0
  15. package/dist/effect.js +14 -1
  16. package/dist/extensions.d.ts +21 -2
  17. package/dist/extensions.js +1 -1
  18. package/dist/fleet.d.ts +22 -2
  19. package/dist/fleet.js +127 -36
  20. package/dist/hosted.js +3 -0
  21. package/dist/http-routes.d.ts +11 -0
  22. package/dist/internal/accepted-run-executor.js +2 -1
  23. package/dist/internal/admission-door.d.ts +5 -0
  24. package/dist/internal/admission-door.js +34 -3
  25. package/dist/internal/contracts.d.ts +14 -1
  26. package/dist/internal/coordinator.js +8 -2
  27. package/dist/internal/effect-client.js +1 -1
  28. package/dist/internal/effect-contracts.d.ts +4 -2
  29. package/dist/internal/extension-command-registry.d.ts +2 -1
  30. package/dist/internal/extension-command-registry.js +3 -1
  31. package/dist/internal/extension-runtime.d.ts +3 -1
  32. package/dist/internal/extension-runtime.js +13 -5
  33. package/dist/internal/model-acquisition.js +2 -0
  34. package/dist/internal/observation-kernel.d.ts +2 -0
  35. package/dist/internal/observation-kernel.js +17 -6
  36. package/dist/internal/run-scope.d.ts +14 -0
  37. package/dist/internal/run-scope.js +32 -9
  38. package/dist/internal/runtime-coordinator.js +8 -1
  39. package/dist/internal/runtime-reconciliation.js +119 -24
  40. package/dist/internal/woodstock/agent-reply.d.ts +5 -0
  41. package/dist/internal/woodstock/agent-reply.js +3 -0
  42. package/dist/internal/woodstock/client-projection.js +9 -1
  43. package/dist/internal/woodstock/fleet-store.d.ts +1 -0
  44. package/dist/internal/woodstock/fleet-store.js +28 -1
  45. package/dist/internal/woodstock/index.d.ts +4 -0
  46. package/dist/internal/woodstock/index.js +7 -0
  47. package/dist/internal/woodstock/session-delegator.d.ts +12 -0
  48. package/dist/internal/woodstock/session-delegator.js +44 -0
  49. package/dist/internal/woodstock/session-entry.d.ts +12 -2
  50. package/dist/internal/woodstock/session-store.d.ts +2 -0
  51. package/dist/internal/woodstock/session-store.js +20 -0
  52. package/dist/internal/woodstock/work-senders.d.ts +32 -0
  53. package/dist/internal/woodstock/work-senders.js +70 -0
  54. package/dist/remote-client.js +7 -2
  55. package/dist/server-visitor-stream.d.ts +13 -0
  56. package/dist/server-visitor-stream.js +115 -0
  57. package/dist/session-attention.d.ts +30 -0
  58. package/dist/session-attention.js +14 -0
  59. package/docs/api/agent-control.md +209 -13
  60. package/docs/api/client-entry.md +3 -0
  61. package/docs/api/client.md +136 -1
  62. package/docs/api/cloudflare.md +7 -1
  63. package/docs/api/delegation.md +234 -61
  64. package/docs/api/effect.md +16 -6
  65. package/docs/api/extensions.md +65 -4
  66. package/docs/api/fleet.md +69 -16
  67. package/docs/api/index.md +6 -4
  68. package/package.json +10 -6
@@ -86,11 +86,16 @@ export function createFleetDelegationStore(storage) {
86
86
  ) STRICT`);
87
87
  storage.sql.exec(`CREATE TABLE IF NOT EXISTS shiitake_fleet_held_delegations (
88
88
  request_id TEXT PRIMARY KEY
89
+ ) STRICT`);
90
+ storage.sql
91
+ .exec(`CREATE TABLE IF NOT EXISTS shiitake_fleet_undeliverable_delegations (
92
+ request_id TEXT PRIMARY KEY
89
93
  ) STRICT`);
90
94
  return {
91
95
  read: (requestId) => storage.sql
92
96
  .exec(`SELECT delegation.input, delegation.mail,
93
97
  CASE
98
+ WHEN terminal.request_id IS NOT NULL THEN 'undeliverable'
94
99
  WHEN held.request_id IS NOT NULL THEN 'held'
95
100
  WHEN delegation.receipt IS NOT NULL THEN 'accepted'
96
101
  ELSE 'pending'
@@ -99,6 +104,8 @@ export function createFleetDelegationStore(storage) {
99
104
  FROM shiitake_fleet_delegations AS delegation
100
105
  LEFT JOIN shiitake_fleet_held_delegations AS held
101
106
  ON held.request_id = delegation.request_id
107
+ LEFT JOIN shiitake_fleet_undeliverable_delegations AS terminal
108
+ ON terminal.request_id = delegation.request_id
102
109
  WHERE delegation.request_id = ?`, requestId)
103
110
  .toArray()[0],
104
111
  /** Every recipient-accepted delegation, newest first. */
@@ -135,7 +142,27 @@ export function createFleetDelegationStore(storage) {
135
142
  storage.transactionSync(() => {
136
143
  storage.sql.exec(`INSERT OR IGNORE INTO shiitake_fleet_held_delegations (request_id)
137
144
  SELECT request_id FROM shiitake_fleet_delegations
138
- WHERE request_id = ? AND receipt IS NULL`, requestId);
145
+ WHERE request_id = ? AND receipt IS NULL
146
+ AND NOT EXISTS (
147
+ SELECT 1 FROM shiitake_fleet_undeliverable_delegations
148
+ WHERE request_id = ?
149
+ )`, requestId, requestId);
150
+ });
151
+ },
152
+ recordUndeliverable(requestId) {
153
+ return storage.transactionSync(() => {
154
+ const held = storage.sql
155
+ .exec(`SELECT delegation.request_id
156
+ FROM shiitake_fleet_delegations AS delegation
157
+ JOIN shiitake_fleet_held_delegations AS held
158
+ ON held.request_id = delegation.request_id
159
+ WHERE delegation.request_id = ? AND delegation.receipt IS NULL`, requestId)
160
+ .toArray()[0];
161
+ if (held === undefined)
162
+ return false;
163
+ storage.sql.exec("INSERT OR IGNORE INTO shiitake_fleet_undeliverable_delegations(request_id) VALUES (?)", requestId);
164
+ storage.sql.exec("DELETE FROM shiitake_fleet_held_delegations WHERE request_id = ?", requestId);
165
+ return true;
139
166
  });
140
167
  },
141
168
  };
@@ -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>;
@@ -0,0 +1,115 @@
1
+ import { SSEStreamingApi } from "hono/streaming";
2
+ import { visitorSnapshot } from "./client-visitor.js";
3
+ export function visitorStream(options) {
4
+ const { readable, writable } = new TransformStream();
5
+ const stream = new SSEStreamingApi(writable, readable);
6
+ let closing;
7
+ let ending = false;
8
+ let heartbeat;
9
+ const expiry = setTimeout(() => {
10
+ void close().catch((cause) => {
11
+ options.onError("channel_visitor_stream_expiry_failed", cause);
12
+ });
13
+ }, Math.max(0, options.expiresAt - options.now()));
14
+ const close = () => {
15
+ if (closing !== undefined)
16
+ return closing;
17
+ ending = true;
18
+ clearTimeout(expiry);
19
+ clearTimeout(heartbeat);
20
+ options.release();
21
+ closing = Promise.resolve().then(async () => {
22
+ stream.abort();
23
+ try {
24
+ await options.watch.close();
25
+ }
26
+ finally {
27
+ await stream.close();
28
+ }
29
+ });
30
+ return closing;
31
+ };
32
+ stream.onAbort(() => {
33
+ void close().catch((cause) => {
34
+ options.onError("channel_visitor_stream_close_failed", cause);
35
+ });
36
+ });
37
+ const admitted = async () => !ending &&
38
+ options.now() < options.expiresAt &&
39
+ (await options.canRead()) &&
40
+ !ending;
41
+ const beat = async () => {
42
+ if (!(await admitted())) {
43
+ await close();
44
+ return;
45
+ }
46
+ await stream.write(": heartbeat\n\n");
47
+ if (!ending)
48
+ heartbeat = setTimeout(() => {
49
+ void beat().catch((cause) => {
50
+ options.onError("channel_visitor_stream_heartbeat_failed", cause);
51
+ void close().catch((closeCause) => {
52
+ options.onError("channel_visitor_stream_close_failed", closeCause);
53
+ });
54
+ });
55
+ }, 30_000);
56
+ };
57
+ const publish = async (snapshot, progressOnly) => {
58
+ if (!(await admitted()))
59
+ return false;
60
+ const baseline = visitorSnapshot(options.filter(snapshot), options.visitorId);
61
+ await stream.writeSSE(progressOnly
62
+ ? {
63
+ event: "frame",
64
+ data: JSON.stringify({
65
+ kind: "progress",
66
+ progress: baseline.progress,
67
+ activity: baseline.activity,
68
+ }),
69
+ }
70
+ : { event: "session", data: JSON.stringify(baseline) });
71
+ return !ending;
72
+ };
73
+ const pump = async () => {
74
+ try {
75
+ let snapshot = options.watch.snapshot;
76
+ if (!(await publish(snapshot, false)))
77
+ return;
78
+ heartbeat = setTimeout(() => {
79
+ void beat().catch((cause) => {
80
+ options.onError("channel_visitor_stream_heartbeat_failed", cause);
81
+ void close().catch((closeCause) => {
82
+ options.onError("channel_visitor_stream_close_failed", closeCause);
83
+ });
84
+ });
85
+ }, 30_000);
86
+ for await (const frame of options.watch.changes) {
87
+ if (ending)
88
+ break;
89
+ if (frame.kind === "tool_output")
90
+ continue;
91
+ snapshot =
92
+ frame.kind === "progress"
93
+ ? {
94
+ ...snapshot,
95
+ progress: frame.progress,
96
+ activity: frame.activity,
97
+ }
98
+ : frame.kind === "activity"
99
+ ? { ...snapshot, activity: frame.activity }
100
+ : frame.kind === "reset"
101
+ ? frame.baseline
102
+ : await options.read();
103
+ if (!(await publish(snapshot, frame.kind === "progress")))
104
+ break;
105
+ }
106
+ }
107
+ finally {
108
+ await close();
109
+ }
110
+ };
111
+ void pump().catch((cause) => {
112
+ options.onError("channel_visitor_stream_failed", cause);
113
+ });
114
+ return stream.responseReadable.pipeThrough(new TransformStream());
115
+ }
@@ -1,7 +1,21 @@
1
1
  import * as Schema from "effect/Schema";
2
+ /**
3
+ * The Agent Session that sent the work an item belongs to. The host derives it
4
+ * from the accepted mail; it is the only party the item waits on until it is
5
+ * escalated.
6
+ */
7
+ export declare const AttentionRecipientSchema: Schema.Struct<{
8
+ readonly agentId: Schema.NonEmptyString;
9
+ readonly sessionId: Schema.NonEmptyString;
10
+ readonly name: Schema.NonEmptyString;
11
+ }>;
12
+ /** Decoded Agent Session an attention item waits on. */
13
+ export type AttentionRecipient = typeof AttentionRecipientSchema.Type;
2
14
  /**
3
15
  * An extension-owned request for human attention; expiry is owned by its timer.
4
16
  * Items marked `inbox: false` remain visible without changing the inbox state.
17
+ * An item with `waitingOn` waits on that Agent first and is never a person's
18
+ * inbox item until it is escalated past every Agent.
5
19
  */
6
20
  export declare const AttentionItemSchema: Schema.Struct<{
7
21
  readonly id: Schema.NonEmptyString;
@@ -11,6 +25,12 @@ export declare const AttentionItemSchema: Schema.Struct<{
11
25
  readonly expiresAt: Schema.optional<Schema.Int>;
12
26
  /** This item does not change the Session's inbox state; omission does. */
13
27
  readonly inbox: Schema.optional<Schema.Literal<false>>;
28
+ /** The Agent this item waits on; absent means a person. */
29
+ readonly waitingOn: Schema.optional<Schema.Struct<{
30
+ readonly agentId: Schema.NonEmptyString;
31
+ readonly sessionId: Schema.NonEmptyString;
32
+ readonly name: Schema.NonEmptyString;
33
+ }>>;
14
34
  }>;
15
35
  /** Input to the granted attention door, scoped to the calling extension. */
16
36
  export type AttentionItem = typeof AttentionItemSchema.Type;
@@ -23,6 +43,11 @@ export declare const SessionAttentionSchema: Schema.Struct<{
23
43
  readonly since: Schema.Int;
24
44
  readonly expiresAt: Schema.optional<Schema.Int>;
25
45
  readonly inbox: Schema.optional<Schema.Literal<false>>;
46
+ readonly waitingOn: Schema.optional<Schema.Struct<{
47
+ readonly agentId: Schema.NonEmptyString;
48
+ readonly sessionId: Schema.NonEmptyString;
49
+ readonly name: Schema.NonEmptyString;
50
+ }>>;
26
51
  }>;
27
52
  /** One open attention item in a Session observation. */
28
53
  export type SessionAttention = typeof SessionAttentionSchema.Type;
@@ -39,4 +64,9 @@ export declare const SessionAttentionListSchema: Schema.$Array<Schema.Struct<{
39
64
  readonly since: Schema.Int;
40
65
  readonly expiresAt: Schema.optional<Schema.Int>;
41
66
  readonly inbox: Schema.optional<Schema.Literal<false>>;
67
+ readonly waitingOn: Schema.optional<Schema.Struct<{
68
+ readonly agentId: Schema.NonEmptyString;
69
+ readonly sessionId: Schema.NonEmptyString;
70
+ readonly name: Schema.NonEmptyString;
71
+ }>>;
42
72
  }>>;
@@ -1,9 +1,21 @@
1
1
  import * as Schema from "effect/Schema";
2
2
  const identity = Schema.NonEmptyString.check(Schema.isMaxLength(256));
3
3
  const instant = Schema.Int.check(Schema.isGreaterThanOrEqualTo(0));
4
+ /**
5
+ * The Agent Session that sent the work an item belongs to. The host derives it
6
+ * from the accepted mail; it is the only party the item waits on until it is
7
+ * escalated.
8
+ */
9
+ export const AttentionRecipientSchema = Schema.Struct({
10
+ agentId: identity,
11
+ sessionId: identity,
12
+ name: identity,
13
+ });
4
14
  /**
5
15
  * An extension-owned request for human attention; expiry is owned by its timer.
6
16
  * Items marked `inbox: false` remain visible without changing the inbox state.
17
+ * An item with `waitingOn` waits on that Agent first and is never a person's
18
+ * inbox item until it is escalated past every Agent.
7
19
  */
8
20
  export const AttentionItemSchema = Schema.Struct({
9
21
  id: identity,
@@ -13,6 +25,8 @@ export const AttentionItemSchema = Schema.Struct({
13
25
  expiresAt: Schema.optional(instant),
14
26
  /** This item does not change the Session's inbox state; omission does. */
15
27
  inbox: Schema.optional(Schema.Literal(false)),
28
+ /** The Agent this item waits on; absent means a person. */
29
+ waitingOn: Schema.optional(AttentionRecipientSchema),
16
30
  });
17
31
  /** Public identity includes the owner so two extensions can use the same id. */
18
32
  export const SessionAttentionSchema = AttentionItemSchema.mapFields((fields) => ({