@haywoodmarx/agent-pager 0.1.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 (178) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -0
  3. package/dist/src/broker/address.d.ts +49 -0
  4. package/dist/src/broker/address.js +110 -0
  5. package/dist/src/broker/broker.d.ts +665 -0
  6. package/dist/src/broker/broker.js +2270 -0
  7. package/dist/src/broker/database.d.ts +81 -0
  8. package/dist/src/broker/database.js +69 -0
  9. package/dist/src/broker/operator-projection.d.ts +291 -0
  10. package/dist/src/broker/operator-projection.js +722 -0
  11. package/dist/src/broker/protocol.d.ts +237 -0
  12. package/dist/src/broker/protocol.js +311 -0
  13. package/dist/src/broker/schema.d.ts +94 -0
  14. package/dist/src/broker/schema.js +454 -0
  15. package/dist/src/broker/snapshot.d.ts +171 -0
  16. package/dist/src/broker/snapshot.js +78 -0
  17. package/dist/src/configuration-defaults.d.ts +64 -0
  18. package/dist/src/configuration-defaults.js +48 -0
  19. package/dist/src/configuration.d.ts +76 -0
  20. package/dist/src/configuration.js +156 -0
  21. package/dist/src/implementations/claude/environment.d.ts +8 -0
  22. package/dist/src/implementations/claude/environment.js +9 -0
  23. package/dist/src/implementations/claude/gateway-cli.d.ts +2 -0
  24. package/dist/src/implementations/claude/gateway-cli.js +136 -0
  25. package/dist/src/implementations/claude/gateway-identity.d.ts +134 -0
  26. package/dist/src/implementations/claude/gateway-identity.js +103 -0
  27. package/dist/src/implementations/claude/gateway.d.ts +148 -0
  28. package/dist/src/implementations/claude/gateway.js +236 -0
  29. package/dist/src/implementations/claude/instructions.d.ts +33 -0
  30. package/dist/src/implementations/claude/instructions.js +64 -0
  31. package/dist/src/implementations/claude/manifest.d.ts +33 -0
  32. package/dist/src/implementations/claude/manifest.js +108 -0
  33. package/dist/src/implementations/claude/plugin-directory.d.ts +80 -0
  34. package/dist/src/implementations/claude/plugin-directory.js +137 -0
  35. package/dist/src/implementations/claude/session-continuation.d.ts +25 -0
  36. package/dist/src/implementations/claude/session-continuation.js +125 -0
  37. package/dist/src/implementations/claude/session-identity.d.ts +25 -0
  38. package/dist/src/implementations/claude/session-identity.js +31 -0
  39. package/dist/src/implementations/claude/session-start-cli.d.ts +2 -0
  40. package/dist/src/implementations/claude/session-start-cli.js +16 -0
  41. package/dist/src/implementations/claude/socket-delivery.d.ts +52 -0
  42. package/dist/src/implementations/claude/socket-delivery.js +159 -0
  43. package/dist/src/implementations/model.d.ts +162 -0
  44. package/dist/src/implementations/model.js +1 -0
  45. package/dist/src/implementations/opencode/adapter.d.ts +71 -0
  46. package/dist/src/implementations/opencode/adapter.js +94 -0
  47. package/dist/src/implementations/opencode/environment.d.ts +8 -0
  48. package/dist/src/implementations/opencode/environment.js +9 -0
  49. package/dist/src/implementations/opencode/manifest.d.ts +30 -0
  50. package/dist/src/implementations/opencode/manifest.js +93 -0
  51. package/dist/src/implementations/opencode/plugin-api.d.ts +120 -0
  52. package/dist/src/implementations/opencode/plugin-api.js +1 -0
  53. package/dist/src/implementations/opencode/plugin.d.ts +38 -0
  54. package/dist/src/implementations/opencode/plugin.js +303 -0
  55. package/dist/src/implementations/opencode/self-close-tool.d.ts +9 -0
  56. package/dist/src/implementations/opencode/self-close-tool.js +31 -0
  57. package/dist/src/implementations/orca/terminal.d.ts +48 -0
  58. package/dist/src/implementations/orca/terminal.js +154 -0
  59. package/dist/src/implementations/pi/broker-client.d.ts +56 -0
  60. package/dist/src/implementations/pi/broker-client.js +156 -0
  61. package/dist/src/implementations/pi/environment.d.ts +8 -0
  62. package/dist/src/implementations/pi/environment.js +9 -0
  63. package/dist/src/implementations/pi/extension.d.ts +57 -0
  64. package/dist/src/implementations/pi/extension.js +253 -0
  65. package/dist/src/implementations/pi/host-slots.d.ts +18 -0
  66. package/dist/src/implementations/pi/host-slots.js +27 -0
  67. package/dist/src/implementations/pi/manifest.d.ts +11 -0
  68. package/dist/src/implementations/pi/manifest.js +61 -0
  69. package/dist/src/implementations/pi/self-close-tool.d.ts +7 -0
  70. package/dist/src/implementations/pi/self-close-tool.js +27 -0
  71. package/dist/src/implementations/registry.d.ts +47 -0
  72. package/dist/src/implementations/registry.js +157 -0
  73. package/dist/src/implementations/session-key.d.ts +13 -0
  74. package/dist/src/implementations/session-key.js +19 -0
  75. package/dist/src/index.d.ts +27 -0
  76. package/dist/src/index.js +26 -0
  77. package/dist/src/launch/broker-lock.d.ts +257 -0
  78. package/dist/src/launch/broker-lock.js +644 -0
  79. package/dist/src/launch/cli.d.ts +37 -0
  80. package/dist/src/launch/cli.js +317 -0
  81. package/dist/src/launch/environment.d.ts +45 -0
  82. package/dist/src/launch/environment.js +48 -0
  83. package/dist/src/launch/launch.d.ts +235 -0
  84. package/dist/src/launch/launch.js +223 -0
  85. package/dist/src/launch/on-demand.d.ts +202 -0
  86. package/dist/src/launch/on-demand.js +435 -0
  87. package/dist/src/launch/runtime-composition.d.ts +88 -0
  88. package/dist/src/launch/runtime-composition.js +253 -0
  89. package/dist/src/launch/runtime.d.ts +79 -0
  90. package/dist/src/launch/runtime.js +196 -0
  91. package/dist/src/launch/service-system.d.ts +11 -0
  92. package/dist/src/launch/service-system.js +39 -0
  93. package/dist/src/launch/service.d.ts +173 -0
  94. package/dist/src/launch/service.js +492 -0
  95. package/dist/src/launch/skill.d.ts +57 -0
  96. package/dist/src/launch/skill.js +81 -0
  97. package/dist/src/launch/terminal.d.ts +26 -0
  98. package/dist/src/launch/terminal.js +43 -0
  99. package/dist/src/launch/workspace-branch.d.ts +10 -0
  100. package/dist/src/launch/workspace-branch.js +29 -0
  101. package/dist/src/messaging/delivery-client.d.ts +144 -0
  102. package/dist/src/messaging/delivery-client.js +261 -0
  103. package/dist/src/messaging/delivery.d.ts +75 -0
  104. package/dist/src/messaging/delivery.js +22 -0
  105. package/dist/src/messaging/inbox-socket.d.ts +98 -0
  106. package/dist/src/messaging/inbox-socket.js +278 -0
  107. package/dist/src/messaging/mcp.d.ts +53 -0
  108. package/dist/src/messaging/mcp.js +501 -0
  109. package/dist/src/messaging/notifications.d.ts +231 -0
  110. package/dist/src/messaging/notifications.js +489 -0
  111. package/dist/src/messaging/push-delivery.d.ts +77 -0
  112. package/dist/src/messaging/push-delivery.js +187 -0
  113. package/dist/src/messaging/tools.d.ts +11 -0
  114. package/dist/src/messaging/tools.js +102 -0
  115. package/dist/src/messaging/wake.d.ts +23 -0
  116. package/dist/src/messaging/wake.js +39 -0
  117. package/dist/src/operator/agent-projection.d.ts +102 -0
  118. package/dist/src/operator/agent-projection.js +249 -0
  119. package/dist/src/operator/commands.d.ts +50 -0
  120. package/dist/src/operator/commands.js +199 -0
  121. package/dist/src/operator/diagnostics.d.ts +21 -0
  122. package/dist/src/operator/diagnostics.js +22 -0
  123. package/dist/src/operator/protocol.d.ts +511 -0
  124. package/dist/src/operator/protocol.js +49 -0
  125. package/dist/src/operator/repository-system.d.ts +14 -0
  126. package/dist/src/operator/repository-system.js +150 -0
  127. package/dist/src/operator/repository.d.ts +79 -0
  128. package/dist/src/operator/repository.js +72 -0
  129. package/dist/src/operator/traffic-projection.d.ts +19 -0
  130. package/dist/src/operator/traffic-projection.js +229 -0
  131. package/dist/src/operator/transport.d.ts +87 -0
  132. package/dist/src/operator/transport.js +549 -0
  133. package/dist/src/operator/update-store.d.ts +53 -0
  134. package/dist/src/operator/update-store.js +162 -0
  135. package/dist/src/operator/updates.d.ts +94 -0
  136. package/dist/src/operator/updates.js +280 -0
  137. package/dist/src/operator/validation.d.ts +114 -0
  138. package/dist/src/operator/validation.js +916 -0
  139. package/dist/src/shared/guards.d.ts +59 -0
  140. package/dist/src/shared/guards.js +119 -0
  141. package/dist/src/shared/http.d.ts +30 -0
  142. package/dist/src/shared/http.js +50 -0
  143. package/dist/src/shared/row.d.ts +2 -0
  144. package/dist/src/shared/row.js +1 -0
  145. package/dist/src/shared/shell.d.ts +6 -0
  146. package/dist/src/shared/shell.js +8 -0
  147. package/dist/src/tui/actions.d.ts +166 -0
  148. package/dist/src/tui/actions.js +253 -0
  149. package/dist/src/tui/agent-tree.d.ts +125 -0
  150. package/dist/src/tui/agent-tree.js +663 -0
  151. package/dist/src/tui/app.d.ts +38 -0
  152. package/dist/src/tui/app.js +378 -0
  153. package/dist/src/tui/chrome.d.ts +86 -0
  154. package/dist/src/tui/chrome.js +85 -0
  155. package/dist/src/tui/client.d.ts +41 -0
  156. package/dist/src/tui/client.js +279 -0
  157. package/dist/src/tui/composition.d.ts +41 -0
  158. package/dist/src/tui/composition.js +146 -0
  159. package/dist/src/tui/message-views.d.ts +94 -0
  160. package/dist/src/tui/message-views.js +515 -0
  161. package/dist/src/tui/model.d.ts +414 -0
  162. package/dist/src/tui/model.js +758 -0
  163. package/dist/src/tui/presentation.d.ts +350 -0
  164. package/dist/src/tui/presentation.js +541 -0
  165. package/dist/src/tui/production.d.ts +79 -0
  166. package/dist/src/tui/production.js +253 -0
  167. package/dist/src/tui/run-terminal.d.ts +32 -0
  168. package/dist/src/tui/run-terminal.js +54 -0
  169. package/dist/src/tui/runtime.d.ts +113 -0
  170. package/dist/src/tui/runtime.js +189 -0
  171. package/dist/src/tui/theme.d.ts +49 -0
  172. package/dist/src/tui/theme.js +55 -0
  173. package/dist/src/tui/transport.d.ts +31 -0
  174. package/dist/src/tui/transport.js +255 -0
  175. package/dist/src/version.d.ts +11 -0
  176. package/dist/src/version.js +52 -0
  177. package/package.json +142 -0
  178. package/skills/pager/SKILL.md +112 -0
