opencode-crosstalk 0.0.0-stage → 0.2.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 (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +460 -2
  3. package/assets/CursorGuard.png +0 -0
  4. package/assets/Wren.png +0 -0
  5. package/assets/avatars/ATTRIBUTION.md +18 -0
  6. package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
  7. package/assets/avatars/female/02-manager-b16a5b.png +0 -0
  8. package/assets/avatars/female/02-manager-e7a391.png +0 -0
  9. package/assets/avatars/female/03-coder-92594b.png +0 -0
  10. package/assets/avatars/female/03-coder-e5a07e.png +0 -0
  11. package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
  12. package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
  13. package/assets/avatars/female/08-tester-d78774.png +0 -0
  14. package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
  15. package/assets/avatars/female/10-builder-92594b.png +0 -0
  16. package/assets/avatars/female/10-builder-e5a07e.png +0 -0
  17. package/assets/avatars/female/13-researcher-92594b.png +0 -0
  18. package/assets/avatars/female/13-researcher-e7a391.png +0 -0
  19. package/assets/avatars/female/14-writer-623d36.png +0 -0
  20. package/assets/avatars/female/14-writer-e5a07e.png +0 -0
  21. package/assets/avatars/female/16-fixer-e7a391.png +0 -0
  22. package/assets/avatars/female/17-deployer-92594b.png +0 -0
  23. package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
  24. package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
  25. package/assets/avatars/female/23-mentor-e7a391.png +0 -0
  26. package/assets/avatars/female/25-pioneer-d78774.png +0 -0
  27. package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
  28. package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
  29. package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
  30. package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
  31. package/assets/avatars/female/34-optimizer-92594b.png +0 -0
  32. package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
  33. package/assets/avatars/female/36-integrator-d78774.png +0 -0
  34. package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
  35. package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
  36. package/assets/avatars/female/37-compiler-e7a391.png +0 -0
  37. package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
  38. package/assets/avatars/female/42-shipper-623d36.png +0 -0
  39. package/assets/avatars/female/46-steward-623d36.png +0 -0
  40. package/assets/avatars/female/46-steward-d78774.png +0 -0
  41. package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
  42. package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
  43. package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
  44. package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
  45. package/assets/avatars/index.html +4 -0
  46. package/assets/avatars/male/01-explorer-d78774.png +0 -0
  47. package/assets/avatars/male/06-planner-92594b.png +0 -0
  48. package/assets/avatars/male/06-planner-e7a391.png +0 -0
  49. package/assets/avatars/male/07-reviewer-623d36.png +0 -0
  50. package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
  51. package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
  52. package/assets/avatars/male/09-migrator-e7a391.png +0 -0
  53. package/assets/avatars/male/11-debugger-623d36.png +0 -0
  54. package/assets/avatars/male/11-debugger-d78774.png +0 -0
  55. package/assets/avatars/male/12-designer-b16a5b.png +0 -0
  56. package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
  57. package/assets/avatars/male/15-editor-d78774.png +0 -0
  58. package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
  59. package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
  60. package/assets/avatars/male/18-scout-623d36.png +0 -0
  61. package/assets/avatars/male/18-scout-d78774.png +0 -0
  62. package/assets/avatars/male/19-architect-b16a5b.png +0 -0
  63. package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
  64. package/assets/avatars/male/20-operator-92594b.png +0 -0
  65. package/assets/avatars/male/20-operator-e7a391.png +0 -0
  66. package/assets/avatars/male/21-coordinator-623d36.png +0 -0
  67. package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
  68. package/assets/avatars/male/22-auditor-d78774.png +0 -0
  69. package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
  70. package/assets/avatars/male/25-pioneer-623d36.png +0 -0
  71. package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
  72. package/assets/avatars/male/28-librarian-623d36.png +0 -0
  73. package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
  74. package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
  75. package/assets/avatars/male/30-tracker-e7a391.png +0 -0
  76. package/assets/avatars/male/31-gardener-92594b.png +0 -0
  77. package/assets/avatars/male/32-refactorer-623d36.png +0 -0
  78. package/assets/avatars/male/32-refactorer-d78774.png +0 -0
  79. package/assets/avatars/male/35-profiler-623d36.png +0 -0
  80. package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
  81. package/assets/avatars/male/38-parser-92594b.png +0 -0
  82. package/assets/avatars/male/38-parser-e5a07e.png +0 -0
  83. package/assets/avatars/male/39-linter-623d36.png +0 -0
  84. package/assets/avatars/male/39-linter-d78774.png +0 -0
  85. package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
  86. package/assets/avatars/male/41-patcher-92594b.png +0 -0
  87. package/assets/avatars/male/41-patcher-e7a391.png +0 -0
  88. package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
  89. package/assets/avatars/male/43-maintainer-d78774.png +0 -0
  90. package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
  91. package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
  92. package/assets/avatars/male/44-guardian-e7a391.png +0 -0
  93. package/assets/avatars/male/45-sentinel-92594b.png +0 -0
  94. package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
  95. package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
  96. package/assets/avatars/male/49-hacker-623d36.png +0 -0
  97. package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
  98. package/assets/avatars/male/50-wrangler-d78774.png +0 -0
  99. package/assets/avatars/manifest.json +1202 -0
  100. package/index.ts +11 -0
  101. package/package.json +65 -4
  102. package/src/config.ts +176 -0
  103. package/src/core/avatar.ts +92 -0
  104. package/src/core/claims.ts +302 -0
  105. package/src/core/clock.ts +31 -0
  106. package/src/core/directory.ts +48 -0
  107. package/src/core/format.ts +382 -0
  108. package/src/core/mailbox.ts +239 -0
  109. package/src/core/mesh.ts +781 -0
  110. package/src/core/registry.ts +306 -0
  111. package/src/deliverer.ts +86 -0
  112. package/src/events.ts +66 -0
  113. package/src/index.ts +323 -0
  114. package/src/rpc.ts +52 -0
  115. package/src/storage.ts +105 -0
  116. package/src/tools/args.ts +132 -0
  117. package/src/tools/claim.ts +87 -0
  118. package/src/tools/heartbeat.ts +152 -0
  119. package/src/tools/inbox.ts +76 -0
  120. package/src/tools/index.ts +62 -0
  121. package/src/tools/peers.ts +101 -0
  122. package/src/tools/send.ts +84 -0
  123. package/src/tools/status.ts +137 -0
  124. package/src/tools/types.ts +72 -0
  125. package/src/tools/wait.ts +96 -0
  126. package/src/tui/avatar.ts +188 -0
  127. package/src/tui/index.tsx +216 -0
  128. package/src/types.ts +147 -0
  129. package/tui.ts +8 -0
@@ -0,0 +1,781 @@
1
+ /**
2
+ * @fileoverview The mesh: presence, mailboxes, and leases behind one façade.
3
+ *
4
+ * `Mesh` owns no I/O beyond the injected clock and an optional `Deliverer`, so
5
+ * every behaviour here is exercised directly in tests. `src/index.ts` supplies
6
+ * the real deliverer (OpenCode's `session.synthetic`) and the real event
7
+ * stream.
8
+ */
9
+
10
+ import type {
11
+ Claim,
12
+ ClaimOutcome,
13
+ CrosstalkMessage,
14
+ Declared,
15
+ Delivery,
16
+ MeshSnapshot,
17
+ MessageKind,
18
+ Peer,
19
+ PeerView,
20
+ Scope,
21
+ } from "../types.ts"
22
+ import { hasTask, portraitFor, type AvatarEntry } from "./avatar.ts"
23
+ import { delay, systemClock, type Clock } from "./clock.ts"
24
+ import { ClaimTable } from "./claims.ts"
25
+ import { Mailbox, type ListOptions, type WaitResult } from "./mailbox.ts"
26
+ import { Registry, type CrosstalkEvent, type ListFilter } from "./registry.ts"
27
+
28
+ export interface MeshOptions {
29
+ scope: Scope
30
+ staleAfterMs: number
31
+ evictAfterMs: number
32
+ maxMessages: number
33
+ messageTtlMs: number
34
+ claimTtlMs: number
35
+ maxWaitMs: number
36
+ pollMs: number
37
+ }
38
+
39
+ export interface Deliverer {
40
+ deliver(message: CrosstalkMessage): Promise<{ delivered: boolean; reason?: string }>
41
+ }
42
+
43
+ export interface MeshDeps {
44
+ options: MeshOptions
45
+ clock?: Clock
46
+ deliverer?: Deliverer
47
+ /** Plugin location, used when a peer record carries no project or directory. */
48
+ defaults?: { projectID?: string; directory?: string }
49
+ /** Bundled Personas; when present, each session freezes one portrait per task. */
50
+ avatars?: readonly AvatarEntry[]
51
+ idFactory?(seq: number): string
52
+ }
53
+
54
+ export interface SendInput {
55
+ to?: string
56
+ role?: string
57
+ all?: boolean
58
+ topic?: string
59
+ text: string
60
+ kind?: MessageKind
61
+ delivery?: Delivery
62
+ /** Set when the caller is answering another message. */
63
+ replyTo?: string
64
+ }
65
+
66
+ export interface SendOutcome {
67
+ recipients: Array<{ sessionID: string; delivered: boolean; reason?: string }>
68
+ skipped: Array<{ target: string; reason: string }>
69
+ messages: CrosstalkMessage[]
70
+ }
71
+
72
+ export interface InboxInput extends ListOptions {
73
+ waitMs?: number
74
+ markRead?: boolean
75
+ }
76
+
77
+ export interface InboxOutcome {
78
+ messages: CrosstalkMessage[]
79
+ reason: "immediate" | WaitResult["reason"]
80
+ marked: number
81
+ /** Unread remaining after this call. */
82
+ unread: number
83
+ /** Unread as it stood before this call acknowledged anything. */
84
+ unreadBefore: number
85
+ }
86
+
87
+ export interface ClaimInput {
88
+ action: "claim" | "release" | "renew" | "list"
89
+ resources?: readonly string[]
90
+ ttlMs?: number
91
+ note?: string
92
+ force?: boolean
93
+ atomic?: boolean
94
+ includeExpired?: boolean
95
+ }
96
+
97
+ export interface ClaimResult {
98
+ outcome: ClaimOutcome
99
+ held: Claim[]
100
+ ttlMs: number
101
+ }
102
+
103
+ export interface PeerIdleResult {
104
+ reason: "done" | "timeout" | "aborted" | "gone" | "outside"
105
+ elapsedMs: number
106
+ status?: Peer["status"]
107
+ }
108
+
109
+ export interface ClaimFreeResult {
110
+ reason: "done" | "timeout" | "aborted"
111
+ elapsedMs: number
112
+ holder?: string
113
+ }
114
+
115
+ export interface DeclareResult {
116
+ view: PeerView
117
+ /**
118
+ * The requested name is already used by another visible session; nothing was
119
+ * changed. `holder` is the session that owns the name.
120
+ */
121
+ nameConflict?: { name: string; holder: string }
122
+ }
123
+
124
+ /**
125
+ * Bumped to 2 when twin plugin instances could each persist to the same key
126
+ * and clobber each other's records. v1 is still migrated rather than dropped:
127
+ * its peer records were written under the same freeze contract, so a long-run
128
+ * session carries its declarations and frozen portrait across the upgrade —
129
+ * discarding them would change art the user already sees, breaking the never-
130
+ * change rule. Only v1's claims are discarded, because the lease table is what
131
+ * the twins corrupted; a snapshot from any other era is ignored whole.
132
+ */
133
+ export const SNAPSHOT_VERSION = 2
134
+
135
+ /** The one older format whose peer records `restore` still carries forward. */
136
+ export const LEGACY_SNAPSHOT_VERSION = 1
137
+
138
+ export class Mesh {
139
+ readonly registry: Registry
140
+ readonly mailbox: Mailbox
141
+ readonly claims: ClaimTable
142
+ readonly options: MeshOptions
143
+
144
+ readonly #clock: Clock
145
+ readonly #deliverer: Deliverer | undefined
146
+ readonly #defaults: { projectID?: string; directory?: string }
147
+ readonly #avatars: readonly AvatarEntry[]
148
+ readonly #idFactory: ((seq: number) => string) | undefined
149
+ /**
150
+ * Messages that are in a mailbox but were never injected into the live
151
+ * session, keyed by the session that should receive them. A `synthetic` call
152
+ * can fail across locations; without this the recipient would only ever find
153
+ * the message by polling, so every tool call retries them first.
154
+ */
155
+ readonly #pendingInjection = new Map<string, CrosstalkMessage[]>()
156
+
157
+ /** Mesh-mutation observers (the RPC bridge); a listener must never throw. */
158
+ readonly #listeners = new Set<() => void>()
159
+
160
+ constructor(deps: MeshDeps) {
161
+ this.options = deps.options
162
+ this.#clock = deps.clock ?? systemClock
163
+ this.#deliverer = deps.deliverer
164
+ this.#defaults = deps.defaults ?? {}
165
+ this.#avatars = deps.avatars ?? []
166
+ this.#idFactory = deps.idFactory
167
+ this.registry = new Registry({
168
+ now: () => this.#clock.now(),
169
+ staleAfterMs: deps.options.staleAfterMs,
170
+ evictAfterMs: deps.options.evictAfterMs,
171
+ })
172
+ this.mailbox = new Mailbox({
173
+ now: () => this.#clock.now(),
174
+ maxPerSession: deps.options.maxMessages,
175
+ ttlMs: deps.options.messageTtlMs,
176
+ ...(this.#idFactory ? { idFactory: this.#idFactory } : {}),
177
+ })
178
+ this.claims = new ClaimTable({ now: () => this.#clock.now(), defaultTtlMs: deps.options.claimTtlMs })
179
+ }
180
+
181
+ now(): number {
182
+ return this.#clock.now()
183
+ }
184
+
185
+ /**
186
+ * Observe declarations and presence changes. The RPC bridge subscribes so it
187
+ * can push directory updates without polling.
188
+ */
189
+ subscribe(listener: () => void): () => void {
190
+ this.#listeners.add(listener)
191
+ return () => {
192
+ this.#listeners.delete(listener)
193
+ }
194
+ }
195
+
196
+ #notify(): void {
197
+ for (const listener of this.#listeners) {
198
+ try {
199
+ listener()
200
+ } catch {
201
+ // A broken observer must never break a tool call.
202
+ }
203
+ }
204
+ }
205
+
206
+ /** Fold a server event in and evict lapsed peers. */
207
+ applyEvent(event: CrosstalkEvent): boolean {
208
+ const applied = this.registry.apply(event)
209
+ if (applied) {
210
+ this.registry.prune(this.#clock.now())
211
+ this.#notify()
212
+ }
213
+ return applied
214
+ }
215
+
216
+ /**
217
+ * Guarantee a record for the calling session. A session that started before
218
+ * the plugin loaded may never emit `session.created` at us.
219
+ */
220
+ ensureSelf(sessionID: string, agent?: string): Peer {
221
+ return this.registry.ensure(sessionID, {
222
+ projectID: this.#defaults.projectID,
223
+ directory: this.#defaults.directory,
224
+ ...(agent ? { agent } : {}),
225
+ })
226
+ }
227
+
228
+ /** The directory claims and relative paths resolve against for a session. */
229
+ directoryOf(sessionID: string): string | undefined {
230
+ return this.registry.get(sessionID)?.directory ?? this.#defaults.directory
231
+ }
232
+
233
+ #filter(selfID: string, overrides: Partial<ListFilter> = {}): ListFilter {
234
+ return {
235
+ selfID,
236
+ scope: this.options.scope,
237
+ selfProjectID: this.#defaults.projectID,
238
+ selfDirectory: this.#defaults.directory,
239
+ ...overrides,
240
+ }
241
+ }
242
+
243
+ /** Peer records visible to `selfID`, decorated with mesh state. */
244
+ peers(selfID: string, filter: Partial<ListFilter> = {}): PeerView[] {
245
+ this.ensureSelf(selfID)
246
+ const now = this.#clock.now()
247
+ return this.registry.list(this.#filter(selfID, filter)).map((peer) =>
248
+ this.registry.view(
249
+ peer,
250
+ {
251
+ isSelf: peer.sessionID === selfID,
252
+ unread: this.mailbox.unreadCount(peer.sessionID),
253
+ claims: this.claims.heldBy(peer.sessionID),
254
+ addressable: this.#inScope(selfID, peer),
255
+ },
256
+ now,
257
+ ),
258
+ )
259
+ }
260
+
261
+ view(selfID: string): PeerView {
262
+ const self = this.ensureSelf(selfID)
263
+ return this.registry.view(
264
+ self,
265
+ { isSelf: true, unread: this.mailbox.unreadCount(selfID), claims: this.claims.heldBy(selfID), addressable: true },
266
+ this.#clock.now(),
267
+ )
268
+ }
269
+
270
+ /** Record what a session says it is doing. Returns the updated self view. */
271
+ declare(sessionID: string, declared: Declared): DeclareResult {
272
+ const id = sessionIDOrThrow(sessionID)
273
+ const peer = this.ensureSelf(id)
274
+ // A name is a nickname, not identity: it must be unique among the peers
275
+ // the session can see, and a taken name is refused without touching the
276
+ // rest of the declaration.
277
+ if (declared.name !== undefined && declared.name !== peer.declared?.name) {
278
+ const holder = this.#nameHolder(declared.name, id)
279
+ if (holder) return { view: this.view(id), nameConflict: { name: declared.name, holder } }
280
+ }
281
+ const previous = peer.declared
282
+ const merged: Declared = {
283
+ name: declared.name ?? previous?.name,
284
+ role: declared.role ?? previous?.role,
285
+ goal: declared.goal ?? previous?.goal,
286
+ summary: declared.summary ?? previous?.summary,
287
+ summaryAt: declared.summary !== undefined ? this.#clock.now() : previous?.summaryAt,
288
+ note: declared.note ?? previous?.note,
289
+ avatar: declared.avatar ?? previous?.avatar,
290
+ workingOn: declared.workingOn ?? previous?.workingOn,
291
+ updatedAt: this.#clock.now(),
292
+ }
293
+ peer.declared = merged
294
+ peer.lastSeen = this.#clock.now()
295
+ // The portrait is a one-shot decision: it is assigned when a task first
296
+ // shows up and frozen afterwards, so no later declaration — different
297
+ // role, name, or avatar hint — can move the artwork.
298
+ if (peer.portrait === undefined && hasTask(merged)) {
299
+ const portrait = portraitFor(this.#avatars, {
300
+ sessionID: id,
301
+ name: merged.name,
302
+ role: merged.role,
303
+ avatar: merged.avatar,
304
+ })
305
+ if (portrait !== undefined) peer.portrait = portrait
306
+ }
307
+ this.#notify()
308
+ return { view: this.view(id) }
309
+ }
310
+
311
+ /** The visible session that already declared `name`, if any. */
312
+ #nameHolder(name: string, selfID: string): string | undefined {
313
+ const wanted = name.toLowerCase()
314
+ for (const peer of this.registry.list(this.#filter(selfID))) {
315
+ if (peer.sessionID === selfID) continue
316
+ if (peer.declared?.name?.toLowerCase() === wanted) return peer.sessionID
317
+ }
318
+ return undefined
319
+ }
320
+
321
+ /**
322
+ * Freeze portraits for sessions that already carry a task but have no
323
+ * portrait — the ones that declared before the portrait existed, or records
324
+ * restored from an older snapshot. One pass at startup; returns how many
325
+ * were assigned.
326
+ */
327
+ assignPortraits(): number {
328
+ let assigned = 0
329
+ for (const peer of this.registry.list({ scope: "server" })) {
330
+ if (peer.portrait !== undefined || !hasTask(peer.declared)) continue
331
+ const portrait = portraitFor(this.#avatars, {
332
+ sessionID: peer.sessionID,
333
+ name: peer.declared?.name,
334
+ role: peer.declared?.role,
335
+ avatar: peer.declared?.avatar,
336
+ })
337
+ if (portrait !== undefined) {
338
+ peer.portrait = portrait
339
+ assigned += 1
340
+ }
341
+ }
342
+ if (assigned > 0) this.#notify()
343
+ return assigned
344
+ }
345
+
346
+ /**
347
+ * Send a message to peers, then try to inject it into each recipient's live
348
+ * session. A failed injection is not an error: the message stays in the
349
+ * recipient's mailbox and surfaces on their next `crosstalk_inbox`.
350
+ */
351
+ async send(selfID: string, input: SendInput, signal?: AbortSignal): Promise<SendOutcome> {
352
+ const sender = this.ensureSelf(selfID)
353
+ const targets = this.#resolveTargets(selfID, input)
354
+ if (targets.recipients.length === 0) {
355
+ return { recipients: [], skipped: targets.skipped, messages: [] }
356
+ }
357
+
358
+ const meta: Record<string, unknown> = {}
359
+ if (input.replyTo) meta.replyTo = input.replyTo
360
+ meta.requested = sender.declared?.role
361
+
362
+ const messages = this.mailbox.send({
363
+ from: selfID,
364
+ ...(sender.declared?.name ? { fromName: sender.declared.name } : {}),
365
+ ...(sender.declared?.role ? { fromRole: sender.declared.role } : {}),
366
+ to: targets.recipients,
367
+ text: input.text,
368
+ ...(input.topic ? { topic: input.topic } : {}),
369
+ kind: input.kind ?? "message",
370
+ delivery: input.delivery ?? "steer",
371
+ meta,
372
+ })
373
+
374
+ const recipients: SendOutcome["recipients"] = []
375
+ for (const message of messages) {
376
+ if (signal?.aborted) {
377
+ recipients.push({ sessionID: message.to, delivered: false, reason: "cancelled" })
378
+ continue
379
+ }
380
+ const peer = this.registry.get(message.to)
381
+ if (!peer) {
382
+ recipients.push({ sessionID: message.to, delivered: false, reason: "session not known to the mesh" })
383
+ continue
384
+ }
385
+ // Defense in depth: the targets were resolved inside the wall, but a
386
+ // session whose location changed between resolution and delivery must
387
+ // not receive mail across it.
388
+ if (!this.#inScope(selfID, peer)) {
389
+ recipients.push({ sessionID: message.to, delivered: false, reason: `outside the ${this.options.scope} scope` })
390
+ continue
391
+ }
392
+ // `session.synthetic` on a foreign location may throw; the mailbox copy
393
+ // is the durable path, so a failure only downgrades delivery and is
394
+ // retried the next time the recipient touches a crosstalk tool.
395
+ const result = this.#deliverer
396
+ ? await this.#deliverer.deliver(message)
397
+ : { delivered: false, reason: "no deliverer configured" }
398
+ if (!result.delivered) this.#queuePending(message)
399
+ recipients.push({ sessionID: message.to, delivered: result.delivered, reason: result.reason })
400
+ }
401
+
402
+ return { recipients, skipped: targets.skipped, messages }
403
+ }
404
+
405
+ /**
406
+ * Retry injecting messages that were queued for `sessionID` but never made it
407
+ * into its live turn. Called before every tool execution, so a peer recovers
408
+ * from a failed injection as soon as it does anything at all. Returns how
409
+ * many were delivered by this attempt.
410
+ */
411
+ async flushPendingInjection(sessionID: string, signal?: AbortSignal): Promise<number> {
412
+ const queue = this.#pendingInjection.get(sessionID)
413
+ if (!queue || queue.length === 0) return 0
414
+
415
+ const cutoff = this.#clock.now() - this.options.messageTtlMs
416
+ const remaining: CrosstalkMessage[] = []
417
+ let delivered = 0
418
+
419
+ for (const message of queue) {
420
+ if (message.created < cutoff) continue
421
+ const peer = this.registry.get(message.to)
422
+ if (signal?.aborted || !peer) {
423
+ remaining.push(message)
424
+ continue
425
+ }
426
+ // The wall holds on retries too: a session that moved out of this
427
+ // location's project must not receive cross-project mail. The message
428
+ // stays queued in case the record catches up; it can never be injected.
429
+ if (!this.#deliverable(peer)) {
430
+ remaining.push(message)
431
+ continue
432
+ }
433
+ const result = this.#deliverer ? await this.#deliverer.deliver(message) : { delivered: false }
434
+ if (result.delivered) delivered += 1
435
+ else remaining.push(message)
436
+ }
437
+
438
+ if (remaining.length === 0) this.#pendingInjection.delete(sessionID)
439
+ else this.#pendingInjection.set(sessionID, remaining)
440
+ return delivered
441
+ }
442
+
443
+ /**
444
+ * A queued injection may only reach a session that is still provably inside
445
+ * this location's scope. Unknown locations pass (they were in scope when the
446
+ * message was queued, and an event may simply be missing); a provably
447
+ * different project or directory is refused.
448
+ */
449
+ #deliverable(target: Peer): boolean {
450
+ if (this.options.scope === "server") return true
451
+ if (this.options.scope === "project") {
452
+ const selfProject = this.#defaults.projectID
453
+ return selfProject === undefined || target.projectID === undefined || target.projectID === selfProject
454
+ }
455
+ const selfDirectory = this.#defaults.directory
456
+ return selfDirectory === undefined || target.directory === undefined || target.directory === selfDirectory
457
+ }
458
+
459
+ /** Messages still waiting to be injected into a session's live turn. */
460
+ pendingInjectionCount(sessionID: string): number {
461
+ return this.#pendingInjection.get(sessionID)?.length ?? 0
462
+ }
463
+
464
+ #queuePending(message: CrosstalkMessage): void {
465
+ const queue = this.#pendingInjection.get(message.to) ?? []
466
+ // Bounded: a recipient that never opens a tool call must not grow the
467
+ // server's memory without limit. Oldest queued injection goes first.
468
+ while (queue.length >= 50) queue.shift()
469
+ queue.push(message)
470
+ this.#pendingInjection.set(message.to, queue)
471
+ }
472
+
473
+ #resolveTargets(selfID: string, input: SendInput): { recipients: string[]; skipped: SendOutcome["skipped"] } {
474
+ const skipped: SendOutcome["skipped"] = []
475
+ if (input.all) {
476
+ // `all` reaches every peer inside the communication wall; a session
477
+ // whose project cannot be proven is never on the distribution list.
478
+ return {
479
+ recipients: this.registry.list(this.#filter(selfID, { strict: true })).map((p) => p.sessionID),
480
+ skipped,
481
+ }
482
+ }
483
+ if (input.to) {
484
+ const target = input.to.trim()
485
+ if (target === "") {
486
+ skipped.push({ target: "(empty)", reason: "no session id given" })
487
+ return { recipients: [], skipped }
488
+ }
489
+ if (target === selfID) {
490
+ skipped.push({ target, reason: "that is you" })
491
+ return { recipients: [], skipped }
492
+ }
493
+ if (this.registry.get(target)) {
494
+ if (!this.#visible(selfID, target)) {
495
+ skipped.push({ target, reason: `outside the ${this.options.scope} scope` })
496
+ return { recipients: [], skipped }
497
+ }
498
+ return { recipients: [target], skipped }
499
+ }
500
+ // Not a session id: try a declared human name, case-insensitively,
501
+ // among addressable peers only — names never cross the wall.
502
+ const wanted = target.toLowerCase()
503
+ const byName = this.registry
504
+ .list(this.#filter(selfID, { strict: true }))
505
+ .filter((peer) => peer.declared?.name?.toLowerCase() === wanted)
506
+ .map((peer) => peer.sessionID)
507
+ if (byName.length === 1) return { recipients: [byName[0] as string], skipped }
508
+ if (byName.length > 1) {
509
+ skipped.push({ target, reason: `"${target}" is claimed by ${byName.length} sessions; use a session id` })
510
+ return { recipients: [], skipped }
511
+ }
512
+ skipped.push({ target, reason: "session unknown; check crosstalk_peers for session ids and names" })
513
+ return { recipients: [], skipped }
514
+ }
515
+ if (input.role) {
516
+ const wanted = input.role.trim().toLowerCase()
517
+ const matches = this.registry
518
+ .list(this.#filter(selfID, { strict: true }))
519
+ .filter((peer) => peer.declared?.role?.toLowerCase() === wanted)
520
+ .map((peer) => peer.sessionID)
521
+ if (matches.length === 0) skipped.push({ target: `role:${input.role}`, reason: "no peer declared that role" })
522
+ return { recipients: matches, skipped }
523
+ }
524
+ skipped.push({ target: "(none)", reason: "give to, role, or all" })
525
+ return { recipients: [], skipped }
526
+ }
527
+
528
+ /**
529
+ * True when `target` is a peer this session may address: provably within the
530
+ * configured scope. An unknown location is a refusal, not a maybe.
531
+ */
532
+ #visible(selfID: string, target: string): boolean {
533
+ const peer = this.registry.get(target)
534
+ return peer !== undefined && this.#inScope(selfID, peer)
535
+ }
536
+
537
+ /**
538
+ * The communication wall. `scope: "server"` has no wall; otherwise the peer
539
+ * must carry a location that provably equals the caller's — a session whose
540
+ * project (or directory) is unknown cannot be messaged or waited on.
541
+ */
542
+ #inScope(selfID: string, peer: Peer): boolean {
543
+ const scope = this.options.scope
544
+ if (peer.sessionID === selfID || scope === "server") return true
545
+ const projectID = this.registry.get(selfID)?.projectID ?? this.#defaults.projectID
546
+ if (scope === "project") return projectID !== undefined && peer.projectID === projectID
547
+ const directory = this.registry.get(selfID)?.directory ?? this.#defaults.directory
548
+ return directory !== undefined && peer.directory === directory
549
+ }
550
+
551
+ /** Read, optionally block for, and optionally ack the caller's mail. */
552
+ async inbox(selfID: string, input: InboxInput, signal?: AbortSignal): Promise<InboxOutcome> {
553
+ this.ensureSelf(selfID)
554
+ const waitMs = Math.min(Math.max(0, input.waitMs ?? 0), this.options.maxWaitMs)
555
+
556
+ let messages: CrosstalkMessage[]
557
+ let reason: InboxOutcome["reason"] = "immediate"
558
+ if (waitMs > 0) {
559
+ const result = await this.mailbox.wait(selfID, waitMs, signal)
560
+ messages = result.messages
561
+ reason = result.reason
562
+ if (reason === "messages") {
563
+ // Re-read through the caller's filters; `wait` only filtered unread.
564
+ messages = this.mailbox.list(selfID, input)
565
+ }
566
+ } else {
567
+ messages = this.mailbox.list(selfID, input)
568
+ }
569
+
570
+ // Counted before acknowledging, so the header can say how much was unread
571
+ // even though this call is about to mark it read.
572
+ const unreadBefore = messages.filter((message) => message.readAt === undefined).length
573
+ const marked = input.markRead === false ? 0 : this.mailbox.markRead(selfID, messages.map((m) => m.id))
574
+ return {
575
+ messages,
576
+ reason,
577
+ marked,
578
+ unread: this.mailbox.unreadCount(selfID),
579
+ unreadBefore,
580
+ }
581
+ }
582
+
583
+ /** Lease bookkeeping for the caller's session. */
584
+ claims_for(selfID: string): ClaimResult {
585
+ return { outcome: emptyOutcome(), held: this.claims.list({ holder: selfID }), ttlMs: this.#claimsExpiry(selfID) }
586
+ }
587
+
588
+ claim(selfID: string, input: ClaimInput): ClaimResult {
589
+ this.ensureSelf(selfID)
590
+ const baseDir = this.directoryOf(selfID)
591
+ const ttlMs = Math.min(Math.max(1, input.ttlMs ?? this.options.claimTtlMs), 86_400_000)
592
+ const resources = (input.resources ?? []).filter((r) => r.trim() !== "")
593
+
594
+ let outcome: ClaimOutcome
595
+ switch (input.action) {
596
+ case "claim":
597
+ outcome = this.claims.claim({
598
+ holder: selfID,
599
+ resources,
600
+ ttlMs,
601
+ baseDir,
602
+ ...(input.note ? { note: input.note } : {}),
603
+ ...(input.force !== undefined ? { force: input.force } : {}),
604
+ ...(input.atomic !== undefined ? { atomic: input.atomic } : {}),
605
+ })
606
+ break
607
+ case "renew":
608
+ outcome = this.claims.renew({
609
+ holder: selfID,
610
+ resources,
611
+ ttlMs,
612
+ baseDir,
613
+ ...(input.note ? { note: input.note } : {}),
614
+ ...(input.atomic !== undefined ? { atomic: input.atomic } : {}),
615
+ })
616
+ break
617
+ case "release":
618
+ outcome = this.claims.release({
619
+ holder: selfID,
620
+ resources,
621
+ baseDir,
622
+ ...(input.force !== undefined ? { force: input.force } : {}),
623
+ })
624
+ break
625
+ case "list": {
626
+ const held = this.claims.list({
627
+ holder: selfID,
628
+ baseDir,
629
+ ...(input.includeExpired ? { includeExpired: true } : {}),
630
+ })
631
+ return { outcome: emptyOutcome(), held, ttlMs }
632
+ }
633
+ }
634
+
635
+ return { outcome, held: this.claims.list({ holder: selfID }), ttlMs }
636
+ }
637
+
638
+ #claimsExpiry(selfID: string): number {
639
+ const held = this.claims.list({ holder: selfID })
640
+ const soonest = held.reduce((min, claim) => Math.min(min, claim.expires), Number.POSITIVE_INFINITY)
641
+ return Number.isFinite(soonest) ? Math.max(0, soonest - this.#clock.now()) : this.options.claimTtlMs
642
+ }
643
+
644
+ /** Block until a peer stops being busy, disappears, or the wait expires. */
645
+ async waitForPeerIdle(
646
+ selfID: string,
647
+ target: string,
648
+ timeoutMs: number,
649
+ signal?: AbortSignal,
650
+ ): Promise<PeerIdleResult> {
651
+ const start = this.#clock.now()
652
+ const limit = Math.min(Math.max(0, timeoutMs), this.options.maxWaitMs)
653
+ if (target === selfID) return { reason: "gone", elapsedMs: 0 }
654
+
655
+ const peer = this.registry.get(target)
656
+ if (!peer) return { reason: "gone", elapsedMs: 0 }
657
+ // Waiting on a peer is communication: the wall applies. A session outside
658
+ // the scope resolves at once instead of blocking until the timeout.
659
+ if (!this.#inScope(selfID, peer)) return { reason: "outside", elapsedMs: 0, status: peer.status }
660
+
661
+ for (;;) {
662
+ if (peer.status === "idle" || peer.status === "unknown") {
663
+ return { reason: "done", elapsedMs: this.#clock.now() - start, status: peer.status }
664
+ }
665
+ if (this.#clock.now() - start >= limit) {
666
+ return { reason: signal?.aborted ? "aborted" : "timeout", elapsedMs: this.#clock.now() - start, status: peer.status }
667
+ }
668
+ await delay(this.options.pollMs, signal)
669
+ if (signal?.aborted) return { reason: "aborted", elapsedMs: this.#clock.now() - start }
670
+ const current = this.registry.get(target)
671
+ if (!current) return { reason: "gone", elapsedMs: this.#clock.now() - start }
672
+ if (!this.#inScope(selfID, current)) return { reason: "outside", elapsedMs: this.#clock.now() - start, status: current.status }
673
+ if (current.status === "idle" || current.status === "unknown") {
674
+ return { reason: "done", elapsedMs: this.#clock.now() - start, status: current.status }
675
+ }
676
+ }
677
+ }
678
+
679
+ /** Block until a resource has no live lease. */
680
+ async waitForClaimFree(
681
+ _selfID: string,
682
+ resource: string,
683
+ timeoutMs: number,
684
+ baseDir?: string,
685
+ signal?: AbortSignal,
686
+ ): Promise<ClaimFreeResult> {
687
+ const start = this.#clock.now()
688
+ const limit = Math.min(Math.max(0, timeoutMs), this.options.maxWaitMs)
689
+ const key = this.claims.normalize(resource, baseDir)
690
+ const holderOf = (): string | undefined => {
691
+ const claim = this.claims.list({ resource: key, includeExpired: true })[0]
692
+ if (!claim || claim.expires <= this.#clock.now()) return undefined
693
+ return claim.holder
694
+ }
695
+
696
+ for (;;) {
697
+ const holder = holderOf()
698
+ if (holder === undefined) return { reason: "done", elapsedMs: this.#clock.now() - start }
699
+ if (this.#clock.now() - start >= limit) {
700
+ return {
701
+ reason: signal?.aborted ? "aborted" : "timeout",
702
+ elapsedMs: this.#clock.now() - start,
703
+ holder,
704
+ }
705
+ }
706
+ await delay(this.options.pollMs, signal)
707
+ if (signal?.aborted) return { reason: "aborted", elapsedMs: this.#clock.now() - start, holder }
708
+ }
709
+ }
710
+
711
+ /** Serializable state. Messages are intentionally left out. */
712
+ snapshot(): MeshSnapshot {
713
+ return {
714
+ version: SNAPSHOT_VERSION,
715
+ savedAt: this.#clock.now(),
716
+ peers: [...this.registry.list({ scope: "server" })].map((peer) => ({ ...peer })),
717
+ claims: this.claims.entries().map((claim) => ({ ...claim })),
718
+ }
719
+ }
720
+
721
+ /**
722
+ * Merge a snapshot back in. A session the live stream has already created
723
+ * keeps its freshness and only receives fields the stream never carried
724
+ * (declarations, project, directory); a session the registry has not seen is
725
+ * restored wholesale. Restored claims are dropped when the same key is
726
+ * already held. A v1 snapshot is migrated rather than rejected: peers (with
727
+ * their declarations and frozen portraits) are carried forward, but none of
728
+ * its claims are adopted — that lease table is exactly what the twin
729
+ * instances corrupted, and a resurrected ghost lease would harm live peers.
730
+ */
731
+ restore(snapshot: MeshSnapshot): void {
732
+ const legacy = snapshot.version === LEGACY_SNAPSHOT_VERSION
733
+ if (!legacy && snapshot.version !== SNAPSHOT_VERSION) return
734
+ for (const peer of snapshot.peers) {
735
+ if (!peer || typeof peer.sessionID !== "string") continue
736
+ const live = this.registry.get(peer.sessionID)
737
+ if (live) {
738
+ if (live.title === undefined && peer.title !== undefined) live.title = peer.title
739
+ if (live.agent === undefined && peer.agent !== undefined) live.agent = peer.agent
740
+ if (live.model === undefined && peer.model !== undefined) live.model = peer.model
741
+ if (live.parentID === undefined && peer.parentID !== undefined) live.parentID = peer.parentID
742
+ if (live.projectID === undefined && peer.projectID !== undefined) live.projectID = peer.projectID
743
+ if (live.directory === undefined && peer.directory !== undefined) live.directory = peer.directory
744
+ if (live.created === undefined && peer.created !== undefined) live.created = peer.created
745
+ if (live.declared === undefined && peer.declared !== undefined) live.declared = peer.declared
746
+ if (live.portrait === undefined && peer.portrait !== undefined) live.portrait = peer.portrait
747
+ continue
748
+ }
749
+ const restored = this.registry.ensure(peer.sessionID)
750
+ restored.status = peer.status ?? "unknown"
751
+ restored.lastSeen = typeof peer.lastSeen === "number" ? peer.lastSeen : this.#clock.now()
752
+ if (peer.title !== undefined) restored.title = peer.title
753
+ if (peer.agent !== undefined) restored.agent = peer.agent
754
+ if (peer.model !== undefined) restored.model = peer.model
755
+ if (peer.parentID !== undefined) restored.parentID = peer.parentID
756
+ if (peer.projectID !== undefined) restored.projectID = peer.projectID
757
+ if (peer.directory !== undefined) restored.directory = peer.directory
758
+ if (peer.created !== undefined) restored.created = peer.created
759
+ if (peer.declared !== undefined) restored.declared = peer.declared
760
+ if (peer.portrait !== undefined) restored.portrait = peer.portrait
761
+ }
762
+ if (legacy) return
763
+ const held = new Set(this.claims.entries().map((claim) => claim.key))
764
+ for (const claim of snapshot.claims) {
765
+ if (!claim || typeof claim.key !== "string" || held.has(claim.key)) continue
766
+ if (claim.expires <= this.#clock.now()) continue
767
+ this.claims.adopt(claim)
768
+ }
769
+ }
770
+ }
771
+
772
+ function emptyOutcome(): ClaimOutcome {
773
+ return { claimed: [], renewed: [], released: [], stolen: [], conflicts: [], rolledBack: false }
774
+ }
775
+
776
+ function sessionIDOrThrow(sessionID: string): string {
777
+ if (typeof sessionID !== "string" || sessionID === "") {
778
+ throw new Error("crosstalk: session id is required")
779
+ }
780
+ return sessionID
781
+ }