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,306 @@
1
+ /**
2
+ * @fileoverview Presence registry: folds the OpenCode event stream into a live
3
+ * view of which sessions exist, where they run, and whether they are busy.
4
+ *
5
+ * The plugin context deliberately exposes no `session.list()` / `session.active`
6
+ * (its session domain is a narrow `Pick` of the client API), so discovery is
7
+ * built from `ctx.event.subscribe()`. Events arrive as `{ type, location, data }`.
8
+ * Most carry a top-level `location`, but no session event besides
9
+ * `session.created` and `session.moved` carries a project id — a peer that
10
+ * missed those two never learns its project from the stream alone.
11
+ *
12
+ * Liveness has two thresholds: past `staleAfterMs` a peer is still listed but
13
+ * flagged, because an agent can spend minutes inside one model call without
14
+ * emitting anything; past `evictAfterMs` it is dropped, because a server that
15
+ * died will never send another event.
16
+ */
17
+
18
+ import type { Peer, PeerStatus, PeerView, Scope } from "../types.ts"
19
+
20
+ /** Structural subset of an OpenCode server event that the registry reads. */
21
+ export interface CrosstalkEvent {
22
+ readonly type: string
23
+ readonly created?: number
24
+ readonly location?: { readonly directory?: string; readonly workspaceID?: string }
25
+ readonly data?: Readonly<Record<string, unknown>>
26
+ }
27
+
28
+ export interface RegistryOptions {
29
+ now(): number
30
+ staleAfterMs: number
31
+ evictAfterMs: number
32
+ }
33
+
34
+ export interface ListFilter {
35
+ selfID?: string
36
+ scope?: Scope
37
+ /** Falls back to the plugin location when the peer record has no project. */
38
+ selfProjectID?: string
39
+ selfDirectory?: string
40
+ status?: "running" | "idle" | "all"
41
+ includeSelf?: boolean
42
+ limit?: number
43
+ /**
44
+ * Communication wall: exclude peers whose location cannot be proven within
45
+ * the scope. Plain listings keep over-reporting unknown peers; sends, waits,
46
+ * and injection retries pass `strict: true` so a session whose project is
47
+ * unknown can never be addressed.
48
+ */
49
+ strict?: boolean
50
+ }
51
+
52
+ const STATUS_RANK: Readonly<Record<PeerStatus, number>> = { running: 0, idle: 1, unknown: 2 }
53
+
54
+ function str(value: unknown): string | undefined {
55
+ return typeof value === "string" && value.length > 0 ? value : undefined
56
+ }
57
+
58
+ /** Best-effort extraction of a session directory from an event. */
59
+ function directoryOf(event: CrosstalkEvent): string | undefined {
60
+ const data = event.data
61
+ const nested = data?.location
62
+ if (nested && typeof nested === "object") {
63
+ const value = (nested as { directory?: unknown }).directory
64
+ if (typeof value === "string" && value.length > 0) return value
65
+ }
66
+ return str(event.location?.directory)
67
+ }
68
+
69
+ function modelLabel(value: unknown): string | undefined {
70
+ if (typeof value === "string") return value
71
+ if (!value || typeof value !== "object") return undefined
72
+ const ref = value as { providerID?: unknown; id?: unknown; variant?: unknown }
73
+ const id = str(ref.id)
74
+ if (!id) return undefined
75
+ const provider = str(ref.providerID)
76
+ const variant = str(ref.variant)
77
+ const base = provider ? `${provider}/${id}` : id
78
+ return variant ? `${base}#${variant}` : base
79
+ }
80
+
81
+ export class Registry {
82
+ readonly #peers = new Map<string, Peer>()
83
+ readonly #now: () => number
84
+ readonly #staleAfterMs: number
85
+ readonly #evictAfterMs: number
86
+
87
+ constructor(options: RegistryOptions) {
88
+ this.#now = options.now
89
+ this.#staleAfterMs = options.staleAfterMs
90
+ this.#evictAfterMs = options.evictAfterMs
91
+ }
92
+
93
+ get size(): number {
94
+ return this.#peers.size
95
+ }
96
+
97
+ get(sessionID: string): Peer | undefined {
98
+ return this.#peers.get(sessionID)
99
+ }
100
+
101
+ /**
102
+ * Create a placeholder record for a session the registry has not seen an
103
+ * event for — a child session started before the plugin loaded, or the
104
+ * calling session itself.
105
+ */
106
+ ensure(sessionID: string, defaults: { projectID?: string; directory?: string; agent?: string } = {}): Peer {
107
+ const existing = this.#peers.get(sessionID)
108
+ if (existing) return existing
109
+ const now = this.#now()
110
+ const peer: Peer = {
111
+ sessionID,
112
+ status: "unknown",
113
+ created: now,
114
+ lastSeen: now,
115
+ projectID: defaults.projectID,
116
+ directory: defaults.directory,
117
+ agent: defaults.agent,
118
+ }
119
+ this.#peers.set(sessionID, peer)
120
+ return peer
121
+ }
122
+
123
+ /** Merge a patch into a peer, creating it when unknown. */
124
+ update(sessionID: string, patch: Partial<Peer>): Peer {
125
+ const peer = this.ensure(sessionID)
126
+ for (const [key, value] of Object.entries(patch)) {
127
+ if (value === undefined) continue
128
+ if (key === "sessionID" || key === "lastSeen") continue
129
+ // `declared` is a nested record; callers pass the whole object.
130
+ ;(peer as unknown as Record<string, unknown>)[key] = value
131
+ }
132
+ return peer
133
+ }
134
+
135
+ delete(sessionID: string): boolean {
136
+ return this.#peers.delete(sessionID)
137
+ }
138
+
139
+ /**
140
+ * Fold one server event into the registry. Returns true when the event was
141
+ * recognized and changed or refreshed a record. Unknown event types are
142
+ * ignored so the reducer can be extended without breaking older hosts.
143
+ */
144
+ apply(event: CrosstalkEvent): boolean {
145
+ const data = event.data
146
+ if (!data) return false
147
+ const sessionID = str(data.sessionID)
148
+ if (!sessionID) return false
149
+
150
+ if (event.type === "session.deleted") return this.delete(sessionID)
151
+
152
+ const at = this.#timestamp(event)
153
+ const directory = directoryOf(event)
154
+ // `ensure` is what creates a record, so the event's own directory has to be
155
+ // passed in here — a second `ensure` call would find the record already
156
+ // present and ignore the defaults.
157
+ const target = this.ensure(sessionID, { directory })
158
+ target.lastSeen = at
159
+ // A record can be born on any event type: a session that started before the
160
+ // plugin loaded first shows up in `session.status`, not `session.created`.
161
+ // Real server events carry a top-level directory on most types and none of
162
+ // them carries a project, so every event that knows where the session runs
163
+ // refreshes it — and an event that does not must not clear what is known.
164
+ // `projectID` still only arrives with `session.created` / `session.moved`.
165
+ if (directory !== undefined) target.directory = directory
166
+
167
+ switch (event.type) {
168
+ case "session.created": {
169
+ target.projectID = str(data.projectID) ?? target.projectID
170
+ target.directory = directoryOf(event) ?? target.directory
171
+ target.title = str(data.title) ?? target.title
172
+ target.agent = str(data.agent) ?? target.agent
173
+ target.parentID = str(data.parentID) ?? target.parentID
174
+ target.model = modelLabel(data.model) ?? target.model
175
+ target.created = typeof data.created === "number" ? data.created : (target.created ?? at)
176
+ break
177
+ }
178
+ case "session.forked": {
179
+ target.parentID = str(data.parentID) ?? target.parentID
180
+ break
181
+ }
182
+ case "session.renamed": {
183
+ target.title = str(data.title) ?? target.title
184
+ break
185
+ }
186
+ case "session.moved": {
187
+ target.projectID = str(data.projectID) ?? target.projectID
188
+ const moved = data.location
189
+ if (moved && typeof moved === "object") {
190
+ const dir = (moved as { directory?: unknown }).directory
191
+ if (typeof dir === "string" && dir.length > 0) target.directory = dir
192
+ }
193
+ break
194
+ }
195
+ case "session.execution.started": {
196
+ target.status = "running"
197
+ break
198
+ }
199
+ case "session.status": {
200
+ const status = data.status
201
+ if (status && typeof status === "object") {
202
+ const kind = (status as { type?: unknown }).type
203
+ if (kind === "busy" || kind === "retry") target.status = "running"
204
+ else if (kind === "idle") target.status = "idle"
205
+ }
206
+ break
207
+ }
208
+ case "session.idle":
209
+ case "session.execution.succeeded":
210
+ case "session.execution.failed":
211
+ case "session.execution.interrupted": {
212
+ target.status = "idle"
213
+ break
214
+ }
215
+ case "session.tool.called":
216
+ case "session.step.started":
217
+ case "session.shell.started": {
218
+ target.status = "running"
219
+ break
220
+ }
221
+ default:
222
+ // Touched liveness above; no field to update.
223
+ return true
224
+ }
225
+ return true
226
+ }
227
+
228
+ /** True when no event arrived within the stale threshold. */
229
+ isStale(peer: Peer, now = this.#now()): boolean {
230
+ return now - peer.lastSeen > this.#staleAfterMs
231
+ }
232
+
233
+ /** Drop peers with no activity within the eviction threshold. */
234
+ prune(now = this.#now()): string[] {
235
+ const removed: string[] = []
236
+ for (const peer of this.#peers.values()) {
237
+ if (now - peer.lastSeen > this.#evictAfterMs) {
238
+ this.#peers.delete(peer.sessionID)
239
+ removed.push(peer.sessionID)
240
+ }
241
+ }
242
+ return removed
243
+ }
244
+
245
+ /** Peers visible under `filter`, running first, then most recently active. */
246
+ list(filter: ListFilter = {}): Peer[] {
247
+ const scope = filter.scope ?? "server"
248
+ const self = filter.selfID ? this.#peers.get(filter.selfID) : undefined
249
+ const selfProject = self?.projectID ?? filter.selfProjectID
250
+ const selfDirectory = self?.directory ?? filter.selfDirectory
251
+ const want = filter.status ?? "all"
252
+
253
+ const visible: Peer[] = []
254
+ for (const peer of this.#peers.values()) {
255
+ const isSelf = filter.selfID !== undefined && peer.sessionID === filter.selfID
256
+ if (isSelf && !filter.includeSelf) continue
257
+ if (scope === "project" && selfProject !== undefined) {
258
+ // Unknown-project peers stay visible in listings: better to over-report
259
+ // than to hide a session that is genuinely working the same repository.
260
+ // Under a strict filter (communication) they are excluded instead.
261
+ if (peer.projectID !== undefined && peer.projectID !== selfProject) continue
262
+ if (filter.strict && peer.projectID === undefined) continue
263
+ }
264
+ if (scope === "location" && selfDirectory !== undefined) {
265
+ if (peer.directory !== undefined && peer.directory !== selfDirectory) continue
266
+ if (filter.strict && peer.directory === undefined) continue
267
+ }
268
+ // Asking for yourself is asking for yourself: a status filter is about
269
+ // peers, so it should not silently drop the caller.
270
+ if (!isSelf && want !== "all" && peer.status !== want) continue
271
+ visible.push(peer)
272
+ }
273
+
274
+ visible.sort((a, b) => {
275
+ const rank = STATUS_RANK[a.status] - STATUS_RANK[b.status]
276
+ if (rank !== 0) return rank
277
+ if (a.lastSeen !== b.lastSeen) return b.lastSeen - a.lastSeen
278
+ return a.sessionID < b.sessionID ? -1 : a.sessionID > b.sessionID ? 1 : 0
279
+ })
280
+
281
+ if (filter.limit !== undefined && filter.limit >= 0 && visible.length > filter.limit) {
282
+ return visible.slice(0, filter.limit)
283
+ }
284
+ return visible
285
+ }
286
+
287
+ /** Peer plus mesh-derived fields, as tools hand them to agents. */
288
+ view(
289
+ peer: Peer,
290
+ extras: { isSelf: boolean; unread: number; claims: string[]; addressable: boolean },
291
+ now = this.#now(),
292
+ ): PeerView {
293
+ return {
294
+ ...peer,
295
+ isSelf: extras.isSelf,
296
+ stale: this.isStale(peer, now),
297
+ unread: extras.unread,
298
+ claims: extras.claims,
299
+ addressable: extras.addressable,
300
+ }
301
+ }
302
+
303
+ #timestamp(event: CrosstalkEvent): number {
304
+ return typeof event.created === "number" ? event.created : this.#now()
305
+ }
306
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * @fileoverview Delivery of crosstalk messages into live sessions.
3
+ *
4
+ * A mailbox entry alone would not reach an agent that is mid-task, so each
5
+ * message is also injected into the recipient's session with
6
+ * `ctx.session.synthetic` — a durable, non-user message that does not masquerade
7
+ * as something the human typed. `steer` (the default) folds it into the
8
+ * recipient's current turn; `queue` waits for the turn to end.
9
+ *
10
+ * Delivery is best effort. A plugin instance belongs to one location, so
11
+ * synthesizing into a session owned by another location can fail; the mailbox
12
+ * copy is authoritative and the recipient sees the message on their next
13
+ * `crosstalk_inbox`.
14
+ */
15
+
16
+ import type { CrosstalkMessage } from "./types.ts"
17
+ import type { Deliverer } from "./core/mesh.ts"
18
+
19
+ /** Structural subset of `ctx.session`. */
20
+ export interface SessionLike {
21
+ synthetic(input: {
22
+ sessionID: string
23
+ text: string
24
+ description?: string
25
+ delivery?: "steer" | "queue"
26
+ metadata?: Record<string, unknown>
27
+ }): Promise<unknown>
28
+ }
29
+
30
+ function errorMessage(error: unknown): string {
31
+ if (error instanceof Error) return error.message
32
+ if (typeof error === "string") return error
33
+ return "delivery failed"
34
+ }
35
+
36
+ /** The text injected into the recipient's session. */
37
+ export function renderDelivery(message: CrosstalkMessage): string {
38
+ const lines: string[] = ["[crosstalk] another session on this project is talking to you."]
39
+ const actor = `${message.from}${message.fromName ? ` (${message.fromName})` : ""}`
40
+ if (message.fromRole) lines.push(`from ${actor} (${message.fromRole}) — ${message.kind}.`)
41
+ else lines.push(`from ${actor} — ${message.kind}.`)
42
+ if (message.topic) lines.push(`topic: ${message.topic}`)
43
+ lines.push("")
44
+ lines.push(message.text)
45
+ lines.push("")
46
+ // Only a `request` demands an answer. Steering every message manufactured
47
+ // ack loops between polite sessions; `meta.requested` carries the sender's
48
+ // role for context, it is not a reply demand.
49
+ if (message.kind === "request") {
50
+ lines.push(
51
+ `This is a request: answer with crosstalk_send addressed to ${message.fromName ?? message.from}, then continue your task.`,
52
+ )
53
+ } else {
54
+ lines.push("Read it with crosstalk_inbox when convenient; reply only if a reply is warranted.")
55
+ }
56
+ return lines.join("\n")
57
+ }
58
+
59
+ export function createSessionDeliverer(session: SessionLike): Deliverer {
60
+ return {
61
+ async deliver(message: CrosstalkMessage) {
62
+ try {
63
+ await session.synthetic({
64
+ sessionID: message.to,
65
+ text: renderDelivery(message),
66
+ description: `crosstalk ${message.kind} from ${message.from}`,
67
+ delivery: message.delivery,
68
+ metadata: {
69
+ crosstalk: {
70
+ id: message.id,
71
+ seq: message.seq,
72
+ from: message.from,
73
+ ...(message.fromName ? { fromName: message.fromName } : {}),
74
+ fromRole: message.fromRole,
75
+ kind: message.kind,
76
+ ...(message.topic ? { topic: message.topic } : {}),
77
+ },
78
+ },
79
+ })
80
+ return { delivered: true }
81
+ } catch (error) {
82
+ return { delivered: false, reason: errorMessage(error) }
83
+ }
84
+ },
85
+ }
86
+ }
package/src/events.ts ADDED
@@ -0,0 +1,66 @@
1
+ /**
2
+ * @fileoverview Event stream plumbing.
3
+ *
4
+ * `ctx.event.subscribe()` is the only way a plugin learns what sessions exist
5
+ * (the plugin context has no `session.list`). The consumer must survive
6
+ * individual malformed events and must stop when the plugin unloads, so the
7
+ * pump takes a plain `AsyncIterable` and an `AbortSignal` rather than the
8
+ * context directly — which also makes it trivially testable.
9
+ */
10
+
11
+ import type { CrosstalkEvent } from "./core/registry.ts"
12
+
13
+ /** Structural subset of `ctx.event.subscribe()`. */
14
+ export interface EventStream {
15
+ subscribe(options?: { signal?: AbortSignal }): AsyncIterable<unknown>
16
+ }
17
+
18
+ export interface PumpOptions {
19
+ onEvent(event: CrosstalkEvent): void
20
+ onError?(error: unknown, event: unknown): void
21
+ }
22
+
23
+ /** Narrow an unknown stream item to something the registry can read. */
24
+ export function asCrosstalkEvent(value: unknown): CrosstalkEvent | undefined {
25
+ if (!value || typeof value !== "object") return undefined
26
+ const candidate = value as { type?: unknown; data?: unknown; created?: unknown; location?: unknown }
27
+ if (typeof candidate.type !== "string") return undefined
28
+ const data =
29
+ candidate.data && typeof candidate.data === "object" && !Array.isArray(candidate.data)
30
+ ? (candidate.data as Record<string, unknown>)
31
+ : undefined
32
+ return {
33
+ type: candidate.type,
34
+ ...(typeof candidate.created === "number" ? { created: candidate.created } : {}),
35
+ ...(data ? { data } : {}),
36
+ ...(candidate.location && typeof candidate.location === "object"
37
+ ? { location: candidate.location as { directory?: string } }
38
+ : {}),
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Consume the event stream until it ends or `signal` aborts. Never rejects: a
44
+ * thrown handler is reported through `onError` and the pump keeps going, so one
45
+ * bad event cannot silently kill presence tracking.
46
+ */
47
+ export async function pumpEvents(
48
+ stream: EventStream,
49
+ signal: AbortSignal,
50
+ options: PumpOptions,
51
+ ): Promise<void> {
52
+ try {
53
+ for await (const raw of stream.subscribe({ signal })) {
54
+ if (signal.aborted) return
55
+ const event = asCrosstalkEvent(raw)
56
+ if (!event) continue
57
+ try {
58
+ options.onEvent(event)
59
+ } catch (error) {
60
+ options.onError?.(error, raw)
61
+ }
62
+ }
63
+ } catch (error) {
64
+ if (!signal.aborted) options.onError?.(error, undefined)
65
+ }
66
+ }