@@ -0,0 +1,2270 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve } from "node:path";
3
+ import { openBrokerDatabase, } from "./database.js";
4
+ import { decodeMessageBody, encodeMessageBody, issueOpaqueToken, assertAgentId, FETCH_LIMIT_DEFAULT, isAgentId, isDirectAgentAddress, mintAgentId, normalizeMessageSubject, normalizeMessageType, ProtocolError, validateImplementationId, validateSessionKey, } from "./protocol.js";
5
+ import { PAGER_DEFAULTS } from "../configuration-defaults.js";
6
+ import { publicEpoch } from "./snapshot.js";
7
+ import { normalizeAlias, parseAgentAddress, parseWorkspaceName, uniquePathNames, validateRegistrationSessionName, workspaceIdentity, workspaceTeamId, } from "./address.js";
8
+ import { ATTACHMENT_CHANNELS, BrokerSchema, MESSAGE_STATES } from "./schema.js";
9
+ import { assertDurableOperatorId, readStoredAgentId, readStoredEnum, BrokerOperatorProjection, } from "./operator-projection.js";
10
+ const notificationDisplay = {
11
+ delivered: { text: "notify delivered", tone: "ok" },
12
+ shed: { text: "notify shed — budget exceeded", tone: "warn" },
13
+ failed: { text: "notify failed", tone: "danger" },
14
+ deferred: { text: "notify deferred — agent busy", tone: "warn" },
15
+ };
16
+ /**
17
+ * Matches an identity that has attached at least once and has no live attachment, detached since
18
+ * before the second bound parameter; the first is the current time.
19
+ */
20
+ const DETACHED_SINCE_SQL = "not exists (select 1 from sessions s where s.agent_id = a.id and s.active = 1 and s.expires_at > ?) and exists (select 1 from sessions s where s.agent_id = a.id) and (select max(s.expires_at) from sessions s where s.agent_id = a.id) <= ?";
21
+ /**
22
+ * How long a detached, unnamed, resumable identity is retained before reaping. It outlives the
23
+ * attachment lease by orders of magnitude because a conversation is resumed by a person hours or
24
+ * days later, while the lease only measures whether a process is currently answering.
25
+ */
26
+ const RESUMABLE_RETENTION_MS = 86_400_000;
27
+ /** How long a named identity stays detached before the sweep archives it. */
28
+ const NAMED_ARCHIVE_AFTER_MS = 86_400_000;
29
+ /** How long an archived identity of any kind is kept, measured from its archival, before reaping. */
30
+ const ARCHIVED_RETENTION_MS = 30 * 24 * 60 * 60 * 1_000;
31
+ /**
32
+ * How long a detached, unnamed, resumable identity that has never sent or received a message is
33
+ * retained. Every directly started session registers one whether or not it will ever use a
34
+ * channel, so an identity with no traffic is not worth a day of the operator's attention; a
35
+ * conversation resumed after this window loses only an alias number it never used.
36
+ */
37
+ const UNUSED_RESUMABLE_RETENTION_MS = 300_000;
38
+ /**
39
+ * Bounds a message body when no configuration overrides it.
40
+ *
41
+ * Set well above `MESSAGE_BODY_LIMIT_BYTES`, which `encodeMessageBody` already enforces
42
+ * unconditionally: at this default, the check below only binds when a caller configures it tighter.
43
+ * A default below `MESSAGE_BODY_LIMIT_BYTES` would shed a body the protocol admits.
44
+ */
45
+ const DEFAULT_MAX_BODY_BYTES = 1_048_576;
46
+ /**
47
+ * Retains a settled message for a week before the sweep deletes it and its dependent rows.
48
+ *
49
+ * This is what bounds the database, and so the cost of every operator projection: the broker holds
50
+ * one retention window of traffic and nothing older.
51
+ */
52
+ const SETTLED_MESSAGE_RETENTION_MS = 7 * 24 * 60 * 60 * 1_000;
53
+ /** Lifecycle text for a message dead-lettered because its stored recipient identity cannot be read. */
54
+ const UNREADABLE_RECIPIENT_REASON = "dead-lettered — stored recipient identity is unreadable";
55
+ /**
56
+ * Attempt at which the redelivery delay stops doubling.
57
+ *
58
+ * Expressed as a count rather than a duration so the ceiling stays a fixed multiple of whatever
59
+ * base delay is configured, and the shape of the schedule does not change when the base is tuned.
60
+ */
61
+ const REDELIVERY_DOUBLING_LIMIT = 6;
62
+ /** Answer to a session the broker holds no reason to describe further: unknown, lapsed, or not proven to be the caller's. */
63
+ const SESSION_EXPIRED_MESSAGE = "MCP session expired; re-attach required";
64
+ /**
65
+ * Selects, with one `now` parameter, an ephemeral identity that never attached and holds no
66
+ * reservation still able to: nothing is or will be answering for it.
67
+ */
68
+ const UNATTACHED_EPHEMERAL_LAPSED_SQL = "a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 1 and not exists (select 1 from sessions s where s.agent_id = a.id) and not exists (select 1 from reservations r where r.agent_id = a.id and r.consumed_at is null and r.expires_at > ?)";
69
+ /**
70
+ * Selects, with one `now` parameter, an identity `a` something may be answering for: a named
71
+ * identity, or an ephemeral one that is attached now or has never attached.
72
+ */
73
+ const ANSWERABLE_AGENT_SQL = "(a.registration_ephemeral = 0 or not exists (select 1 from sessions s where s.agent_id = a.id) or exists (select 1 from sessions s where s.agent_id = a.id and s.active = 1 and s.expires_at > ?))";
74
+ /**
75
+ * Selects a message that counts as use of identity `a`: one it sent, or one sent to it alone. A
76
+ * broadcast copy it received was addressed to its workspace, not to it.
77
+ */
78
+ const MESSAGE_USING_AGENT_SQL = "select 1 from messages m where m.sender_id = a.id or (m.recipient_id = a.id and m.address_outcome != 'broadcast')";
79
+ /** Precedes the workspace name in the address every copy of a broadcast carries. */
80
+ const BROADCAST_ADDRESS_PREFIX = "*@";
81
+ /**
82
+ * Precedes the target workspace and the caller's key in a broadcast's stored idempotency key, so a
83
+ * broadcast never matches a direct send that carried the same key.
84
+ */
85
+ const BROADCAST_KEY_PREFIX = "broadcast:";
86
+ /** Durable single-writer SQLite broker for Pager. */
87
+ export class Broker {
88
+ #db;
89
+ #now;
90
+ #visibilityTimeoutMs;
91
+ #redeliveryBaseDelayMs;
92
+ #redeliveryGiveUpMs;
93
+ #reservationTtlMs;
94
+ #leaseTtlMs;
95
+ #resumableRetentionMs;
96
+ #namedArchiveAfterMs;
97
+ #archivedRetentionMs;
98
+ #unusedResumableRetentionMs;
99
+ #epoch;
100
+ #perPairRateLimit;
101
+ #perPairRateWindowMs;
102
+ #maxBodyBytes;
103
+ #maxInboxMessages;
104
+ #epochOwner;
105
+ #lifecycleSynchronization;
106
+ #workspaceBranch;
107
+ #report;
108
+ #projection;
109
+ #commitListeners = new Set();
110
+ #writeStatements = new Map();
111
+ #writtenTables;
112
+ #declaredRowChanges = 0;
113
+ /**
114
+ * Opens a durable broker database with the supplied timing and routing policies.
115
+ * @param path - SQLite database location, or memory sentinel, for durable state.
116
+ * @param options - Clock, workspace-branch lookup, and diagnostics sink are required; see {@link BrokerOptions} for its optional fields.
117
+ */
118
+ constructor(path, options) {
119
+ this.#db = (options.openDatabase ?? openBrokerDatabase)(path);
120
+ this.#now = options.now;
121
+ this.#workspaceBranch = options.workspaceBranch;
122
+ this.#report = options.report;
123
+ this.#visibilityTimeoutMs = options.visibilityTimeoutMs ?? PAGER_DEFAULTS.visibilityTimeoutMs;
124
+ this.#redeliveryBaseDelayMs = options.redeliveryBaseDelayMs ?? PAGER_DEFAULTS.redeliveryBaseDelayMs;
125
+ this.#redeliveryGiveUpMs = options.redeliveryGiveUpMs ?? PAGER_DEFAULTS.redeliveryGiveUpMs;
126
+ this.#reservationTtlMs = options.reservationTtlMs ?? PAGER_DEFAULTS.reservationTtlMs;
127
+ this.#leaseTtlMs = options.leaseTtlMs ?? PAGER_DEFAULTS.leaseTtlMs;
128
+ this.#resumableRetentionMs = options.resumableRetentionMs ?? RESUMABLE_RETENTION_MS;
129
+ this.#namedArchiveAfterMs = options.namedArchiveAfterMs ?? NAMED_ARCHIVE_AFTER_MS;
130
+ this.#archivedRetentionMs = options.archivedRetentionMs ?? ARCHIVED_RETENTION_MS;
131
+ this.#unusedResumableRetentionMs = options.unusedResumableRetentionMs ?? UNUSED_RESUMABLE_RETENTION_MS;
132
+ this.#perPairRateLimit = options.perPairRateLimit ?? PAGER_DEFAULTS.perPairRateLimit;
133
+ this.#perPairRateWindowMs = options.perPairRateWindowMs ?? PAGER_DEFAULTS.perPairRateWindowMs;
134
+ this.#maxBodyBytes = options.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES;
135
+ this.#maxInboxMessages = options.maxInboxMessages ?? PAGER_DEFAULTS.maxInboxMessages;
136
+ this.#epochOwner = options.epochOwner ?? true;
137
+ this.#lifecycleSynchronization = options.lifecycleSynchronization;
138
+ // A constructor that throws hands its caller nothing to close, so the connection is closed here.
139
+ try {
140
+ this.#epoch = new BrokerSchema(this.#db, {
141
+ now: this.#now,
142
+ epochOwner: this.#epochOwner,
143
+ report: (message) => this.#reportSafely(message),
144
+ }).prepare();
145
+ this.#projection = new BrokerOperatorProjection(this.#db, this.#now);
146
+ }
147
+ catch (error) {
148
+ this.#db.close();
149
+ throw error;
150
+ }
151
+ }
152
+ /** Close the broker database connection. */
153
+ close() {
154
+ this.#db.close();
155
+ }
156
+ /**
157
+ * Report the durability settings of the connection that owns broker writes.
158
+ *
159
+ * `synchronous` and `busy_timeout` are connection-local, so a second connection opened elsewhere
160
+ * cannot attest to them and must ask the running service instead.
161
+ * @returns - Journal mode, synchronous level, busy timeout, and foreign-key enforcement.
162
+ */
163
+ durability() {
164
+ return this.#db.durability();
165
+ }
166
+ /**
167
+ * Report broker generation and durability without projecting any traffic.
168
+ *
169
+ * Liveness callers poll this on a deadline, so it must not depend on how much history the
170
+ * database holds.
171
+ * @param generatedAt - Observation time to stamp, defaulting to now.
172
+ * @returns - Epoch, observation time, and live durability settings.
173
+ */
174
+ snapshotHealth(generatedAt = this.#now()) {
175
+ return {
176
+ brokerEpoch: publicEpoch(this.#epoch),
177
+ generatedAt,
178
+ durability: this.#db.durability(),
179
+ };
180
+ }
181
+ /**
182
+ * Return the current singleton epoch for local service ownership.
183
+ * @returns - Opaque generation identifier owned by this broker process.
184
+ */
185
+ epoch() {
186
+ return this.#epoch;
187
+ }
188
+ /**
189
+ * Observe successful broker commits after their SQLite transaction is durable.
190
+ * @param listener - Isolated observer that cannot change the committed caller outcome.
191
+ * @returns A callback that removes this observer.
192
+ */
193
+ subscribeCommits(listener) {
194
+ this.#commitListeners.add(listener);
195
+ return () => this.#commitListeners.delete(listener);
196
+ }
197
+ /**
198
+ * Register a concrete agent under one workspace-unique session name.
199
+ * @param args - Caller-supplied identity, topology, and implementation values.
200
+ * @returns - Stable agent identity registered in the requested team.
201
+ */
202
+ registerAgent(args) {
203
+ const workspacePath = resolve(args.workspacePath);
204
+ const identity = this.#registrationIdentity(args.agentId, args.sessionName, args.implementation);
205
+ // Validated before the port runs, so a rejected registration never pays for a git subprocess.
206
+ const branch = this.#currentWorkspaceBranch(workspacePath);
207
+ return this.#transaction(() => this.#registerAgent(args, identity, branch));
208
+ }
209
+ /**
210
+ * Register or rejoin an implementation identity without a prior launch reservation.
211
+ *
212
+ * A `sessionKey` makes the identity resumable: the same harness conversation returning after a
213
+ * restart resolves to the identity it already owns, keeping its alias, inbox, and traffic, whatever
214
+ * the caller passes as `sessionName`. Without one, rejoining still falls back to matching the alias.
215
+ *
216
+ * A `continuesSessionKey` is the key a conversation was known by before it continued under
217
+ * `sessionKey`. When `sessionKey` owns no identity yet, the identity the continued key owns moves
218
+ * to `sessionKey`, its holder displaced, so the alias, inbox, and traffic follow the conversation.
219
+ * @param args - Validated workspace, implementation, optional alias, optional resumable key, and
220
+ * the key of the conversation that one continues, which requires the resumable key.
221
+ * @returns - Stable agent identity, its current alias, and a fresh single-use attachment reservation.
222
+ */
223
+ registerAgentWithoutReservation(args) {
224
+ const workspacePath = resolve(args.workspacePath);
225
+ const workspaceId = workspaceIdentity(workspacePath);
226
+ const teamId = workspaceTeamId(workspacePath);
227
+ const ephemeral = args.sessionName === undefined;
228
+ validateImplementationId(args.implementation.id);
229
+ const sessionKey = args.sessionKey === undefined ? undefined : validateSessionKey(args.sessionKey);
230
+ const continuesSessionKey = args.continuesSessionKey === undefined ? undefined : validateSessionKey(args.continuesSessionKey);
231
+ if (continuesSessionKey !== undefined && sessionKey === undefined) {
232
+ throw new ProtocolError("validation_failed", "A continued session key requires the conversation's own session key");
233
+ }
234
+ // A cheap read, ahead of the branch lookup below and outside its transaction, so a session key
235
+ // that will resume an existing identity can skip that lookup entirely: the resumable path below
236
+ // never writes a workspace row, so a branch read for it would only pay for a discarded answer.
237
+ // Re-read inside the transaction, since this and the branch lookup can both race a row that
238
+ // starts or stops resolving between here and there; harmless either way, per #ensureWorkspaceAndTeam.
239
+ const resumableAhead = sessionKey
240
+ ? this.#agentForSessionKey(workspaceId, args.implementation.id, sessionKey)
241
+ : undefined;
242
+ // Read before the transaction opens: the port's real implementation is a subprocess, and holding
243
+ // SQLite's write lock across it stalls every other connection for as long as git takes.
244
+ const branch = resumableAhead ? null : this.#currentWorkspaceBranch(workspacePath);
245
+ return this.#transaction(() => {
246
+ const owned = sessionKey ? this.#agentForSessionKey(workspaceId, args.implementation.id, sessionKey) : undefined;
247
+ const continued = sessionKey && !owned && continuesSessionKey
248
+ ? this.#agentForSessionKey(workspaceId, args.implementation.id, continuesSessionKey)
249
+ : undefined;
250
+ if (sessionKey && continued)
251
+ this.#bindSessionKey(continued.agentId, sessionKey, false);
252
+ const resumable = owned ?? continued;
253
+ if (resumable) {
254
+ this.#displaceHolder(resumable.agentId);
255
+ return {
256
+ agentId: resumable.agentId,
257
+ reservation: this.#reserveAttachment(resumable.agentId, "messaging"),
258
+ sessionName: resumable.sessionName,
259
+ ephemeral: resumable.ephemeral,
260
+ resumed: true,
261
+ };
262
+ }
263
+ // Validated here rather than beside the key and the implementation id, because a resume never
264
+ // reaches this line: a returning conversation keeps the alias it already owns, and refusing its
265
+ // request over a name it will not be given would cost it that identity, its inbox and its traffic.
266
+ const sessionName = validateRegistrationSessionName(args.sessionName ?? this.#nextImplementationAlias(workspaceId, args.implementation.id));
267
+ const agentId = this.#registerAgentWithoutReservation({
268
+ workspacePath,
269
+ workspaceId,
270
+ teamId,
271
+ sessionName,
272
+ ephemeral,
273
+ implementation: args.implementation,
274
+ branch,
275
+ });
276
+ if (sessionKey)
277
+ this.#bindSessionKey(agentId, sessionKey, true);
278
+ return {
279
+ agentId,
280
+ reservation: this.#reserveAttachment(agentId, "messaging"),
281
+ sessionName,
282
+ ephemeral,
283
+ resumed: false,
284
+ };
285
+ });
286
+ }
287
+ /**
288
+ * Replace an agent's routable alias and durable reconnection name.
289
+ * @param agentId - Authenticated identity whose name is changing.
290
+ * @param requestedAlias - Workspace-unique name, with an optional leading at-sign.
291
+ * @returns - Current identity and alias history after the replacement.
292
+ */
293
+ updateAgentAlias(agentId, requestedAlias) {
294
+ const sessionName = validateRegistrationSessionName(requestedAlias.replace(/^@/u, ""), "agent alias");
295
+ return this.#transaction(() => {
296
+ const agent = this.#activeAgent(agentId);
297
+ this.#lifecycleSynchronization?.checkpoint("agent_alias_active");
298
+ const workspaceId = String(agent.workspace_id);
299
+ this.#assertNameAvailable(workspaceId, sessionName, agentId);
300
+ const currentAlias = normalizeAlias(String(agent.registration_name));
301
+ const nextAlias = normalizeAlias(sessionName);
302
+ if (currentAlias !== nextAlias) {
303
+ this.#write("insert or ignore into alias_history(workspace_id, alias, agent_id) values (?, ?, ?)", workspaceId, currentAlias, agentId);
304
+ this.#write("delete from alias_history where workspace_id = ? and alias = ? and agent_id = ?", workspaceId, nextAlias, agentId);
305
+ }
306
+ this.#write("update agents set registration_name = ?, registration_ephemeral = 0 where id = ?", sessionName, agentId);
307
+ this.#lifecycleSynchronization?.checkpoint("agent_alias_updated");
308
+ const previous = this.#db
309
+ .prepare("select alias from alias_history where workspace_id = ? and agent_id = ? order by id")
310
+ .all(workspaceId, agentId);
311
+ return {
312
+ id: agentId,
313
+ alias: normalizeAlias(sessionName),
314
+ previousAliases: previous.map((row) => String(row.alias)),
315
+ };
316
+ });
317
+ }
318
+ /**
319
+ * Return the authenticated identity, current alias, and prior routable names.
320
+ * @param agentId - Stable identity to inspect.
321
+ * @returns - Identity details safe to return to the owning agent.
322
+ */
323
+ agentIdentity(agentId) {
324
+ const agent = this.#agent(agentId);
325
+ const previous = this.#db
326
+ .prepare("select alias from alias_history where workspace_id = ? and agent_id = ? order by id")
327
+ .all(String(agent.workspace_id), agentId);
328
+ return {
329
+ id: agentId,
330
+ alias: normalizeAlias(String(agent.registration_name)),
331
+ previousAliases: previous.map((row) => String(row.alias)),
332
+ };
333
+ }
334
+ /**
335
+ * Reserve an identity and single-use attach reservation for a future process.
336
+ * @param args - Registration name, team, and workspace the reservation is created under.
337
+ * @returns - Reservation identity and one-time reservation for the future agent.
338
+ */
339
+ reserveAgent(args) {
340
+ const workspacePath = resolve(args.workspacePath);
341
+ // A reservation is made for a process that has not started, so a caller with no name to give may
342
+ // send an empty one and mean "derive it"; only a name with content is held to the alias rule.
343
+ const requestedName = args.sessionName === undefined || args.sessionName === "" ? undefined : args.sessionName;
344
+ const identity = this.#registrationIdentity(undefined, requestedName, args.implementation);
345
+ // Validated before the port runs, so a rejected reservation never pays for a git subprocess.
346
+ const branch = this.#currentWorkspaceBranch(workspacePath);
347
+ return this.#transaction(() => {
348
+ const agentId = this.#registerAgent({
349
+ teamId: args.teamId,
350
+ parentId: args.parentId,
351
+ workspacePath: args.workspacePath,
352
+ ...(args.implementation !== undefined ? { implementation: args.implementation } : {}),
353
+ }, identity, branch);
354
+ return { agentId, reservation: this.#reserveAttachment(agentId, "messaging") };
355
+ });
356
+ }
357
+ /**
358
+ * Issue an additional single-use attach reservation for an existing broker identity.
359
+ * @param agentId - Registered agent the new reservation attaches.
360
+ * @param channel - Attachment channel the reservation may occupy; a new session evicts only its own channel.
361
+ * @returns - One-time reservation that can attach the registered agent.
362
+ */
363
+ reserveAttachment(agentId, channel = "messaging") {
364
+ return this.#transaction(() => this.#reserveAttachment(agentId, channel));
365
+ }
366
+ /**
367
+ * Consume a single-use reservation and bind it to an agent identity plus fenced session.
368
+ *
369
+ * A launch reserves an identity before the harness has started, so it cannot know which conversation
370
+ * will occupy it. Presenting `sessionKey` here closes that gap: an unclaimed key is bound to the
371
+ * reserved identity, and a key already held by another identity attaches to its owner instead, so
372
+ * relaunching a harness against an existing conversation rejoins rather than forking a duplicate.
373
+ *
374
+ * A durable session name outlives the conversation that first claimed it. When a new conversation
375
+ * rejoins that name, it takes the identity over: the prior session is deactivated and loses its
376
+ * fencing token, rather than the attach being refused and the caller left with no channel at all.
377
+ * @param reservationId - One-time secret used to attach a reserved agent.
378
+ * @param options - Optional resumable session key presented by the attaching conversation.
379
+ * @returns - Attached identity, which is the key's owner when adoption occurred, plus its session.
380
+ */
381
+ attach(reservationId, options = {}) {
382
+ return this.#transaction(() => this.#attach(reservationId, options));
383
+ }
384
+ /**
385
+ * Runs the attach logic inside a caller-supplied transaction; see {@link attach}.
386
+ * @param reservationId - One-time secret used to attach a reserved agent.
387
+ * @param options - Optional session key under which the attaching identity should be resolved.
388
+ * @returns - Attached session identity and the epoch that issued it.
389
+ */
390
+ #attach(reservationId, options = {}) {
391
+ if (!this.#writtenTables)
392
+ throw new Error("Broker#attach must run inside #transaction");
393
+ const sessionKey = options.sessionKey === undefined ? undefined : validateSessionKey(options.sessionKey);
394
+ const reservation = this.#db.prepare("select * from reservations where reservation_id = ?").get(reservationId);
395
+ if (!reservation)
396
+ throw new ProtocolError("validation_failed", "Attach reservation is unknown");
397
+ if (typeof reservation.consumed_at === "number")
398
+ throw new ProtocolError("conflict", "Attach reservation is already consumed");
399
+ if (Number(reservation.expires_at) <= this.#now())
400
+ throw new ProtocolError("expired", "Attach reservation is expired");
401
+ // Hard-fail: a reservation naming an unreadable agent cannot attach anything, and this answers one caller.
402
+ const reserved = readStoredAgentId(reservation.agent_id, "reservations.agent_id");
403
+ const agentId = sessionKey ? this.#resolveSessionKeyAtAttach(reserved, sessionKey) : reserved;
404
+ this.#activeAgent(agentId);
405
+ const channel = readStoredEnum(reservation.channel, ATTACHMENT_CHANNELS, "reservations.channel");
406
+ const sessionId = issueOpaqueToken("session");
407
+ const fencingToken = issueOpaqueToken("session");
408
+ this.#write("update reservations set consumed_at = ? where reservation_id = ?", this.#now(), reservationId);
409
+ this.#write("update sessions set active = 0 where agent_id = ? and channel = ?", agentId, channel);
410
+ // Attaching is how an archived identity comes back: the operator put it away, and the
411
+ // conversation returning under the same key or name takes it out again.
412
+ this.#write("update agents set archived_at = null where id = ? and archived_at is not null", agentId);
413
+ this.#write("insert into sessions(id, agent_id, channel, fencing_token, expires_at, active, created_at, epoch) values (?, ?, ?, ?, ?, 1, ?, ?)", sessionId, agentId, channel, fencingToken, this.#now() + this.#leaseTtlMs, this.#now(), this.#epoch);
414
+ return { agentId, sessionId, fencingToken, epoch: this.#epoch };
415
+ }
416
+ /**
417
+ * Claim a conversation key for an already attached identity, or move to the identity that holds it.
418
+ *
419
+ * A harness whose channels are attached by the editor rather than by us cannot present its key at
420
+ * attach time, so it says who it is once it is connected. When the key belongs to another identity,
421
+ * that identity is the real one: this one is a launch artefact, and it is removed along with its
422
+ * connections after its outstanding reservations move across. An identity that has already sent or
423
+ * received a message is never removed: the call throws `conflict` and nothing is bound.
424
+ * @param agentId - Authenticated identity making the claim.
425
+ * @param sessionKey - Resumable key identifying the caller's conversation.
426
+ * @returns - Resolved identity, and fresh attachments for both channels when it moved.
427
+ */
428
+ adoptSessionKey(agentId, sessionKey) {
429
+ const key = validateSessionKey(sessionKey);
430
+ return this.#transaction(() => {
431
+ const agent = this.#db
432
+ .prepare("select workspace_id, implementation_id from agents where id = ? and state = 'active'")
433
+ .get(agentId);
434
+ if (!agent)
435
+ throw new ProtocolError("conflict", "Only an active agent identity can claim a session key");
436
+ if (typeof agent.implementation_id !== "string") {
437
+ throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
438
+ }
439
+ const owner = this.#agentForSessionKey(String(agent.workspace_id), agent.implementation_id, key);
440
+ if (!owner || owner.agentId === agentId) {
441
+ this.#bindSessionKey(agentId, key, false);
442
+ return { agentId, adopted: false };
443
+ }
444
+ this.#displaceHolder(owner.agentId);
445
+ this.#discardUnusedIdentity(agentId, owner.agentId, { allowConnections: true });
446
+ return {
447
+ agentId: owner.agentId,
448
+ adopted: true,
449
+ reservation: this.#reserveAttachment(owner.agentId, "messaging"),
450
+ deliveryReservation: this.#reserveAttachment(owner.agentId, "delivery"),
451
+ };
452
+ });
453
+ }
454
+ /**
455
+ * Renew an active attachment when its complete broker-issued identity is presented.
456
+ * @param sessionId - Attached session identity being renewed.
457
+ * @param fencingToken - Token proving ownership of the active attachment generation.
458
+ * @param epoch - Broker generation that issued the attachment.
459
+ * @returns - Agent identity whose durable attachment lease was renewed.
460
+ */
461
+ renewAttachment(sessionId, fencingToken, epoch) {
462
+ return this.#transaction(() => {
463
+ const row = this.#activeAttachment(sessionId, fencingToken, epoch);
464
+ this.#lifecycleSynchronization?.checkpoint("attachment_renewal_active");
465
+ this.#write("update sessions set expires_at = ? where id = ?", this.#now() + this.#leaseTtlMs, sessionId);
466
+ return readStoredAgentId(row.agent_id, "sessions.agent_id");
467
+ });
468
+ }
469
+ /**
470
+ * Close one active attachment by its own session identity, leaving any other session of the same
471
+ * agent untouched.
472
+ *
473
+ * No production path calls this: a harness ends every attachment of its identity together, through
474
+ * `releaseAttachments` or a lapsed lease. It stays as the one way a test can end a single session
475
+ * of a multi-session agent without ending the others, which neither of those offers.
476
+ * @param sessionId - Attached session identity being closed.
477
+ * @param fencingToken - Token proving ownership of the active attachment generation.
478
+ * @param epoch - Broker generation that issued the attachment.
479
+ * @internal
480
+ */
481
+ closeAttachment(sessionId, fencingToken, epoch) {
482
+ this.#transaction(() => {
483
+ this.#activeAttachment(sessionId, fencingToken, epoch);
484
+ this.#lifecycleSynchronization?.checkpoint("attachment_close_active");
485
+ this.#write("update sessions set active = 0 where id = ?", sessionId);
486
+ });
487
+ }
488
+ /**
489
+ * End every active attachment of an agent whose harness is shutting down, leaving the identity in place.
490
+ *
491
+ * The caller must have authenticated as this agent over one of those attachments. This is the graceful
492
+ * shutdown path for a harness whose messaging attachment is held by a client the connector cannot
493
+ * reach into, so an attachment the connector never opened still ends before its lease lapses.
494
+ * @param agentId - Identity whose harness is shutting down.
495
+ */
496
+ releaseAttachments(agentId) {
497
+ this.#transaction(() => {
498
+ this.#agent(agentId);
499
+ this.#displaceHolder(agentId);
500
+ });
501
+ }
502
+ /**
503
+ * Persist terminal disposition and detach every active connection for an agent.
504
+ * @param agentId - Stable identity whose process reached terminal disposition.
505
+ */
506
+ markAgentTerminal(agentId) {
507
+ this.#transaction(() => {
508
+ this.#agent(agentId);
509
+ this.#lifecycleSynchronization?.checkpoint("agent_terminal_present");
510
+ this.#write("update agents set state = 'terminal' where id = ?", agentId);
511
+ this.#write("update sessions set active = 0 where agent_id = ?", agentId);
512
+ });
513
+ }
514
+ /** Enqueue terminal outcomes for parents and persist the completed disposition. */
515
+ reapTerminalAgents() {
516
+ const terminal = this.#db
517
+ .prepare("select rowid, id, parent_id from agents where state = 'terminal' order by id")
518
+ .all();
519
+ for (const row of terminal) {
520
+ // Settle-and-name: a terminal row makes `nextSweepDelayMs` answer zero, so a skip alone would
521
+ // spin. The parent notification and the session close do not run; both need the unreadable id.
522
+ const agentId = this.#readableAgentId(row.id, "agents.id", String(row.rowid));
523
+ if (agentId === null) {
524
+ this.#transaction(() => {
525
+ this.#write("update agents set state = 'reaped' where rowid = ?", Number(row.rowid));
526
+ });
527
+ continue;
528
+ }
529
+ if (typeof row.parent_id === "string")
530
+ this.#notifyParent(agentId, row.parent_id, "terminal", `agent-terminal:${agentId}`);
531
+ this.#transaction(() => {
532
+ const current = this.#db.prepare("select id from agents where id = ? and state = 'terminal'").get(agentId);
533
+ if (!current)
534
+ return;
535
+ this.#lifecycleSynchronization?.checkpoint("terminal_reap_selected");
536
+ this.#reapAgent(agentId);
537
+ });
538
+ }
539
+ this.#reapExpiredEphemeralAgents();
540
+ this.#pruneSettledMessages();
541
+ }
542
+ /**
543
+ * Report how long until a sweep could next find work.
544
+ *
545
+ * A sweep scheduled from this value does nothing on an idle broker. Zero means work is already due.
546
+ * @returns - Delay in milliseconds, or null when nothing is pending.
547
+ */
548
+ nextSweepDelayMs() {
549
+ const now = this.#now();
550
+ const terminal = this.#db.prepare("select 1 as pending from agents where state = 'terminal' limit 1").get();
551
+ if (terminal)
552
+ return 0;
553
+ const deadlines = [
554
+ this.#earliestDeadline(
555
+ // Mirrors #reapExpiredEphemeralAgents: only a live session defers the sweep, so an agent
556
+ // whose attachment was closed while its lease still ran is due now, not at that lease.
557
+ // An archived identity is left out of both agent deadlines and due when its retention window ends.
558
+ "select min(deadline) as deadline from (select coalesce(max(case when s.active = 1 then s.expires_at end), 0) as deadline from agents a join sessions s on s.agent_id = a.id where a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 1 and a.session_key is null group by a.id)"),
559
+ this.#earliestDeadline(
560
+ // Mirrors the never-attached reap: due when the last unconsumed reservation lapses, or now
561
+ // when none is left.
562
+ "select min(deadline) as deadline from (select coalesce(max(case when r.consumed_at is null then r.expires_at end), 0) as deadline from agents a left join reservations r on r.agent_id = a.id where a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 1 and not exists (select 1 from sessions s where s.agent_id = a.id) group by a.id)"),
563
+ this.#earliestDeadline("select min(deadline) as deadline from (select max(s.expires_at) + ? as deadline from agents a join sessions s on s.agent_id = a.id where a.state = 'active' and a.archived_at is null and a.session_key is not null and a.registration_ephemeral = 1 group by a.id)", this.#resumableRetentionMs),
564
+ this.#earliestDeadline(`select min(deadline) as deadline from (select max(s.expires_at) + ? as deadline from agents a join sessions s on s.agent_id = a.id where a.state = 'active' and a.archived_at is null and a.session_key is not null and a.registration_ephemeral = 1 and not exists (${MESSAGE_USING_AGENT_SQL}) group by a.id)`, this.#unusedResumableRetentionMs),
565
+ this.#earliestDeadline("select min(deadline) as deadline from (select max(s.expires_at) + ? as deadline from agents a join sessions s on s.agent_id = a.id where a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 0 group by a.id)", this.#namedArchiveAfterMs),
566
+ this.#earliestDeadline("select min(archived_at) + ? as deadline from agents where state = 'active' and archived_at is not null", this.#archivedRetentionMs),
567
+ this.#earliestDeadline("select min(created_at) + ? as deadline from messages where state in ('acknowledged', 'expired', 'dead_lettered', 'failed')", SETTLED_MESSAGE_RETENTION_MS),
568
+ this.#earliestDeadline("select min(next_wake_at) as deadline from wakes"),
569
+ this.#earliestDeadline("select min(claim_expires_at) as deadline from messages where state = 'claimed'"),
570
+ ].filter((deadline) => deadline !== null);
571
+ if (deadlines.length === 0)
572
+ return null;
573
+ return Math.max(0, Math.min(...deadlines) - now);
574
+ }
575
+ #earliestDeadline(sql, ...parameters) {
576
+ const row = this.#db.prepare(sql).get(...parameters);
577
+ return typeof row?.deadline === "number" ? row.deadline : null;
578
+ }
579
+ /**
580
+ * Tell a parent what became of its child, without letting an unreachable parent stop the sweep.
581
+ *
582
+ * The notification is sent from the child and so must precede any reap, which leaves it unable to
583
+ * send. A parent that has itself been reaped or detached is unroutable, and an escaping throw
584
+ * would leave the child terminal forever, which a deadline-scheduled sweep retries without ever
585
+ * draining.
586
+ *
587
+ * The two outcomes scope their idempotency differently because they recur differently. An identity
588
+ * reaches `terminal` once and stays there, so its key is the child alone and a terminal row still
589
+ * present on a later tick is suppressed — until the notification itself passes the retention window
590
+ * and `#pruneSettledMessages` deletes the idempotency row naming it, after which the same key sends
591
+ * again. An inbox can be dead-lettered more than once, because {@link reapExpiredReservations} is
592
+ * called from outside the sweep, and a child can hold a reservation on each attachment channel; so
593
+ * that key names the reap it belongs to, collapsing the several rows one reap finds while leaving a
594
+ * later one free to report itself. Each reap is identified by a token minted per call, so two reaps
595
+ * within one millisecond stay two outcomes.
596
+ * @param agentId - Child identity the outcome happened to.
597
+ * @param parentId - Parent to notify.
598
+ * @param outcome - Which outcome to report, selecting the subject and type.
599
+ * @param idempotencyKey - Scope over which repeats of this outcome collapse into one notification.
600
+ */
601
+ #notifyParent(agentId, parentId, outcome, idempotencyKey) {
602
+ const notification = outcome === "terminal"
603
+ ? { subject: "Agent session ended", type: "terminal", log: `${agentId} ended` }
604
+ : { subject: "Agent inbox dead-lettered", type: "dead", log: `${agentId}'s inbox was dead-lettered` };
605
+ try {
606
+ this.send({
607
+ from: agentId,
608
+ to: parentId,
609
+ subject: notification.subject,
610
+ type: notification.type,
611
+ body: { agentId },
612
+ idempotencyKey,
613
+ });
614
+ }
615
+ catch (error) {
616
+ const cause = error instanceof Error ? error.message : String(error);
617
+ this.#reportSafely(`pager: ${notification.log} and its parent ${parentId} could not be told (${cause})`);
618
+ }
619
+ }
620
+ /**
621
+ * Remove settled messages past the retention window along with every row that depends on them.
622
+ *
623
+ * An idempotency record is deleted with its message: a key outliving the message it names would
624
+ * answer a retry with an identifier that no longer resolves.
625
+ */
626
+ #pruneSettledMessages() {
627
+ this.#transaction(() => {
628
+ const cutoff = this.#now() - SETTLED_MESSAGE_RETENTION_MS;
629
+ const settled = this.#db
630
+ .prepare("select id from messages where state in ('acknowledged', 'expired', 'dead_lettered', 'failed') and created_at <= ?")
631
+ .all(cutoff);
632
+ for (const row of settled) {
633
+ const messageId = String(row.id);
634
+ this.#write("delete from message_events where message_id = ?", messageId);
635
+ this.#write("delete from claims where message_id = ?", messageId);
636
+ this.#write("delete from idempotency where message_id = ?", messageId);
637
+ this.#write("delete from messages where id = ?", messageId);
638
+ }
639
+ });
640
+ }
641
+ /**
642
+ * Resume an MCP attachment while rotating a lapsed or prior-epoch session generation.
643
+ * @param request - Session, fencing proof, and last-seen generation the caller resumes with.
644
+ * @returns - Current attachment credentials and the stable owning agent identity.
645
+ */
646
+ resumeSession({ sessionId, fencingToken, epoch }) {
647
+ return this.#transaction(() => {
648
+ const row = this.#db.prepare("select * from sessions where id = ?").get(sessionId);
649
+ if (!row)
650
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
651
+ if (Number(row.active) === 0) {
652
+ // A losing racer's fencing token still proves the same attachment as whichever caller won the
653
+ // rotation below, so it is walked forward instead of refused. Bounded to one lease past when
654
+ // THIS row was rotated away, not past its own lease: elapsed wall time since last renewal
655
+ // includes any broker downtime, which the caller had no opportunity to renew through, and
656
+ // downtime longer than one lease is the ordinary case a restart produces. The rotation event is
657
+ // what a race is relative to, so it is the only clock this bound may read.
658
+ if (!fencingToken || fencingToken !== String(row.fencing_token))
659
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
660
+ if (row.superseded_at === null || Number(row.superseded_at) + this.#leaseTtlMs <= this.#now())
661
+ throw this.#sessionEndedError(row);
662
+ const live = this.#liveSuccessor(row);
663
+ return this.#rotateOrRenew(live, String(live.epoch) !== this.#epoch, true);
664
+ }
665
+ return this.#rotateOrRenew(row, (epoch !== undefined && epoch !== this.#epoch) || String(row.epoch) !== this.#epoch, fencingToken !== undefined && fencingToken === String(row.fencing_token));
666
+ });
667
+ }
668
+ /**
669
+ * Renew a live row's lease, or rotate it to a fresh generation, by the same trigger the direct and
670
+ * walked-forward resume paths both apply: an already-lapsed lease, or a stale epoch. Ownership is
671
+ * checked once, ahead of that choice, so neither branch can return a fencing token — the row's
672
+ * existing one or a freshly minted one — to a caller who has not proven it holds the current one.
673
+ * @param row - Live session row to renew or rotate.
674
+ * @param epochStale - Whether the epoch this row is pinned to is behind the broker's current one.
675
+ * @param ownershipProven - Whether the caller has already been shown to hold the fencing token for
676
+ * this exact row (directly) or for a superseded ancestor of it (walked forward).
677
+ * @returns - Current attachment credentials for the row or its replacement.
678
+ */
679
+ #rotateOrRenew(row, epochStale, ownershipProven) {
680
+ if (!ownershipProven)
681
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
682
+ // Only a sweep archive reaches here archived: an operator archive ends every attachment.
683
+ this.#write("update agents set archived_at = null where id = ? and archived_at is not null", String(row.agent_id));
684
+ const rotate = Number(row.expires_at) <= this.#now() || epochStale;
685
+ if (!rotate) {
686
+ this.#write("update sessions set expires_at = ? where id = ?", this.#now() + this.#leaseTtlMs, String(row.id));
687
+ return {
688
+ agentId: readStoredAgentId(row.agent_id, "sessions.agent_id"),
689
+ sessionId: String(row.id),
690
+ fencingToken: String(row.fencing_token),
691
+ epoch: this.#epoch,
692
+ };
693
+ }
694
+ const replacementSessionId = issueOpaqueToken("session");
695
+ const replacementFencingToken = issueOpaqueToken("session");
696
+ this.#write("update sessions set active = 0, superseded_by = ?, superseded_at = ? where id = ?", replacementSessionId, this.#now(), String(row.id));
697
+ this.#write("insert into sessions(id, agent_id, channel, fencing_token, expires_at, active, created_at, epoch) values (?, ?, ?, ?, ?, 1, ?, ?)", replacementSessionId, String(row.agent_id), String(row.channel), replacementFencingToken, this.#now() + this.#leaseTtlMs, this.#now(), this.#epoch);
698
+ return {
699
+ agentId: readStoredAgentId(row.agent_id, "sessions.agent_id"),
700
+ sessionId: replacementSessionId,
701
+ fencingToken: replacementFencingToken,
702
+ epoch: this.#epoch,
703
+ };
704
+ }
705
+ /**
706
+ * Follow a superseded session's rotation lineage to the generation currently active for it.
707
+ * @param row - Superseded row whose fencing token the caller already proved ownership of.
708
+ * @returns - The lineage's active row.
709
+ */
710
+ #liveSuccessor(row) {
711
+ let current = row;
712
+ const visited = new Set([String(current.id)]);
713
+ while (Number(current.active) === 0) {
714
+ const nextId = current.superseded_by;
715
+ if (typeof nextId !== "string" || nextId === "" || visited.has(nextId))
716
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
717
+ const next = this.#db.prepare("select * from sessions where id = ?").get(nextId);
718
+ if (!next)
719
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
720
+ visited.add(nextId);
721
+ current = next;
722
+ }
723
+ return current;
724
+ }
725
+ /**
726
+ * Name why a known session no longer resolves, so its holder is not told it merely expired.
727
+ *
728
+ * An archive and a newer attachment both deactivate a session while its lease still has time
729
+ * left; reporting either as an expiry sends the holder looking for a timing problem. A session
730
+ * rotated to a new generation of the same attachment carries `superseded_by` and is not a
731
+ * replacement by another holder.
732
+ * @param row - The session row the caller presented, inactive or past its lease.
733
+ * @returns - The error to raise: the archive or the replacement when that ended it, otherwise an expiry.
734
+ */
735
+ #sessionEndedError(row) {
736
+ const archived = this.#db.prepare("select archived_at from agents where id = ?").get(String(row.agent_id));
737
+ if (typeof archived?.archived_at === "number")
738
+ return new ProtocolError("expired", "MCP session ended because an operator archived this agent; re-attach required");
739
+ const replaced = this.#db
740
+ .prepare("select 1 as present from sessions where agent_id = ? and channel = ? and rowid > (select rowid from sessions where id = ?) limit 1")
741
+ .get(String(row.agent_id), String(row.channel), String(row.id));
742
+ if (Number(row.active) === 0 && row.superseded_by === null && replaced)
743
+ return new ProtocolError("expired", "MCP session ended because a newer attachment replaced it; re-attach required");
744
+ return new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
745
+ }
746
+ /**
747
+ * Return the configured lifetime of an authenticated MCP attachment.
748
+ * @returns - Milliseconds from successful authentication until renewal is required.
749
+ */
750
+ sessionLeaseTtlMs() {
751
+ return this.#leaseTtlMs;
752
+ }
753
+ /**
754
+ * Resolve a live session ID to an agent identity, expiring sessions on timeout or restart epoch mismatch.
755
+ *
756
+ * Resolving renews the session lease. The lease reaps sessions whose process is gone, and a caller
757
+ * presenting the session is evidence its process is not; an attach reservation is single-use, so a
758
+ * session allowed to lapse under continuous use could never be re-established.
759
+ * @param sessionId - Session id presented by the attached process.
760
+ * @param epoch - Broker generation used to fence stale sessions, when the client protocol carries one.
761
+ * @returns - Agent identity owned by the current fenced session.
762
+ */
763
+ resolveSession(sessionId, epoch) {
764
+ if (epoch !== undefined && epoch !== this.#epoch)
765
+ throw new ProtocolError("expired", "Broker epoch changed; re-attach required");
766
+ return this.#transaction(() => {
767
+ const row = this.#db.prepare("select * from sessions where id = ?").get(sessionId);
768
+ if (!row)
769
+ throw new ProtocolError("expired", SESSION_EXPIRED_MESSAGE);
770
+ if (Number(row.active) !== 1 || Number(row.expires_at) <= this.#now())
771
+ throw this.#sessionEndedError(row);
772
+ // The row's own recorded epoch fences it even when the caller sends none: a client that never
773
+ // learned this custom header cannot otherwise be told a restart happened, and would resolve a
774
+ // pre-restart session indefinitely.
775
+ if (String(row.epoch) !== this.#epoch)
776
+ throw new ProtocolError("expired", "Broker epoch changed; re-attach required");
777
+ this.#lifecycleSynchronization?.checkpoint("session_resolution_active");
778
+ this.#write("update sessions set expires_at = ? where id = ?", this.#now() + this.#leaseTtlMs, sessionId);
779
+ return readStoredAgentId(row.agent_id, "sessions.agent_id");
780
+ });
781
+ }
782
+ /**
783
+ * Send a message from an agent to a concrete recipient or alias address.
784
+ * `from` is taken as given: the broker looks the agent up and does not authenticate the caller, so
785
+ * the caller must have authenticated the identity itself and never pass one read from request
786
+ * content. An alias resolves to a concrete recipient here, at send time, and a
787
+ * queued message keeps that recipient rather than following a later rebinding. Only an explicit
788
+ * send creates a message; assistant output is never forwarded automatically.
789
+ * @param args - Sender identity, address, subject, type, and body of the message to send.
790
+ * @returns - Durable message identity, the resolved recipient, and whether that recipient is archived.
791
+ */
792
+ send(args) {
793
+ const sender = this.#messageSender(args.from);
794
+ const key = args.idempotencyKey ?? issueOpaqueToken("idem");
795
+ const content = this.#messageContent(args);
796
+ return this.#transaction(() => {
797
+ const to = this.#resolveRecipientIdentity(sender.workspaceId, args.to);
798
+ const repeated = this.#repeatedMessage(sender.id, to, key);
799
+ if (repeated)
800
+ return { ...repeated, recipientArchived: this.#isArchived(repeated.to) };
801
+ // Asserted only once the send is known not to be a replay. A retry carrying a key the broker
802
+ // already answered must return that answer, including after the recipient detached; the key
803
+ // exists for exactly that window.
804
+ this.#assertRoutableRecipient(to);
805
+ const refusal = this.#limitRefusal(sender.id, to);
806
+ if (refusal)
807
+ throw new ProtocolError("conflict", refusal);
808
+ const messageId = this.#queue(sender, to, key, content, {
809
+ addressedTo: args.to,
810
+ outcome: isDirectAgentAddress(args.to) ? "direct" : "resolved",
811
+ });
812
+ return { messageId, to, recipientArchived: this.#isArchived(to) };
813
+ });
814
+ }
815
+ /**
816
+ * Send one message from an agent to the other agents in a workspace.
817
+ *
818
+ * `from` is taken as given, as for {@link Broker.send}. The recipients are the workspace's active,
819
+ * unarchived agents other than the sender that are named, attached now, or still able to attach
820
+ * for the first time; an agent that joins later receives nothing. Each recipient gets its own
821
+ * message. A recipient refused by its pair rate or inbox limit is reported and the others still
822
+ * receive. A repeat carrying an earlier broadcast's key to the same workspace returns the message
823
+ * that broadcast queued for each recipient it reached, whatever has become of that recipient
824
+ * since, and sends to every other recipient. A key a direct send carried names a different send.
825
+ * @param args - Sender identity, subject, type, and body of the message, and the name of the
826
+ * workspace to broadcast to, the sender's own when absent.
827
+ * @returns - The broadcast address, the message sent to each recipient, and each recipient refused.
828
+ */
829
+ broadcast(args) {
830
+ const sender = this.#messageSender(args.from);
831
+ const requestedKey = args.idempotencyKey ?? issueOpaqueToken("idem");
832
+ const content = this.#messageContent(args);
833
+ const requestedWorkspace = args.workspace === undefined ? undefined : parseWorkspaceName(args.workspace);
834
+ return this.#transaction(() => {
835
+ const names = this.workspaceNames();
836
+ const target = requestedWorkspace === undefined
837
+ ? { id: sender.workspaceId, name: names.get(sender.workspaceId) }
838
+ : { id: this.#workspaceForName(requestedWorkspace, names), name: requestedWorkspace };
839
+ if (target.name === undefined)
840
+ throw new Error("The sender's workspace has no registered path");
841
+ const address = `${BROADCAST_ADDRESS_PREFIX}${target.name}`;
842
+ const key = `${BROADCAST_KEY_PREFIX}${target.id}:${requestedKey}`;
843
+ const result = { address, sent: [], refused: [] };
844
+ for (const to of this.#broadcastRecipients(target.id, sender.id, key)) {
845
+ const repeated = this.#repeatedMessage(sender.id, to, key);
846
+ const refusal = repeated ? undefined : this.#limitRefusal(sender.id, to);
847
+ if (refusal)
848
+ result.refused.push({ to, code: "conflict", message: refusal });
849
+ else
850
+ result.sent.push(repeated ?? {
851
+ messageId: this.#queue(sender, to, key, content, { addressedTo: address, outcome: "broadcast" }),
852
+ to,
853
+ });
854
+ }
855
+ return result;
856
+ });
857
+ }
858
+ #messageSender(agentId) {
859
+ const row = this.#agent(agentId);
860
+ return { id: agentId, teamId: String(row.team_id), workspaceId: String(row.workspace_id) };
861
+ }
862
+ /**
863
+ * Conform a send's content, refusing a body over the configured size before any transaction opens.
864
+ * @param args - Sender identity and the content it supplied.
865
+ * @returns - Content in the form every message the send queues stores it.
866
+ */
867
+ #messageContent(args) {
868
+ const subject = normalizeMessageSubject(args.subject);
869
+ const type = normalizeMessageType(args.type);
870
+ const body = encodeMessageBody(args.body);
871
+ if (Buffer.byteLength(body) > this.#maxBodyBytes) {
872
+ this.#projection.recordShed({
873
+ type: "message_shed",
874
+ reason: "body_size",
875
+ agentId: args.from,
876
+ limit: this.#maxBodyBytes,
877
+ });
878
+ throw new ProtocolError("validation_failed", "Message body exceeds the configured size limit");
879
+ }
880
+ return { subject, type, body, contentType: contentType(args.body) };
881
+ }
882
+ /**
883
+ * Find the message an earlier send carrying the same key queued for a recipient, recording the
884
+ * repeat on that message's lifecycle. A key names one send to one recipient, so the same key sends
885
+ * anew to a different recipient.
886
+ * @param senderId - Agent repeating the send.
887
+ * @param to - Recipient the repeat is addressed to.
888
+ * @param key - Idempotency key as stored, scoped to its kind of send.
889
+ * @returns - The earlier message and its recipient, or nothing when the key names no send to this
890
+ * recipient.
891
+ */
892
+ #repeatedMessage(senderId, to, key) {
893
+ const existing = this.#db
894
+ .prepare("select message_id from idempotency where agent_id = ? and recipient_id = ? and key = ?")
895
+ .get(senderId, to, key);
896
+ if (!existing)
897
+ return undefined;
898
+ const messageId = String(existing.message_id);
899
+ const recipient = this.#message(messageId).to;
900
+ this.#appendEvent(messageId, "broker", "duplicate_ignored", "duplicate send ignored", "muted");
901
+ return { messageId, to: recipient };
902
+ }
903
+ /**
904
+ * Report the limit that refuses one more message from a sender to a recipient, recording the shed.
905
+ * @param senderId - Agent the message would be from.
906
+ * @param to - Recipient the message would be queued for.
907
+ * @returns - The refusal's message, or nothing when neither the pair rate nor the inbox limit is
908
+ * reached.
909
+ */
910
+ #limitRefusal(senderId, to) {
911
+ if (this.#pairSendCount(senderId, to) >= this.#perPairRateLimit) {
912
+ this.#projection.recordShed({
913
+ type: "message_shed",
914
+ reason: "per_pair_rate",
915
+ agentId: senderId,
916
+ peerId: to,
917
+ limit: this.#perPairRateLimit,
918
+ });
919
+ return "Per-pair rate limit exceeded";
920
+ }
921
+ if (this.#inboxMessageCount(to) >= this.#maxInboxMessages) {
922
+ this.#projection.recordShed({
923
+ type: "message_shed",
924
+ reason: "queue_limit",
925
+ agentId: senderId,
926
+ peerId: to,
927
+ limit: this.#maxInboxMessages,
928
+ });
929
+ return "Recipient inbox limit exceeded";
930
+ }
931
+ return undefined;
932
+ }
933
+ /**
934
+ * Queue one message for a recipient every check has already admitted, inside the caller's
935
+ * transaction.
936
+ * @param sender - Agent the message is from, whose team and workspace it is recorded under.
937
+ * @param to - Recipient the message is queued for.
938
+ * @param key - Idempotency key a repeat of this send will present, as stored.
939
+ * @param content - Subject, type, and encoded body, already conformed.
940
+ * @param routing - Address the sender used and how it reached this recipient, recorded on the
941
+ * message and its lifecycle.
942
+ * @returns - Identity of the queued message.
943
+ */
944
+ #queue(sender, to, key, content, routing) {
945
+ const messageId = issueOpaqueToken("msg");
946
+ const trafficPosition = this.#allocateTrafficPosition();
947
+ this.#write("insert into messages(id, team_id, workspace_id, sender_id, recipient_id, addressed_to, address_outcome, subject, type, body, body_size, content_type, state, created_at, sequence) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'queued', ?, ?)", messageId, sender.teamId, sender.workspaceId, sender.id, to, routing.addressedTo, routing.outcome, content.subject, content.type, content.body, Buffer.byteLength(content.body), content.contentType, this.#now(), trafficPosition);
948
+ this.#write("insert into idempotency(agent_id, recipient_id, key, message_id, created_at) values (?, ?, ?, ?, ?)", sender.id, to, key, messageId, this.#now());
949
+ this.#appendEvent(messageId, "broker", "enqueued", "enqueued", "neutral");
950
+ const routed = routingEvent(routing, to);
951
+ this.#appendEvent(messageId, "broker", routed.type, routed.text, "neutral");
952
+ this.#syncRedelivery(to);
953
+ return messageId;
954
+ }
955
+ /**
956
+ * List who a broadcast reaches: every agent it may send to now, and every agent an earlier
957
+ * broadcast carrying the same key already reached.
958
+ * @param workspaceId - Workspace the broadcast targets.
959
+ * @param senderId - Agent broadcasting, which is never its own recipient.
960
+ * @param key - The broadcast's idempotency key, as stored.
961
+ * @returns - Recipient identities, ordered by id.
962
+ */
963
+ #broadcastRecipients(workspaceId, senderId, key) {
964
+ const rows = this.#db
965
+ .prepare(`select rowid, id from agents a where a.workspace_id = ? and a.id != ? and a.state = 'active' and a.archived_at is null and ${ANSWERABLE_AGENT_SQL} and not (${UNATTACHED_EPHEMERAL_LAPSED_SQL}) union select a.rowid, a.id from agents a join idempotency i on i.recipient_id = a.id where i.agent_id = ? and i.key = ? order by id`)
966
+ .all(workspaceId, senderId, this.#now(), this.#now(), senderId, key);
967
+ return this.#readableAgentRows(rows).map(({ id }) => id);
968
+ }
969
+ /**
970
+ * Claim queued messages for an agent atomically, returning opaque acknowledgement metadata.
971
+ * @param request - Agent to claim for, the claim cap, and the path making the claim.
972
+ * @returns - Messages claimed for the agent with fresh claim tokens.
973
+ */
974
+ fetch({ agentId, limit = FETCH_LIMIT_DEFAULT, claimedBy = "fetch" }) {
975
+ this.expireClaims();
976
+ const claimed = [];
977
+ this.#transaction(() => {
978
+ const workspaces = this.workspaceNames();
979
+ const broadcastAddress = this.#currentBroadcastAddress(agentId, workspaces);
980
+ const rows = this.#db
981
+ .prepare("select * from messages where recipient_id = ? and state = 'queued' order by sender_id, sequence limit ?")
982
+ .all(agentId, limit);
983
+ for (const row of rows) {
984
+ const from = row.sender_id;
985
+ if (!isAgentId(from)) {
986
+ // Dead-letter: a skipped row is selected again by every fetch, and a throw rolls back the
987
+ // claims taken beside it.
988
+ this.#deadLetter([row], "dead-lettered — stored sender identity is unreadable");
989
+ continue;
990
+ }
991
+ let body;
992
+ try {
993
+ body = decodeMessageBody(row.body);
994
+ }
995
+ catch {
996
+ this.#deadLetter([row], "dead-lettered — stored message body is not valid JSON");
997
+ continue;
998
+ }
999
+ const token = issueOpaqueToken("claim");
1000
+ const expires = this.#now() + this.#visibilityTimeoutMs;
1001
+ this.#write("insert into claims(message_id, token, agent_id, expires_at, created_at) values (?, ?, ?, ?, ?)", String(row.id), token, agentId, expires, this.#now());
1002
+ this.#write("update messages set state = 'claimed', claim_token = ?, claim_expires_at = ? where id = ? and state = 'queued'", token, expires, String(row.id));
1003
+ this.#appendEvent(String(row.id), "broker", "claimed", claimedBy === "delivery" ? "claimed by delivery" : "claimed", "neutral");
1004
+ claimed.push({
1005
+ id: String(row.id),
1006
+ from,
1007
+ fromAddress: this.#agentAddress(from, workspaces),
1008
+ // The select above constrains this column to the agent it was given, so it needs no read.
1009
+ to: agentId,
1010
+ // The address it was sent under stands in when the recipient's workspace has no name now.
1011
+ ...(row.address_outcome === "broadcast"
1012
+ ? { broadcastAddress: broadcastAddress ?? String(row.addressed_to) }
1013
+ : {}),
1014
+ subject: String(row.subject),
1015
+ type: String(row.type),
1016
+ body,
1017
+ claimToken: token,
1018
+ claimExpiresAt: expires,
1019
+ });
1020
+ }
1021
+ this.#syncRedelivery(agentId);
1022
+ });
1023
+ return claimed;
1024
+ }
1025
+ /**
1026
+ * Acknowledge claims while preserving their bodies for later inspection or explicit expiry.
1027
+ *
1028
+ * A token identifies both its message and its claim generation, so a token superseded by a re-claim
1029
+ * after expiry matches nothing and leaves the live claim outstanding.
1030
+ * @param agentId - Agent acknowledging its own claimed messages.
1031
+ * @param claimTokens - Claim tokens proving ownership of fetched messages; a token repeated in one call counts once.
1032
+ * @returns - The tokens acknowledged and, for each token that was not, the reason.
1033
+ */
1034
+ ack(agentId, claimTokens) {
1035
+ const acked = [];
1036
+ const rejected = [];
1037
+ this.#transaction(() => {
1038
+ for (const claimToken of new Set(claimTokens)) {
1039
+ const row = this.#db
1040
+ .prepare("select id from messages where recipient_id = ? and state = 'claimed' and claim_token = ?")
1041
+ .get(agentId, claimToken);
1042
+ if (!row) {
1043
+ rejected.push({ claimToken, reason: this.#ackRejectionReason(agentId, claimToken) });
1044
+ continue;
1045
+ }
1046
+ const messageId = String(row.id);
1047
+ this.#write("update messages set state = 'acknowledged', claim_token = null, claim_expires_at = null where id = ?", messageId);
1048
+ this.#appendEvent(messageId, "broker", "acknowledged", "consumed", "ok");
1049
+ acked.push(claimToken);
1050
+ }
1051
+ this.#syncRedelivery(agentId);
1052
+ });
1053
+ return { acked, rejected };
1054
+ }
1055
+ /**
1056
+ * Explain why a token matched no live claim of the acknowledging agent.
1057
+ *
1058
+ * Every token ever issued stays in `claims` until its message is pruned, so the token names its
1059
+ * message and the message's state says what is left to do. A token the message has moved past, by a
1060
+ * later claim that was itself acknowledged, is reported by that state rather than as a lapse.
1061
+ * @param agentId - Agent that presented the token.
1062
+ * @param claimToken - Token that matched no claimed message of that agent.
1063
+ * @returns - The reason the acknowledgement did nothing.
1064
+ */
1065
+ #ackRejectionReason(agentId, claimToken) {
1066
+ const claim = this.#db.prepare("select message_id, agent_id from claims where token = ?").get(claimToken);
1067
+ if (!claim)
1068
+ return "unknown_token";
1069
+ if (claim.agent_id !== agentId)
1070
+ return "foreign_token";
1071
+ const message = this.#db.prepare("select state from messages where id = ?").get(String(claim.message_id));
1072
+ if (!message)
1073
+ return "claim_expired";
1074
+ switch (readStoredEnum(message.state, MESSAGE_STATES, "messages.state")) {
1075
+ case "acknowledged":
1076
+ return "already_acknowledged";
1077
+ case "expired":
1078
+ case "dead_lettered":
1079
+ case "failed":
1080
+ return "message_settled";
1081
+ case "queued":
1082
+ case "claimed":
1083
+ return "claim_expired";
1084
+ }
1085
+ }
1086
+ /**
1087
+ * Return claims the broker made on a recipient's behalf whose delivery never reached the host.
1088
+ *
1089
+ * Waiting out the visibility window would hold a message the recipient never saw for the whole
1090
+ * window; the transport has already said nothing arrived, so the queue gets it back now. The
1091
+ * redelivery schedule armed when the message was queued is still in place, because a claimed
1092
+ * message keeps its inbox's schedule, and it decides when the message is offered again.
1093
+ * @param agentId - Recipient the claims were made for.
1094
+ * @param claimTokens - Claim tokens the failed delivery carried.
1095
+ * @returns - Message identities returned to the queue; a claim already acknowledged or expired is skipped.
1096
+ */
1097
+ releaseClaims(agentId, claimTokens) {
1098
+ const released = [];
1099
+ this.#transaction(() => {
1100
+ for (const claimToken of claimTokens) {
1101
+ const row = this.#db
1102
+ .prepare("select id from messages where recipient_id = ? and state = 'claimed' and claim_token = ?")
1103
+ .get(agentId, claimToken);
1104
+ if (!row)
1105
+ continue;
1106
+ const messageId = String(row.id);
1107
+ this.#write("update messages set state = 'queued', claim_token = null, claim_expires_at = null where id = ?", messageId);
1108
+ this.#appendEvent(messageId, "broker", "claim_released", "delivery failed · still queued", "warn");
1109
+ this.#appendEvent(messageId, "broker", "queued", "queued after delivery failed", "neutral");
1110
+ released.push(messageId);
1111
+ }
1112
+ });
1113
+ return released;
1114
+ }
1115
+ /**
1116
+ * Return whether an agent has queued durable inbox messages without claiming them.
1117
+ * @param agentId - Agent whose queue is checked.
1118
+ * @returns - Whether the agent has at least one deliverable queued message.
1119
+ */
1120
+ hasQueuedMessages(agentId) {
1121
+ const row = this.#db
1122
+ .prepare("select 1 from messages where recipient_id = ? and state = 'queued' limit 1")
1123
+ .get(agentId);
1124
+ return Boolean(row);
1125
+ }
1126
+ /**
1127
+ * Expire active claims back to the durable queue for redelivery.
1128
+ *
1129
+ * The only thing that settles a lapsed claim, and so the only thing that makes {@link snapshot}'s
1130
+ * claim states current: a reader that never runs it sees a dead claimant's claim as `claimed`.
1131
+ */
1132
+ expireClaims() {
1133
+ this.#transaction(() => {
1134
+ const expired = this.#db
1135
+ .prepare("select id, recipient_id from messages where state = 'claimed' and claim_expires_at <= ?")
1136
+ .all(this.#now());
1137
+ for (const row of expired) {
1138
+ const messageId = String(row.id);
1139
+ this.#write("update messages set state = 'queued', claim_token = null, claim_expires_at = null where id = ?", messageId);
1140
+ this.#appendEvent(messageId, "broker", "claim_expired", `still queued · ttl ${formatDurationLabel(this.#redeliveryGiveUpMs)}`, "warn");
1141
+ this.#appendEvent(messageId, "broker", "queued", "queued after claim expiry", "neutral");
1142
+ // Skip-and-name: a throw would roll back every claim this sweep settles.
1143
+ this.#syncSkippingUnreadable(row.recipient_id, "messages.recipient_id", messageId);
1144
+ }
1145
+ });
1146
+ }
1147
+ /**
1148
+ * Advance the redelivery schedule and report the inboxes now owed another wake.
1149
+ *
1150
+ * Claims are expired first, so an inbox whose messages are merely in flight is distinguished from
1151
+ * one whose recipient stopped answering: only the second spends an attempt. An inbox that has been
1152
+ * retried past the give-up budget is dead-lettered here rather than retried again, because a
1153
+ * sender waiting on a delivery outcome is owed a terminal state. An inbox whose stored recipient
1154
+ * cannot be read is dead-lettered on its first due wake, claimed messages included, since none can
1155
+ * be fetched or acknowledged.
1156
+ * @returns - Recipients whose queued messages should be notified again, in no guaranteed order.
1157
+ */
1158
+ dueRedeliveryWakes() {
1159
+ this.expireClaims();
1160
+ const due = [];
1161
+ this.#transaction(() => {
1162
+ const now = this.#now();
1163
+ const rows = this.#db
1164
+ .prepare("select rowid, agent_id, attempts from wakes where next_wake_at <= ?")
1165
+ .all(now);
1166
+ for (const row of rows) {
1167
+ // Settle-and-name, by rowid: a row left due holds `nextSweepDelayMs` at zero, and the column
1168
+ // that would address it is the unreadable one.
1169
+ const agentId = this.#readableAgentId(row.agent_id, "wakes.agent_id", String(row.rowid));
1170
+ if (agentId === null) {
1171
+ this.#deadLetter(this.#db
1172
+ .prepare("select m.id from messages m join wakes w on m.recipient_id = w.agent_id where w.rowid = ? and m.state in ('queued','claimed') order by m.sequence")
1173
+ .all(Number(row.rowid)), UNREADABLE_RECIPIENT_REASON);
1174
+ this.#write("delete from wakes where rowid = ?", Number(row.rowid));
1175
+ continue;
1176
+ }
1177
+ this.#abandonExhaustedMessages(agentId, now);
1178
+ if (this.#inboxMessageCount(agentId) === 0) {
1179
+ this.#write("delete from wakes where agent_id = ?", agentId);
1180
+ continue;
1181
+ }
1182
+ // An unclaimed message is what the backoff measures, so a tick that finds every message
1183
+ // claimed reschedules without spending an attempt. Leaving the row due instead would make
1184
+ // the deadline permanently zero and turn the sweep into a spin.
1185
+ const owed = this.hasQueuedMessages(agentId);
1186
+ const attempts = owed ? Number(row.attempts) + 1 : Number(row.attempts);
1187
+ this.#write("update wakes set attempts = ?, next_wake_at = ? where agent_id = ?", attempts, this.#nextWakeAt(agentId, now, attempts), agentId);
1188
+ if (owed)
1189
+ due.push(agentId);
1190
+ }
1191
+ });
1192
+ return due;
1193
+ }
1194
+ /**
1195
+ * Record that a redelivery wake reached one recipient, against each message it covered.
1196
+ *
1197
+ * Separate from scheduling because only the caller that published the wake knows whether it was
1198
+ * admitted: a wake the budget shed still costs an attempt, but writing it to the lifecycle would
1199
+ * tell an operator a message was delivered again when nothing left the broker.
1200
+ * @param agentId - Recipient the wake was published for.
1201
+ */
1202
+ recordRedeliveryAttempt(agentId) {
1203
+ this.#transaction(() => {
1204
+ const row = this.#db.prepare("select attempts from wakes where agent_id = ?").get(agentId);
1205
+ if (!row)
1206
+ return;
1207
+ const queued = this.#db
1208
+ .prepare("select id from messages where recipient_id = ? and state = 'queued' order by sequence")
1209
+ .all(agentId);
1210
+ for (const message of queued) {
1211
+ this.#appendEvent(String(message.id), "broker", "redelivery", `redelivery attempt ${String(Number(row.attempts))}`, "warn");
1212
+ }
1213
+ });
1214
+ }
1215
+ /**
1216
+ * Mark expired unattached reservations and parent-visible dead-letter outcomes.
1217
+ *
1218
+ * Scoped to an agent with no active session at all: an agent already attached on one channel can
1219
+ * hold an unconsumed reservation on another (its delivery channel, when its implementation has no
1220
+ * host-push capability and so never attaches it) that outlives the reservation TTL forever. That
1221
+ * reservation expiring is not the agent going unanswered — it is alive and reachable — so treating
1222
+ * it as a never-attached identity would dead-letter its live mail out from under it.
1223
+ */
1224
+ reapExpiredReservations() {
1225
+ const expired = [];
1226
+ const sweep = issueOpaqueToken("sweep");
1227
+ this.#transaction(() => {
1228
+ const expiredAt = this.#now();
1229
+ const rows = this.#db
1230
+ .prepare("select r.rowid as reservation_rowid, r.agent_id, a.parent_id from reservations r join agents a on a.id = r.agent_id where r.consumed_at is null and r.expires_at <= ? and not exists (select 1 from sessions s where s.agent_id = a.id and s.active = 1)")
1231
+ .all(expiredAt);
1232
+ this.#lifecycleSynchronization?.checkpoint("expired_reservations_scanned");
1233
+ for (const row of rows) {
1234
+ const agentId = this.#readableAgentId(row.agent_id, "reservations.agent_id", String(row.reservation_rowid));
1235
+ if (agentId === null) {
1236
+ // Settle-and-name, by rowid; its messages are dead-lettered by the stored value.
1237
+ this.#deadLetter(this.#db
1238
+ .prepare("select m.id from messages m join reservations r on m.recipient_id = r.agent_id where r.rowid = ? and m.state in ('queued','claimed') order by m.sequence")
1239
+ .all(Number(row.reservation_rowid)), UNREADABLE_RECIPIENT_REASON);
1240
+ this.#write("delete from reservations where rowid = ?", Number(row.reservation_rowid));
1241
+ continue;
1242
+ }
1243
+ expired.push({ agentId, parentId: typeof row.parent_id === "string" ? row.parent_id : null });
1244
+ this.#deadLetter(this.#db
1245
+ .prepare("select id from messages where recipient_id = ? and state in ('queued','claimed') order by sequence")
1246
+ .all(agentId), "dead-lettered");
1247
+ this.#write("delete from reservations where agent_id = ? and consumed_at is null and expires_at <= ?", agentId, expiredAt);
1248
+ this.#syncRedelivery(agentId);
1249
+ }
1250
+ });
1251
+ for (const { agentId, parentId } of expired) {
1252
+ if (parentId === null)
1253
+ continue;
1254
+ this.#notifyParent(agentId, parentId, "dead_lettered", `agent-dead_lettered:${agentId}:${sweep}`);
1255
+ }
1256
+ }
1257
+ /**
1258
+ * Mark a message expired, retaining the stored body and the sender-visible status of its row.
1259
+ * @param messageId - Message being marked expired.
1260
+ * @param actor - Identity performing the audited action.
1261
+ */
1262
+ expireMessage(messageId, actor) {
1263
+ this.#transaction(() => {
1264
+ this.#transitionTerminalInTransaction(messageId, { state: "expired", actor });
1265
+ });
1266
+ }
1267
+ /**
1268
+ * Mark a message failed, retaining the stored body and the sender-visible status of its row.
1269
+ * @param messageId - Message being marked failed.
1270
+ */
1271
+ failMessage(messageId) {
1272
+ this.#transaction(() => {
1273
+ const current = this.#db.prepare("select state from messages where id = ?").get(messageId);
1274
+ if (!current)
1275
+ throw new ProtocolError("validation_failed", "Unknown message");
1276
+ if (current.state === "failed")
1277
+ return;
1278
+ this.#transitionTerminalInTransaction(messageId, { state: "failed" });
1279
+ });
1280
+ }
1281
+ /**
1282
+ * Return body-free status for a sender-visible message.
1283
+ * @param messageId - Message whose status is read.
1284
+ * @param requester - Agent identity authorized to inspect sender-visible status.
1285
+ * @returns - Sender-visible delivery status without retained message content.
1286
+ */
1287
+ status(messageId, requester) {
1288
+ const row = this.#message(messageId);
1289
+ if (requester && row.from !== requester)
1290
+ throw new ProtocolError("validation_failed", "Message status is sender-visible only");
1291
+ return { messageId, state: row.state, to: row.to };
1292
+ }
1293
+ /**
1294
+ * Return the broker's observable operational events.
1295
+ * @returns - Copy of accumulated operational events, safe for caller inspection.
1296
+ */
1297
+ operationalEvents() {
1298
+ return this.#projection.operationalEvents();
1299
+ }
1300
+ /**
1301
+ * Append an operational event recording the outcome of a delivery notification attempt.
1302
+ * @param messageId - Message the notification outcome is recorded against.
1303
+ * @param outcome - Observed notification result recorded for diagnostics.
1304
+ */
1305
+ recordNotification(messageId, outcome) {
1306
+ const display = notificationDisplay[outcome];
1307
+ this.#transaction(() => {
1308
+ this.#appendEvent(messageId, "notify", `notify_${outcome}`, display.text, display.tone);
1309
+ });
1310
+ }
1311
+ /**
1312
+ * Reads a message's retained body, available until the settled-message prune deletes its row.
1313
+ * @param messageId - Message whose retained body is read.
1314
+ * @returns - Retained body content and its stored representation metadata.
1315
+ */
1316
+ readMessageBody(messageId) {
1317
+ return this.#projection.readMessageBody(messageId);
1318
+ }
1319
+ /**
1320
+ * Read bounded durable traffic without claiming, acknowledging, or changing retained bodies.
1321
+ * @returns Traffic source rows in their single broker-owned durable order.
1322
+ */
1323
+ operatorTrafficRecords() {
1324
+ return this.#projection.operatorTrafficRecords();
1325
+ }
1326
+ /**
1327
+ * Return a prior operator command acceptance for idempotent request handling.
1328
+ * @param commandId - Opaque command correlation supplied by the operator boundary.
1329
+ * @returns Stored acceptance, or null when this command has not been accepted.
1330
+ */
1331
+ operatorCommand(commandId) {
1332
+ return this.#projection.operatorCommand(commandId);
1333
+ }
1334
+ /**
1335
+ * Persist ping request acceptance and its audit before the scheduler publishes a wake.
1336
+ * @param commandId - Opaque idempotency correlation for the operator request.
1337
+ * @param targetAgentId - Active broker agent requested by the operator.
1338
+ * @param controlId - Opaque correlation carried by the body-free wake.
1339
+ * @returns Newly persisted command acceptance.
1340
+ */
1341
+ acceptOperatorPing(commandId, targetAgentId, controlId) {
1342
+ assertDurableOperatorId(commandId, "command");
1343
+ assertDurableOperatorId(controlId, "control");
1344
+ return this.#transaction(() => {
1345
+ const prior = this.operatorCommand(commandId);
1346
+ if (prior) {
1347
+ if (prior.type !== "ping_agent" || prior.targetId !== targetAgentId) {
1348
+ throw new ProtocolError("conflict", "Operator command already exists");
1349
+ }
1350
+ return { status: "existing", record: prior };
1351
+ }
1352
+ this.#activeAgent(targetAgentId);
1353
+ const acceptedAt = this.#now();
1354
+ const trafficPosition = this.#allocateTrafficPosition();
1355
+ const auditId = issueOpaqueToken("audit");
1356
+ this.#write("insert into operator_commands(command_id, type, target_id, accepted_at, control_id) values (?, 'ping_agent', ?, ?, ?)", commandId, targetAgentId, acceptedAt, controlId);
1357
+ this.#write("insert into operator_ping_audits(audit_id, command_id, control_id, target_agent_id, actor, accepted_at, traffic_position) values (?, ?, ?, ?, 'operator', ?, ?)", auditId, commandId, controlId, targetAgentId, acceptedAt, trafficPosition);
1358
+ return {
1359
+ status: "created",
1360
+ record: { type: "ping_agent", commandId, targetId: targetAgentId, acceptedAt, controlId },
1361
+ };
1362
+ });
1363
+ }
1364
+ /**
1365
+ * Atomically expire one retained message and persist command acceptance for idempotent retry.
1366
+ * @param commandId - Opaque idempotency correlation for the operator request.
1367
+ * @param messageId - Accepted message whose retained body stays readable until the settled-message prune.
1368
+ * @returns Newly persisted command acceptance.
1369
+ */
1370
+ acceptOperatorExpiry(commandId, messageId) {
1371
+ assertDurableOperatorId(commandId, "command");
1372
+ assertDurableOperatorId(messageId, "message");
1373
+ return this.#transaction(() => {
1374
+ const prior = this.operatorCommand(commandId);
1375
+ if (prior) {
1376
+ if (prior.type !== "expire_message" || prior.targetId !== messageId) {
1377
+ throw new ProtocolError("conflict", "Operator command already exists");
1378
+ }
1379
+ return { status: "existing", record: prior };
1380
+ }
1381
+ this.#transitionTerminalInTransaction(messageId, { state: "expired", actor: "operator" });
1382
+ const acceptedAt = this.#now();
1383
+ this.#write("insert into operator_commands(command_id, type, target_id, accepted_at, control_id) values (?, 'expire_message', ?, ?, null)", commandId, messageId, acceptedAt);
1384
+ return {
1385
+ status: "created",
1386
+ record: { type: "expire_message", commandId, targetId: messageId, acceptedAt },
1387
+ };
1388
+ });
1389
+ }
1390
+ /**
1391
+ * Accept one operator archive request, putting the agent away without disturbing its mail.
1392
+ *
1393
+ * The same lifecycle holds for every identity, ephemeral or named: active, archived, then reaped.
1394
+ * Until the reaper takes it, once `archivedRetentionMs` has passed since this acceptance, the
1395
+ * identity keeps its name, its conversation key, its inbox and its addressability, so the ordinary
1396
+ * reconnection path is what brings it back and everything queued for it, before or since, is
1397
+ * waiting when it does. Attaching un-archives it and stops the clock. Archiving an identity that is
1398
+ * already archived keeps the first `archived_at`, so a repeat request never extends the window.
1399
+ * @param commandId - Correlates retries of the same operator request.
1400
+ * @param agentId - Agent the operator asked to archive.
1401
+ * @returns - Durable acceptance, reporting whether this call created it.
1402
+ */
1403
+ acceptOperatorArchive(commandId, agentId) {
1404
+ assertDurableOperatorId(commandId, "command");
1405
+ const target = agentId;
1406
+ const acceptance = this.#transaction(() => {
1407
+ const prior = this.operatorCommand(commandId);
1408
+ if (prior) {
1409
+ if (prior.type !== "archive_agent" || prior.targetId !== agentId) {
1410
+ throw new ProtocolError("conflict", "Operator command already exists");
1411
+ }
1412
+ return { status: "existing", record: prior };
1413
+ }
1414
+ const row = this.#db.prepare("select id from agents where id = ? and state != 'reaped'").get(target);
1415
+ if (!row)
1416
+ throw new ProtocolError("validation_failed", "Unknown agent");
1417
+ const acceptedAt = this.#now();
1418
+ // The identity is otherwise left as a detached one: archiving ends its attachments and marks it.
1419
+ this.#write("update agents set archived_at = coalesce(archived_at, ?) where id = ?", acceptedAt, target);
1420
+ this.#write("update sessions set active = 0 where agent_id = ?", target);
1421
+ this.#write("insert into operator_commands(command_id, type, target_id, accepted_at, control_id) values (?, 'archive_agent', ?, ?, null)", commandId, agentId, acceptedAt);
1422
+ return {
1423
+ status: "created",
1424
+ record: { type: "archive_agent", commandId, targetId: agentId, acceptedAt },
1425
+ };
1426
+ });
1427
+ return acceptance;
1428
+ }
1429
+ /**
1430
+ * Returns the broker snapshot, optionally scoped to one workspace.
1431
+ *
1432
+ * A read: it writes nothing and publishes no commit. Claim states are as current as the last
1433
+ * {@link expireClaims}.
1434
+ * @param workspaceId - Workspace whose agents, and the messages they sent or received, the
1435
+ * snapshot is scoped to; every workspace when absent.
1436
+ * @returns - Broker snapshot, optionally scoped to the requested workspace.
1437
+ */
1438
+ snapshot(workspaceId) {
1439
+ const now = this.#now();
1440
+ return this.#projection.readSnapshot(workspaceId, now, this.snapshotHealth(now));
1441
+ }
1442
+ /**
1443
+ * Return the agent-derived state an operator projection reads, without any delivery.
1444
+ * @returns - The snapshot's workspace, agent, and connection sections; deliveries and diagnostics are omitted.
1445
+ */
1446
+ operatorAgentSource() {
1447
+ return this.#projection.readAgentSource();
1448
+ }
1449
+ /**
1450
+ * Return the concrete team for a registered agent.
1451
+ * @param agentId - Registered agent whose team is looked up.
1452
+ * @returns - Team identity that owns the registered agent.
1453
+ */
1454
+ teamForAgent(agentId) {
1455
+ return String(this.#agent(agentId).team_id);
1456
+ }
1457
+ /**
1458
+ * Return the implementation identity recorded for an agent when it was registered.
1459
+ * @param agentId - Registered agent whose implementation is looked up.
1460
+ * @returns - Implementation recorded by the composition boundary, or null when none was supplied.
1461
+ */
1462
+ implementationForAgent(agentId) {
1463
+ const agent = this.#agent(agentId);
1464
+ if (typeof agent.implementation_id !== "string")
1465
+ return null;
1466
+ return {
1467
+ id: agent.implementation_id,
1468
+ ...(typeof agent.implementation_version === "string" ? { version: agent.implementation_version } : {}),
1469
+ };
1470
+ }
1471
+ /**
1472
+ * Return team directory entries visible to the attached agent.
1473
+ *
1474
+ * A reaped identity is omitted, since a send to it is refused.
1475
+ * @param agentId - Attached agent whose team roster is listed.
1476
+ * @returns - Registered identities and current aliases visible within the agent team.
1477
+ */
1478
+ listTeamMembers(agentId) {
1479
+ const agent = this.#agent(agentId);
1480
+ const agents = this.#db
1481
+ .prepare("select rowid, id, registration_name from agents where team_id = ? and state != 'reaped' order by id")
1482
+ .all(String(agent.team_id));
1483
+ return this.#readableAgentRows(agents).map(({ id, row }) => ({
1484
+ id,
1485
+ alias: normalizeAlias(String(row.registration_name)),
1486
+ }));
1487
+ }
1488
+ /**
1489
+ * Return every agent a sender can currently reach, with the address that reaches it from any workspace.
1490
+ *
1491
+ * A reaped identity is omitted, since a send to it is refused. So is a detached ephemeral identity
1492
+ * that is not archived: it is retained only so its own conversation can resume, and offering it
1493
+ * here invites a sender to address an inbox nothing will ever claim. An archived identity is listed
1494
+ * because a send to it is accepted and waits.
1495
+ * @returns - Registered identities, aliases, workspace names, and routable addresses.
1496
+ */
1497
+ listAgents() {
1498
+ const names = this.workspaceNames();
1499
+ const agents = this.#db
1500
+ .prepare(`select rowid, id, registration_name, workspace_id from agents a where state != 'reaped' and (archived_at is not null or ${ANSWERABLE_AGENT_SQL}) order by workspace_id, id`)
1501
+ .all(this.#now());
1502
+ return this.#readableAgentRows(agents).map(({ id, row }) => {
1503
+ const workspace = names.get(String(row.workspace_id)) ?? String(row.workspace_id);
1504
+ const alias = normalizeAlias(String(row.registration_name));
1505
+ return { id, alias, workspace, address: `${alias}@${workspace}` };
1506
+ });
1507
+ }
1508
+ /**
1509
+ * Settle and validate the identity a registration will write, before any port or transaction runs.
1510
+ *
1511
+ * The single producer of the identity {@link Broker.registerAgent} accepts, so the `agents.id` it
1512
+ * writes has passed the protocol's rule whatever the caller supplied. The implementation id is
1513
+ * checked here too, so a provider identity the operator wire would refuse to carry is never written.
1514
+ *
1515
+ * `parentId` is not checked here. It is typed {@link AgentId}, so only a caller that casts past the
1516
+ * type can put an unaddressable value in `agents.parent_id`.
1517
+ * @param requested - Caller-supplied identity, or nothing when the broker mints one.
1518
+ * @param requestedName - Caller-supplied alias, or nothing to derive one from the identity.
1519
+ * @param implementation - Caller-supplied implementation, when the registration records one.
1520
+ * @returns - Validated identity and the alias that will be written beside it.
1521
+ */
1522
+ #registrationIdentity(requested, requestedName, implementation) {
1523
+ const agentId = requested ?? mintAgentId(issueOpaqueToken("agent"));
1524
+ assertAgentId(agentId, "registration agent id");
1525
+ if (implementation)
1526
+ validateImplementationId(implementation.id);
1527
+ return {
1528
+ agentId,
1529
+ sessionName: validateRegistrationSessionName(requestedName ?? defaultAgentSessionName(agentId)),
1530
+ };
1531
+ }
1532
+ #registerAgent(args, identity, branch) {
1533
+ const { agentId, sessionName } = identity;
1534
+ const registeredAt = this.#now();
1535
+ const workspacePath = resolve(args.workspacePath);
1536
+ const workspaceId = workspaceIdentity(workspacePath);
1537
+ this.#ensureWorkspaceAndTeam(workspaceId, workspacePath, args.teamId, registeredAt, branch);
1538
+ const existing = this.#db.prepare("select state from agents where id = ?").get(agentId);
1539
+ if (existing && existing.state !== "active") {
1540
+ throw new ProtocolError("conflict", "Only an active agent identity can be reused");
1541
+ }
1542
+ this.#assertNameAvailable(workspaceId, sessionName, agentId);
1543
+ this.#write("insert or ignore into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, ?, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, workspaceId, args.parentId ?? null, registeredAt, args.implementation?.id ?? null, args.implementation?.version ?? null, sessionName, args.ephemeral ? 1 : 0);
1544
+ return agentId;
1545
+ }
1546
+ /**
1547
+ * Hand one diagnostic to the report port, absorbing whatever the port does with it.
1548
+ *
1549
+ * Callers are draining a loop whose earlier steps have already committed — the reap has deleted
1550
+ * the reservations it settled — so a sink that throws must not cost the parents further down that
1551
+ * loop their notifications. A sink that cannot accept a message is its own problem, not theirs.
1552
+ * @param message - Operator-facing diagnostic to report.
1553
+ */
1554
+ #reportSafely(message) {
1555
+ try {
1556
+ this.#report(message);
1557
+ }
1558
+ catch {
1559
+ // A diagnostic that cannot be delivered is not worth failing the sweep that raised it.
1560
+ }
1561
+ }
1562
+ /**
1563
+ * Read the branch checked out at a workspace path, for every registration that reaches it.
1564
+ *
1565
+ * Read before the transaction opens, same as for a new workspace: the port's real implementation is
1566
+ * a subprocess, and holding the write lock across it stalls every other connection. A resumed
1567
+ * session never reaches this — `registerAgentWithoutReservation`'s resumable branch returns before
1568
+ * any workspace write — so the hottest registration path still pays no I/O; only a genuinely new
1569
+ * registration does, which is also the only moment a later `git checkout` on that workspace could
1570
+ * otherwise go unseen forever.
1571
+ * @param workspacePath - Absolute path the port inspects.
1572
+ * @returns - Branch currently checked out at that path, or null when it is not a repository.
1573
+ */
1574
+ #currentWorkspaceBranch(workspacePath) {
1575
+ return this.#workspaceBranch(workspacePath);
1576
+ }
1577
+ /**
1578
+ * Record the workspace and team a registration lands in, and refresh a known workspace's branch.
1579
+ *
1580
+ * A `null` branch never overwrites one already stored: the caller's lookup collapses "not a
1581
+ * repository" and "the git subprocess failed" into the same answer, and a transient failure must
1582
+ * not blank out a branch a prior registration actually observed.
1583
+ * @param workspaceId - Identity derived from the workspace path.
1584
+ * @param workspacePath - Absolute path the workspace identity was derived from.
1585
+ * @param teamId - Team the registering agent joins.
1586
+ * @param registeredAt - Broker-clock timestamp shared with the agent row this registration writes.
1587
+ * @param branch - Branch checked out at that path, read before this transaction opened.
1588
+ */
1589
+ #ensureWorkspaceAndTeam(workspaceId, workspacePath, teamId, registeredAt, branch) {
1590
+ this.#write("insert into workspaces(id, path, branch, created_at) values (?, ?, ?, ?) on conflict(id) do update set branch = coalesce(excluded.branch, workspaces.branch)", workspaceId, workspacePath, branch, registeredAt);
1591
+ this.#write("insert or ignore into teams(id, workspace_id, created_at) values (?, ?, ?)", teamId, workspaceId, registeredAt);
1592
+ }
1593
+ #registerAgentWithoutReservation(args) {
1594
+ const registeredAt = this.#now();
1595
+ this.#ensureWorkspaceAndTeam(args.workspaceId, args.workspacePath, args.teamId, registeredAt, args.branch);
1596
+ const existing = this.#db
1597
+ .prepare("select id, implementation_id from agents where workspace_id = ? and registration_name = ? and state = 'active'")
1598
+ .get(args.workspaceId, args.sessionName);
1599
+ if (existing) {
1600
+ if (existing.implementation_id !== args.implementation.id) {
1601
+ throw new ProtocolError("alias_conflict", "Agent alias is already in use in this workspace");
1602
+ }
1603
+ const agentId = readStoredAgentId(existing.id, "agents.id");
1604
+ this.#assertNameAvailable(args.workspaceId, args.sessionName, agentId);
1605
+ this.#assertHolderDetached(agentId, args.sessionName);
1606
+ this.#displaceHolder(agentId);
1607
+ return agentId;
1608
+ }
1609
+ this.#assertNameAvailable(args.workspaceId, args.sessionName);
1610
+ const agentId = mintAgentId(issueOpaqueToken("agent"));
1611
+ this.#write("insert into agents(id, team_id, workspace_id, parent_id, state, created_at, implementation_id, implementation_version, registration_name, registration_ephemeral) values (?, ?, ?, null, 'active', ?, ?, ?, ?, ?)", agentId, args.teamId, args.workspaceId, registeredAt, args.implementation.id, args.implementation.version ?? null, args.sessionName, args.ephemeral ? 1 : 0);
1612
+ return agentId;
1613
+ }
1614
+ /**
1615
+ * Refuse to rejoin a session name whose holder is still attached.
1616
+ *
1617
+ * Rejoining deactivates the holder's sessions and hands its inbox to the newcomer, so the holder's
1618
+ * next call fails with no way back. A holder whose lease has lapsed, or that was archived (which
1619
+ * ends its attachments), is not answering and may be rejoined.
1620
+ * @param holderId - Identity currently registered under the name.
1621
+ * @param sessionName - Name the caller asked to rejoin, for the error.
1622
+ */
1623
+ #assertHolderDetached(holderId, sessionName) {
1624
+ const live = this.#db
1625
+ .prepare("select 1 as present from sessions where agent_id = ? and active = 1 and expires_at > ? limit 1")
1626
+ .get(holderId, this.#now());
1627
+ if (!live)
1628
+ return;
1629
+ const holder = this.#db.prepare("select implementation_id from agents where id = ?").get(holderId);
1630
+ const address = this.#agentAddress(holderId, this.workspaceNames());
1631
+ const implementation = typeof holder?.implementation_id === "string" ? `${holder.implementation_id}, ` : "";
1632
+ throw new ProtocolError("alias_conflict", `Session name ${sessionName} is held by ${address} (${implementation}${holderId}), whose attachment is live; choose another name, or end that session first`);
1633
+ }
1634
+ #agentForSessionKey(workspaceId, implementationId, sessionKey) {
1635
+ const row = this.#db
1636
+ .prepare("select id, registration_name, registration_ephemeral from agents where workspace_id = ? and implementation_id = ? and session_key = ? and state = 'active'")
1637
+ .get(workspaceId, implementationId, sessionKey);
1638
+ if (!row)
1639
+ return undefined;
1640
+ return {
1641
+ agentId: readStoredAgentId(row.id, "agents.id"),
1642
+ sessionName: String(row.registration_name),
1643
+ ephemeral: Number(row.registration_ephemeral) === 1,
1644
+ };
1645
+ }
1646
+ /**
1647
+ * Point an identity at a conversation key, optionally retiring the attachments of the previous one.
1648
+ * @param agentId - Identity whose key is being set.
1649
+ * @param sessionKey - Conversation key to bind.
1650
+ * @param evictExistingSessions - Whether to retire the identity's current attachments. Only a
1651
+ * caller that hands back a fresh attachment may pass true: retiring them for a caller that does
1652
+ * not leaves it holding a session the broker will refuse and no reservation to replace it with.
1653
+ */
1654
+ #bindSessionKey(agentId, sessionKey, evictExistingSessions) {
1655
+ const row = this.#db.prepare("select session_key from agents where id = ?").get(agentId);
1656
+ if (!row)
1657
+ throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
1658
+ const current = typeof row.session_key === "string" ? row.session_key : undefined;
1659
+ if (current === sessionKey)
1660
+ return;
1661
+ if (current !== undefined && evictExistingSessions) {
1662
+ this.#write("update sessions set active = 0 where agent_id = ?", agentId);
1663
+ }
1664
+ this.#write("update agents set session_key = ? where id = ?", sessionKey, agentId);
1665
+ }
1666
+ #resolveSessionKeyAtAttach(reserved, sessionKey) {
1667
+ const reservedAgent = this.#db
1668
+ .prepare("select workspace_id, implementation_id, session_key from agents where id = ?")
1669
+ .get(reserved);
1670
+ if (!reservedAgent)
1671
+ throw new ProtocolError("unknown_recipient", "Agent identity is unknown");
1672
+ if (typeof reservedAgent.implementation_id !== "string") {
1673
+ throw new ProtocolError("validation_failed", "A session key requires a registered implementation");
1674
+ }
1675
+ const owner = this.#agentForSessionKey(String(reservedAgent.workspace_id), reservedAgent.implementation_id, sessionKey);
1676
+ if (!owner || owner.agentId === reserved) {
1677
+ this.#bindSessionKey(reserved, sessionKey, true);
1678
+ return reserved;
1679
+ }
1680
+ this.#displaceHolder(owner.agentId);
1681
+ this.#discardUnusedIdentity(reserved, owner.agentId);
1682
+ return owner.agentId;
1683
+ }
1684
+ /**
1685
+ * End what a process holds on an identity it is leaving or losing: its sessions, so a lapsed one
1686
+ * cannot be resumed and act as the identity or detach it, and its unconsumed reservations, which
1687
+ * outlive a lease and would attach the same way. On a hand-over this runs before the newcomer's
1688
+ * reservations are made or moved onto the identity, so nothing of the newcomer's is touched.
1689
+ * @param agentId - Identity being left or taken over.
1690
+ */
1691
+ #displaceHolder(agentId) {
1692
+ this.#write("update sessions set active = 0 where agent_id = ? and active = 1", agentId);
1693
+ this.#write("delete from reservations where agent_id = ? and consumed_at is null", agentId);
1694
+ }
1695
+ /**
1696
+ * Remove an identity a launch minted for a conversation that turned out to already own one. It is
1697
+ * deleted rather than reaped so it never reaches operator projection: nothing ever used it, and an
1698
+ * archived row for a duplicate that never existed is noise a person would have to interpret.
1699
+ *
1700
+ * A launch reserves one reservation per channel and the process consumes them one at a time, so any
1701
+ * reservation still outstanding moves to the adopting identity. Without that, the second channel of a
1702
+ * multi-channel harness would attach against an identity that no longer exists.
1703
+ * @param agentId - Reserved identity that has never carried traffic.
1704
+ * @param adoptedBy - Identity that takes over this one's outstanding attachment reservations.
1705
+ * @param options - Whether the caller has proven it owns this identity's live connections.
1706
+ */
1707
+ #discardUnusedIdentity(agentId, adoptedBy, options = {}) {
1708
+ const attached = this.#db.prepare("select 1 as present from sessions where agent_id = ? limit 1").get(agentId);
1709
+ const routed = this.#db
1710
+ .prepare("select 1 as present from messages where sender_id = ? or recipient_id = ? limit 1")
1711
+ .get(agentId, agentId);
1712
+ if ((attached !== undefined && options.allowConnections !== true) || routed !== undefined)
1713
+ throw new ProtocolError("conflict", "Reserved identity has already been used");
1714
+ this.#write("update sessions set active = 0 where agent_id = ?", agentId);
1715
+ this.#write("delete from alias_history where agent_id = ?", agentId);
1716
+ this.#write("update reservations set agent_id = ? where agent_id = ? and consumed_at is null", adoptedBy, agentId);
1717
+ this.#write("delete from reservations where agent_id = ?", agentId);
1718
+ this.#write("delete from agents where id = ?", agentId);
1719
+ }
1720
+ #reserveAttachment(agentId, channel) {
1721
+ const reservationId = issueOpaqueToken("attach");
1722
+ this.#activeAgent(agentId);
1723
+ this.#lifecycleSynchronization?.checkpoint("attachment_reservation_active");
1724
+ this.#write("insert into reservations(reservation_id, agent_id, channel, expires_at, consumed_at) values (?, ?, ?, ?, null)", reservationId, agentId, channel, this.#now() + this.#reservationTtlMs);
1725
+ return reservationId;
1726
+ }
1727
+ /**
1728
+ * Execute one write and declare the table it changed to the surrounding transaction.
1729
+ *
1730
+ * Every broker write goes through here. A write that reaches the connection directly is invisible
1731
+ * to the changeset, which is why `check:broker-writes` rejects one.
1732
+ * @param sql - Single insert, update, or delete statement.
1733
+ * @param parameters - Values bound to the statement's positional placeholders.
1734
+ * @returns - Change count and inserted row identity.
1735
+ */
1736
+ #write(sql, ...parameters) {
1737
+ let statement = this.#writeStatements.get(sql);
1738
+ if (!statement) {
1739
+ statement = this.#db.prepare(sql);
1740
+ this.#writeStatements.set(sql, statement);
1741
+ }
1742
+ const result = statement.run(...parameters);
1743
+ if (result.changes > 0) {
1744
+ this.#declaredRowChanges += result.changes;
1745
+ this.#writtenTables?.add(mutatedTable(sql));
1746
+ }
1747
+ return result;
1748
+ }
1749
+ #rowChanges() {
1750
+ return Number(this.#db.prepare("select total_changes() as changes").get().changes);
1751
+ }
1752
+ #transaction(fn) {
1753
+ if (this.#writtenTables)
1754
+ throw new Error("Broker#transaction does not nest");
1755
+ const tables = new Set();
1756
+ this.#writtenTables = tables;
1757
+ this.#declaredRowChanges = 0;
1758
+ this.#db.exec("begin immediate");
1759
+ const rowChangesBefore = this.#rowChanges();
1760
+ try {
1761
+ const result = fn();
1762
+ const declared = this.#declaredRowChanges;
1763
+ const changed = this.#rowChanges() - rowChangesBefore;
1764
+ this.#db.exec("commit");
1765
+ this.#writtenTables = undefined;
1766
+ this.#declaredRowChanges = 0;
1767
+ if (changed > 0)
1768
+ this.#publishCommitted({ tables: changed === declared ? tables : undefined });
1769
+ return result;
1770
+ }
1771
+ catch (error) {
1772
+ this.#db.exec("rollback");
1773
+ this.#writtenTables = undefined;
1774
+ this.#declaredRowChanges = 0;
1775
+ throw error;
1776
+ }
1777
+ }
1778
+ #publishCommitted(commit) {
1779
+ for (const listener of [...this.#commitListeners]) {
1780
+ try {
1781
+ listener(commit);
1782
+ }
1783
+ catch {
1784
+ // A post-commit observer cannot retroactively reject an already durable broker mutation.
1785
+ }
1786
+ }
1787
+ }
1788
+ #appendEvent(messageId, source, type, text, tone) {
1789
+ this.#write("insert into message_events(message_id, at, source, type, text, tone) values (?, ?, ?, ?, ?, ?)", messageId, this.#now(), source, type, text, tone);
1790
+ }
1791
+ #nextImplementationAlias(workspaceId, implementationId) {
1792
+ for (let ordinal = 1; ordinal <= IMPLEMENTATION_ALIAS_LIMIT; ordinal += 1) {
1793
+ const candidate = `${implementationId}-${String(ordinal)}`;
1794
+ if (this.#nameHeldInWorkspace(workspaceId, candidate))
1795
+ continue;
1796
+ try {
1797
+ this.#assertNameAvailable(workspaceId, candidate);
1798
+ return candidate;
1799
+ }
1800
+ catch {
1801
+ continue;
1802
+ }
1803
+ }
1804
+ return defaultAgentSessionName(issueOpaqueToken("agent"));
1805
+ }
1806
+ #nameHeldInWorkspace(workspaceId, sessionName) {
1807
+ const row = this.#db
1808
+ .prepare("select 1 from agents where workspace_id = ? and registration_name = ? limit 1")
1809
+ .get(workspaceId, sessionName);
1810
+ return Boolean(row);
1811
+ }
1812
+ #assertNameAvailable(workspaceId, sessionName, owner) {
1813
+ const alias = normalizeAlias(sessionName);
1814
+ const registration = this.#db
1815
+ .prepare("select id from agents where workspace_id = ? and registration_name = ? and state != 'reaped'")
1816
+ .get(workspaceId, sessionName);
1817
+ const historical = this.#db
1818
+ .prepare("select agent_id from alias_history where workspace_id = ? and alias = ?")
1819
+ .get(workspaceId, alias);
1820
+ if (registration && registration.id !== owner) {
1821
+ throw new ProtocolError("alias_conflict", "Agent alias is already in use in this workspace");
1822
+ }
1823
+ if (historical && historical.agent_id !== owner) {
1824
+ throw new ProtocolError("alias_conflict", "Agent alias is a retired name that stays reserved in this workspace");
1825
+ }
1826
+ }
1827
+ #pairSendCount(from, to) {
1828
+ const row = this.#db
1829
+ .prepare("select count(*) as count from messages where sender_id = ? and recipient_id = ? and created_at > ?")
1830
+ .get(from, to, this.#now() - this.#perPairRateWindowMs);
1831
+ return Number(row?.count ?? 0);
1832
+ }
1833
+ /**
1834
+ * Hold the redelivery schedule to one row per recipient with undelivered messages.
1835
+ *
1836
+ * Arming is conditional so a second send cannot restart a schedule already in progress: a chatty
1837
+ * sender would otherwise hold its recipient at the first interval forever.
1838
+ * @param agentId - Recipient whose undelivered set just changed.
1839
+ */
1840
+ #syncRedelivery(agentId) {
1841
+ if (this.#inboxMessageCount(agentId) === 0) {
1842
+ this.#write("delete from wakes where agent_id = ?", agentId);
1843
+ return;
1844
+ }
1845
+ const now = this.#now();
1846
+ this.#write("insert or ignore into wakes(agent_id, attempts, next_wake_at) values (?, 0, ?)", agentId, now + this.#redeliveryDelayMs(0));
1847
+ }
1848
+ /**
1849
+ * Report how long to wait before the next redelivery wake.
1850
+ * @param attempts - Redelivery wakes already spent on this inbox.
1851
+ * @returns - Delay in milliseconds, doubling from the base until it reaches the ceiling.
1852
+ */
1853
+ #redeliveryDelayMs(attempts) {
1854
+ return this.#redeliveryBaseDelayMs * 2 ** Math.min(attempts, REDELIVERY_DOUBLING_LIMIT);
1855
+ }
1856
+ /**
1857
+ * Report when the next redelivery wake for one recipient is due.
1858
+ *
1859
+ * The give-up horizon of the oldest queued message caps the doubling, so abandonment lands on the
1860
+ * budget rather than up to one whole interval past it. A claimed message is excluded because its
1861
+ * own claim already schedules a sweep, and a horizon that has passed would make this deadline
1862
+ * permanently due.
1863
+ * @param agentId - Recipient whose schedule is being advanced.
1864
+ * @param now - Current time, already read once by the caller.
1865
+ * @param attempts - Redelivery wakes spent on this inbox including the one just scheduled.
1866
+ * @returns - Timestamp of the next wake.
1867
+ */
1868
+ #nextWakeAt(agentId, now, attempts) {
1869
+ const scheduled = now + this.#redeliveryDelayMs(attempts);
1870
+ const oldest = this.#db
1871
+ .prepare("select min(created_at) as oldest from messages where recipient_id = ? and state = 'queued'")
1872
+ .get(agentId);
1873
+ if (typeof oldest?.oldest !== "number")
1874
+ return scheduled;
1875
+ return Math.min(scheduled, oldest.oldest + this.#redeliveryGiveUpMs);
1876
+ }
1877
+ /**
1878
+ * Move every message that outlived the redelivery budget to its terminal dead-lettered state.
1879
+ *
1880
+ * The budget is measured per message rather than per inbox, so a message that arrived while an
1881
+ * older one was still being retried gets the whole budget of its own. Only queued messages are
1882
+ * abandoned: dead-lettering one a recipient currently holds would make its acknowledgement match
1883
+ * nothing and be reported as success while the sender read the delivery as abandoned.
1884
+ * @param agentId - Recipient whose oldest messages are being abandoned.
1885
+ * @param now - Current time, already read once by the caller.
1886
+ */
1887
+ #abandonExhaustedMessages(agentId, now) {
1888
+ this.#deadLetter(this.#db
1889
+ .prepare("select id from messages where recipient_id = ? and state = 'queued' and created_at <= ? order by sequence")
1890
+ .all(agentId, now - this.#redeliveryGiveUpMs), "dead-lettered after redelivery gave up");
1891
+ }
1892
+ /**
1893
+ * Move messages to their terminal dead-lettered state, releasing any claim outstanding on them.
1894
+ * @param messages - Rows carrying the message identities to abandon.
1895
+ * @param reason - Operator-facing lifecycle text naming why they were abandoned.
1896
+ */
1897
+ #deadLetter(messages, reason) {
1898
+ for (const row of messages) {
1899
+ const messageId = String(row.id);
1900
+ this.#write("update messages set state = 'dead_lettered', claim_token = null, claim_expires_at = null where id = ?", messageId);
1901
+ this.#appendEvent(messageId, "broker", "dead_lettered", reason, "danger");
1902
+ }
1903
+ }
1904
+ /**
1905
+ * Report whether an identity is currently archived.
1906
+ *
1907
+ * An archived agent stays addressable until it is reaped, so a sender is told rather than refused:
1908
+ * the mail waits for the conversation to come back and claim it.
1909
+ * @param agentId - Identity being addressed.
1910
+ * @returns - Whether an operator has archived it.
1911
+ */
1912
+ #isArchived(agentId) {
1913
+ const row = this.#db.prepare("select archived_at from agents where id = ?").get(agentId);
1914
+ return typeof row?.archived_at === "number";
1915
+ }
1916
+ #inboxMessageCount(agentId) {
1917
+ const row = this.#db
1918
+ .prepare("select count(*) as count from messages where recipient_id = ? and state in ('queued', 'claimed')")
1919
+ .get(agentId);
1920
+ return Number(row?.count ?? 0);
1921
+ }
1922
+ /**
1923
+ * Settle one message into a terminal state from within an open transaction.
1924
+ *
1925
+ * Only a live message settles: a source outside `queued` and `claimed` is refused with `conflict`,
1926
+ * so a terminal state is never rewritten by a later expiry. The sender reads delivery state back
1927
+ * through {@link status}, and rewriting an `acknowledged` message as `expired` would tell it the
1928
+ * work it already had consumed was abandoned.
1929
+ * @param messageId - Message being settled.
1930
+ * @param outcome - Terminal state to settle into; an expiry also names who asked, distinguishing
1931
+ * an operator command from the broker's own ttl sweep in the operator-facing lifecycle text. A
1932
+ * failure carries no actor: every caller settles it the same way.
1933
+ */
1934
+ #transitionTerminalInTransaction(messageId, outcome) {
1935
+ const { state } = outcome;
1936
+ const current = this.#db.prepare("select state, body, recipient_id from messages where id = ?").get(messageId);
1937
+ if (!current)
1938
+ throw new ProtocolError("validation_failed", "Unknown message");
1939
+ if (current.state !== "queued" && current.state !== "claimed") {
1940
+ throw new ProtocolError("conflict", `Message state cannot transition to ${state}`);
1941
+ }
1942
+ const changed = this.#write("update messages set state = ?, claim_token = null, claim_expires_at = null where id = ?", state, messageId);
1943
+ if (changed.changes === 0)
1944
+ throw new ProtocolError("validation_failed", "Unknown message");
1945
+ this.#appendEvent(messageId, "broker", state, outcome.state === "expired"
1946
+ ? outcome.actor === "operator"
1947
+ ? "expired by operator"
1948
+ : "expired at ttl"
1949
+ : "failed", state === "expired" ? "muted" : "danger");
1950
+ this.#syncSkippingUnreadable(current.recipient_id, "messages.recipient_id", messageId);
1951
+ }
1952
+ /**
1953
+ * Read a stored agent id, reporting one it cannot read rather than throwing.
1954
+ *
1955
+ * Reports through the projection's ring, and writes the diagnostic line only when the ring did not
1956
+ * already hold this row and column.
1957
+ * @param value - Column value as the driver returned it.
1958
+ * @param column - Column named in the diagnostic, qualified by its table.
1959
+ * @param rowKey - Identifies the row within that table, so two bad rows are two reports.
1960
+ * @returns - The identity, or null when the value is not one.
1961
+ */
1962
+ #readableAgentId(value, column, rowKey) {
1963
+ if (isAgentId(value))
1964
+ return value;
1965
+ if (this.#projection.recordStoredRowSkip({ type: "stored_row_skipped", column, rowKey })) {
1966
+ this.#reportSafely(`pager: ${column} of row ${rowKey} cannot be read`);
1967
+ }
1968
+ return null;
1969
+ }
1970
+ /**
1971
+ * Pair each readable `agents.id` with its row, skipping and reporting every row whose id cannot be
1972
+ * read. For a read that answers which agents exist: one unreadable row must not fail it for every
1973
+ * caller.
1974
+ * @param rows - Rows of `agents`, each carrying its `rowid` and `id`.
1975
+ * @returns - The readable rows, in the order given.
1976
+ */
1977
+ #readableAgentRows(rows) {
1978
+ return rows.flatMap((row) => {
1979
+ const id = this.#readableAgentId(row.id, "agents.id", String(row.rowid));
1980
+ return id === null ? [] : [{ id, row }];
1981
+ });
1982
+ }
1983
+ /**
1984
+ * Name the broadcast a copy queued for this recipient belongs to, by the recipient's workspace.
1985
+ * @param recipientId - Agent whose copies are being claimed.
1986
+ * @param workspaces - Current name of each workspace.
1987
+ * @returns - The broadcast address under the workspace's current name, or nothing when the
1988
+ * recipient or its workspace has none.
1989
+ */
1990
+ #currentBroadcastAddress(recipientId, workspaces) {
1991
+ const row = this.#db.prepare("select workspace_id from agents where id = ?").get(recipientId);
1992
+ const name = row ? workspaces.get(String(row.workspace_id)) : undefined;
1993
+ return name === undefined ? undefined : `${BROADCAST_ADDRESS_PREFIX}${name}`;
1994
+ }
1995
+ /**
1996
+ * Start a recipient's redelivery schedule if it has none, or clear it once the inbox is empty,
1997
+ * skipping a row whose recipient cannot be read.
1998
+ * @param value - Stored recipient identity as the driver returned it.
1999
+ * @param column - Column named in the diagnostic.
2000
+ * @param rowKey - Identifies the row the value came from.
2001
+ */
2002
+ #syncSkippingUnreadable(value, column, rowKey) {
2003
+ const agentId = this.#readableAgentId(value, column, rowKey);
2004
+ if (agentId !== null)
2005
+ this.#syncRedelivery(agentId);
2006
+ }
2007
+ #allocateTrafficPosition() {
2008
+ const result = this.#write("insert into traffic_positions default values");
2009
+ const position = Number(result.lastInsertRowid);
2010
+ if (!Number.isSafeInteger(position) || position < 1) {
2011
+ throw new ProtocolError("validation_failed", "Could not allocate operator traffic position");
2012
+ }
2013
+ return position;
2014
+ }
2015
+ #activeAttachment(sessionId, fencingToken, epoch) {
2016
+ if (epoch !== this.#epoch)
2017
+ throw new ProtocolError("expired", "Broker epoch changed; re-attach required");
2018
+ const row = this.#db
2019
+ .prepare("select * from sessions where id = ? and fencing_token = ? and active = 1")
2020
+ .get(sessionId, fencingToken);
2021
+ if (!row || Number(row.expires_at) <= this.#now())
2022
+ throw new ProtocolError("expired", "Attachment expired; re-attach required");
2023
+ // The row's own recorded epoch fences it independent of what the caller claims, matching
2024
+ // resolveSession: a caller presenting the current epoch by other means cannot renew a row minted
2025
+ // under a superseded one.
2026
+ if (String(row.epoch) !== this.#epoch)
2027
+ throw new ProtocolError("expired", "Broker epoch changed; re-attach required");
2028
+ return row;
2029
+ }
2030
+ #agent(agentId) {
2031
+ const row = this.#db.prepare("select * from agents where id = ? and state != 'reaped'").get(agentId);
2032
+ if (!row)
2033
+ throw new ProtocolError("unknown_recipient", "Unknown agent");
2034
+ return row;
2035
+ }
2036
+ #activeAgent(agentId) {
2037
+ const row = this.#db.prepare("select * from agents where id = ? and state = 'active'").get(agentId);
2038
+ if (!row)
2039
+ throw new ProtocolError("conflict", "Agent identity is not active");
2040
+ return row;
2041
+ }
2042
+ #message(messageId) {
2043
+ const row = this.#db.prepare("select * from messages where id = ?").get(messageId);
2044
+ if (!row)
2045
+ throw new ProtocolError("validation_failed", "Unknown message");
2046
+ return {
2047
+ state: readStoredEnum(row.state, MESSAGE_STATES, "messages.state"),
2048
+ to: readStoredAgentId(row.recipient_id, "messages.recipient_id"),
2049
+ // Null rather than a throw: only the sender check in `status()` reads it, and a sender that
2050
+ // cannot be identified fails that check, which is the same answer as not being the sender.
2051
+ from: isAgentId(row.sender_id) ? row.sender_id : null,
2052
+ };
2053
+ }
2054
+ /**
2055
+ * Reject an ephemeral recipient no process is answering for.
2056
+ *
2057
+ * An ephemeral identity is auto-named and anonymous: nobody addresses one intending it to be read
2058
+ * after the conversation behind it has gone. Its row outlives its attachment only so a resume can
2059
+ * rejoin it, so accepting a send against a detached one queues a message that is never claimed.
2060
+ * A named identity is the opposite case and keeps its durable inbox while it is offline.
2061
+ *
2062
+ * An archived identity, of either kind, is accepted: the operator put it away deliberately and
2063
+ * attaching takes it back out, so what is queued for it waits until then or until it is reaped.
2064
+ * @param agentId - Resolved recipient identity.
2065
+ */
2066
+ #assertRoutableRecipient(agentId) {
2067
+ const agent = this.#db
2068
+ .prepare("select registration_ephemeral, archived_at from agents where id = ?")
2069
+ .get(agentId);
2070
+ if (!agent || Number(agent.registration_ephemeral) !== 1 || typeof agent.archived_at === "number")
2071
+ return;
2072
+ const attached = this.#db.prepare("select 1 as present from sessions where agent_id = ? limit 1").get(agentId);
2073
+ // An identity that has never attached is still starting up; only a lapsed one is a dead end.
2074
+ if (!attached)
2075
+ return;
2076
+ const live = this.#db
2077
+ .prepare("select 1 as present from sessions where agent_id = ? and active = 1 and expires_at > ? limit 1")
2078
+ .get(agentId, this.#now());
2079
+ if (!live)
2080
+ throw new ProtocolError("unknown_recipient", "Recipient session has ended");
2081
+ }
2082
+ #resolveRecipientIdentity(workspaceId, recipient) {
2083
+ if (isDirectAgentAddress(recipient)) {
2084
+ const row = this.#db.prepare("select id from agents where id = ? and state != 'reaped'").get(recipient);
2085
+ if (row)
2086
+ return readStoredAgentId(row.id, "agents.id");
2087
+ throw new ProtocolError("unknown_recipient", "Unknown recipient");
2088
+ }
2089
+ // A parser rejection and a name that resolves to nobody are the same routing failure to the sender.
2090
+ let address;
2091
+ try {
2092
+ address = parseAgentAddress(recipient);
2093
+ }
2094
+ catch {
2095
+ throw new ProtocolError("unknown_recipient", "Unknown recipient");
2096
+ }
2097
+ const targetWorkspace = address.workspace === null ? workspaceId : this.#workspaceForName(address.workspace, this.workspaceNames());
2098
+ const current = this.#db
2099
+ .prepare("select id from agents where workspace_id = ? and registration_name = ? and state != 'reaped'")
2100
+ .get(targetWorkspace, address.sessionName);
2101
+ if (current)
2102
+ return readStoredAgentId(current.id, "agents.id");
2103
+ const historical = this.#db
2104
+ .prepare("select a.registration_name from alias_history h join agents a on a.id = h.agent_id where h.workspace_id = ? and h.alias = ? and a.state != 'reaped'")
2105
+ .get(targetWorkspace, normalizeAlias(address.sessionName));
2106
+ if (typeof historical?.registration_name === "string") {
2107
+ throw new ProtocolError("recipient_alias_changed", `Agent alias changed to ${normalizeAlias(historical.registration_name)}; try that alias instead`);
2108
+ }
2109
+ throw new ProtocolError("unknown_recipient", "Unknown recipient");
2110
+ }
2111
+ /**
2112
+ * Name every registered workspace by the shortest trailing path segments unique across the broker.
2113
+ * @returns - Addressable workspace name for each workspace identity.
2114
+ */
2115
+ workspaceNames() {
2116
+ const rows = this.#db.prepare("select id, path from workspaces order by path").all();
2117
+ return uniquePathNames(rows.map((row) => ({ id: String(row.id), path: String(row.path) })));
2118
+ }
2119
+ #agentAddress(agentId, workspaces) {
2120
+ const row = this.#db.prepare("select registration_name, workspace_id from agents where id = ?").get(agentId);
2121
+ if (!row)
2122
+ return agentId;
2123
+ const alias = row.registration_name;
2124
+ if (typeof alias !== "string" || alias === "")
2125
+ return agentId;
2126
+ const workspace = workspaces.get(String(row.workspace_id)) ?? String(row.workspace_id);
2127
+ return `${normalizeAlias(alias)}@${workspace}`;
2128
+ }
2129
+ #workspaceForName(name, names) {
2130
+ for (const [workspaceId, workspaceName] of names) {
2131
+ if (workspaceName === name)
2132
+ return workspaceId;
2133
+ }
2134
+ throw new ProtocolError("unknown_recipient", `Unknown workspace ${name}`);
2135
+ }
2136
+ #reapExpiredEphemeralAgents() {
2137
+ this.#transaction(() => {
2138
+ const expiredAt = this.#now();
2139
+ const expired = this.#db
2140
+ .prepare("select a.id from agents a where a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 1 and a.session_key is null and exists (select 1 from sessions s where s.agent_id = a.id) and not exists (select 1 from sessions s where s.agent_id = a.id and s.active = 1 and s.expires_at > ?)")
2141
+ .all(expiredAt);
2142
+ this.#lifecycleSynchronization?.checkpoint("ephemeral_expiry_scanned");
2143
+ for (const row of expired)
2144
+ this.#reapAgent(String(row.id));
2145
+ const unattached = this.#db
2146
+ .prepare(`select a.id from agents a where ${UNATTACHED_EPHEMERAL_LAPSED_SQL}`)
2147
+ .all(expiredAt);
2148
+ for (const row of unattached)
2149
+ this.#reapAgent(String(row.id));
2150
+ });
2151
+ this.#reapAbandonedResumableAgents();
2152
+ this.#archiveDetachedNamedAgents();
2153
+ this.#reapExpiredArchivedAgents();
2154
+ }
2155
+ /**
2156
+ * Retire one identity: it stops resolving, its attachments and reserved names are released, any
2157
+ * reservation nobody consumed is discarded, and the messages still waiting for it are dead-lettered.
2158
+ *
2159
+ * Reaping settles the inbox at once rather than leaving it to the redelivery give-up budget, and
2160
+ * deletes the wakes row so no further wake targets a reaped identity. The archive mark is cleared:
2161
+ * the identity is no longer archived, it is gone.
2162
+ * @param agentId - Identity being retired.
2163
+ */
2164
+ #reapAgent(agentId) {
2165
+ this.#write("update agents set state = 'reaped', archived_at = null where id = ?", agentId);
2166
+ this.#write("update sessions set active = 0 where agent_id = ?", agentId);
2167
+ this.#write("delete from alias_history where agent_id = ?", agentId);
2168
+ this.#write("delete from reservations where agent_id = ? and consumed_at is null", agentId);
2169
+ this.#deadLetter(this.#db
2170
+ .prepare("select id from messages where recipient_id = ? and state in ('queued','claimed') order by sequence")
2171
+ .all(agentId), "dead-lettered — recipient was reaped");
2172
+ this.#write("delete from wakes where agent_id = ?", agentId);
2173
+ }
2174
+ /**
2175
+ * Retire unnamed resumable identities whose conversation never came back. Detachment alone never
2176
+ * reaps one, because a person resumes a conversation long after its process stopped answering; it
2177
+ * is held for the retention window, or for the shorter unused window when it never sent or
2178
+ * received a message. A named identity is archived instead, by `#archiveDetachedNamedAgents`, and
2179
+ * an archived identity is not measured here; `#reapExpiredArchivedAgents` owns it.
2180
+ */
2181
+ #reapAbandonedResumableAgents() {
2182
+ this.#transaction(() => {
2183
+ const unnamedResumable = "a.state = 'active' and a.archived_at is null and a.session_key is not null and a.registration_ephemeral = 1";
2184
+ const abandoned = this.#db
2185
+ .prepare(`select a.id from agents a where ${unnamedResumable} and ${DETACHED_SINCE_SQL}`)
2186
+ .all(this.#now(), this.#now() - this.#resumableRetentionMs);
2187
+ const unused = this.#db
2188
+ .prepare(`select a.id from agents a where ${unnamedResumable} and ${DETACHED_SINCE_SQL} and not exists (${MESSAGE_USING_AGENT_SQL})`)
2189
+ .all(this.#now(), this.#now() - this.#unusedResumableRetentionMs);
2190
+ this.#lifecycleSynchronization?.checkpoint("resumable_abandonment_scanned");
2191
+ for (const row of [...abandoned, ...unused])
2192
+ this.#reapAgent(String(row.id));
2193
+ });
2194
+ }
2195
+ /**
2196
+ * Archive named identities detached for longer than the named archive window. The identity keeps
2197
+ * its name, conversation key, and inbox, and its attachments are left as they are, so a holder
2198
+ * that resumes one, or a conversation that attaches again, un-archives it.
2199
+ */
2200
+ #archiveDetachedNamedAgents() {
2201
+ this.#transaction(() => {
2202
+ const detached = this.#db
2203
+ .prepare(`select a.id from agents a where a.state = 'active' and a.archived_at is null and a.registration_ephemeral = 0 and ${DETACHED_SINCE_SQL}`)
2204
+ .all(this.#now(), this.#now() - this.#namedArchiveAfterMs);
2205
+ for (const row of detached)
2206
+ this.#write("update agents set archived_at = ? where id = ?", this.#now(), String(row.id));
2207
+ });
2208
+ }
2209
+ /**
2210
+ * Retire archived identities whose archive window has run out.
2211
+ *
2212
+ * One rule for every identity, ephemeral or named, resumable or not, and however it was archived:
2213
+ * an archived identity is kept for the archive window measured from the moment it was archived,
2214
+ * and attaching within it un-archives the identity.
2215
+ */
2216
+ #reapExpiredArchivedAgents() {
2217
+ this.#transaction(() => {
2218
+ const archivedBefore = this.#now() - this.#archivedRetentionMs;
2219
+ const expired = this.#db
2220
+ .prepare("select id from agents where state = 'active' and archived_at is not null and archived_at <= ?")
2221
+ .all(archivedBefore);
2222
+ for (const row of expired)
2223
+ this.#reapAgent(String(row.id));
2224
+ });
2225
+ }
2226
+ }
2227
+ /**
2228
+ * Render a millisecond duration as an operator-facing label, in the largest unit that divides it
2229
+ * evenly so a configured value round-trips exactly instead of losing precision to rounding.
2230
+ * @param ms - Duration in milliseconds.
2231
+ * @returns - Duration suffixed with its unit (h, m, s, or ms).
2232
+ */
2233
+ function formatDurationLabel(ms) {
2234
+ if (ms % 3_600_000 === 0)
2235
+ return `${String(ms / 3_600_000)}h`;
2236
+ if (ms % 60_000 === 0)
2237
+ return `${String(ms / 60_000)}m`;
2238
+ if (ms % 1_000 === 0)
2239
+ return `${String(ms / 1_000)}s`;
2240
+ return `${String(ms)}ms`;
2241
+ }
2242
+ /** Ordinals tried before a generated default alias falls back to an identity-derived name. */
2243
+ const IMPLEMENTATION_ALIAS_LIMIT = 512;
2244
+ function defaultAgentSessionName(seed) {
2245
+ return `agent-${createHash("sha256").update(seed).digest("hex").slice(0, 12)}`;
2246
+ }
2247
+ /**
2248
+ * Name the table one write statement changes.
2249
+ * @param sql - Single insert, update, or delete statement.
2250
+ * @returns - Table the statement writes to.
2251
+ */
2252
+ function mutatedTable(sql) {
2253
+ const match = /^\s*(?:insert(?:\s+or\s+\w+)?\s+into|update|delete\s+from)\s+([A-Za-z_][A-Za-z0-9_]*)/iu.exec(sql);
2254
+ if (!match?.[1])
2255
+ throw new Error(`Broker write statement names no table: ${sql}`);
2256
+ return match[1];
2257
+ }
2258
+ function contentType(body) {
2259
+ return typeof body === "string" ? "text" : "json";
2260
+ }
2261
+ function routingEvent(routing, to) {
2262
+ switch (routing.outcome) {
2263
+ case "direct":
2264
+ return { type: "direct", text: "direct id" };
2265
+ case "resolved":
2266
+ return { type: "alias_resolved", text: `alias resolved to ${to}` };
2267
+ case "broadcast":
2268
+ return { type: "broadcast", text: `broadcast to ${routing.addressedTo}` };
2269
+ }
2270
+ }