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.
- package/LICENSE +21 -0
- package/README.md +460 -2
- package/assets/CursorGuard.png +0 -0
- package/assets/Wren.png +0 -0
- package/assets/avatars/ATTRIBUTION.md +18 -0
- package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
- package/assets/avatars/female/02-manager-b16a5b.png +0 -0
- package/assets/avatars/female/02-manager-e7a391.png +0 -0
- package/assets/avatars/female/03-coder-92594b.png +0 -0
- package/assets/avatars/female/03-coder-e5a07e.png +0 -0
- package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
- package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
- package/assets/avatars/female/08-tester-d78774.png +0 -0
- package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
- package/assets/avatars/female/10-builder-92594b.png +0 -0
- package/assets/avatars/female/10-builder-e5a07e.png +0 -0
- package/assets/avatars/female/13-researcher-92594b.png +0 -0
- package/assets/avatars/female/13-researcher-e7a391.png +0 -0
- package/assets/avatars/female/14-writer-623d36.png +0 -0
- package/assets/avatars/female/14-writer-e5a07e.png +0 -0
- package/assets/avatars/female/16-fixer-e7a391.png +0 -0
- package/assets/avatars/female/17-deployer-92594b.png +0 -0
- package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
- package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
- package/assets/avatars/female/23-mentor-e7a391.png +0 -0
- package/assets/avatars/female/25-pioneer-d78774.png +0 -0
- package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
- package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
- package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
- package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
- package/assets/avatars/female/34-optimizer-92594b.png +0 -0
- package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
- package/assets/avatars/female/36-integrator-d78774.png +0 -0
- package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
- package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
- package/assets/avatars/female/37-compiler-e7a391.png +0 -0
- package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
- package/assets/avatars/female/42-shipper-623d36.png +0 -0
- package/assets/avatars/female/46-steward-623d36.png +0 -0
- package/assets/avatars/female/46-steward-d78774.png +0 -0
- package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
- package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
- package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
- package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
- package/assets/avatars/index.html +4 -0
- package/assets/avatars/male/01-explorer-d78774.png +0 -0
- package/assets/avatars/male/06-planner-92594b.png +0 -0
- package/assets/avatars/male/06-planner-e7a391.png +0 -0
- package/assets/avatars/male/07-reviewer-623d36.png +0 -0
- package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
- package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
- package/assets/avatars/male/09-migrator-e7a391.png +0 -0
- package/assets/avatars/male/11-debugger-623d36.png +0 -0
- package/assets/avatars/male/11-debugger-d78774.png +0 -0
- package/assets/avatars/male/12-designer-b16a5b.png +0 -0
- package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
- package/assets/avatars/male/15-editor-d78774.png +0 -0
- package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
- package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
- package/assets/avatars/male/18-scout-623d36.png +0 -0
- package/assets/avatars/male/18-scout-d78774.png +0 -0
- package/assets/avatars/male/19-architect-b16a5b.png +0 -0
- package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
- package/assets/avatars/male/20-operator-92594b.png +0 -0
- package/assets/avatars/male/20-operator-e7a391.png +0 -0
- package/assets/avatars/male/21-coordinator-623d36.png +0 -0
- package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
- package/assets/avatars/male/22-auditor-d78774.png +0 -0
- package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
- package/assets/avatars/male/25-pioneer-623d36.png +0 -0
- package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
- package/assets/avatars/male/28-librarian-623d36.png +0 -0
- package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
- package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
- package/assets/avatars/male/30-tracker-e7a391.png +0 -0
- package/assets/avatars/male/31-gardener-92594b.png +0 -0
- package/assets/avatars/male/32-refactorer-623d36.png +0 -0
- package/assets/avatars/male/32-refactorer-d78774.png +0 -0
- package/assets/avatars/male/35-profiler-623d36.png +0 -0
- package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
- package/assets/avatars/male/38-parser-92594b.png +0 -0
- package/assets/avatars/male/38-parser-e5a07e.png +0 -0
- package/assets/avatars/male/39-linter-623d36.png +0 -0
- package/assets/avatars/male/39-linter-d78774.png +0 -0
- package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
- package/assets/avatars/male/41-patcher-92594b.png +0 -0
- package/assets/avatars/male/41-patcher-e7a391.png +0 -0
- package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
- package/assets/avatars/male/43-maintainer-d78774.png +0 -0
- package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
- package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
- package/assets/avatars/male/44-guardian-e7a391.png +0 -0
- package/assets/avatars/male/45-sentinel-92594b.png +0 -0
- package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
- package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
- package/assets/avatars/male/49-hacker-623d36.png +0 -0
- package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
- package/assets/avatars/male/50-wrangler-d78774.png +0 -0
- package/assets/avatars/manifest.json +1202 -0
- package/index.ts +11 -0
- package/package.json +65 -4
- package/src/config.ts +176 -0
- package/src/core/avatar.ts +92 -0
- package/src/core/claims.ts +302 -0
- package/src/core/clock.ts +31 -0
- package/src/core/directory.ts +48 -0
- package/src/core/format.ts +382 -0
- package/src/core/mailbox.ts +239 -0
- package/src/core/mesh.ts +781 -0
- package/src/core/registry.ts +306 -0
- package/src/deliverer.ts +86 -0
- package/src/events.ts +66 -0
- package/src/index.ts +323 -0
- package/src/rpc.ts +52 -0
- package/src/storage.ts +105 -0
- package/src/tools/args.ts +132 -0
- package/src/tools/claim.ts +87 -0
- package/src/tools/heartbeat.ts +152 -0
- package/src/tools/inbox.ts +76 -0
- package/src/tools/index.ts +62 -0
- package/src/tools/peers.ts +101 -0
- package/src/tools/send.ts +84 -0
- package/src/tools/status.ts +137 -0
- package/src/tools/types.ts +72 -0
- package/src/tools/wait.ts +96 -0
- package/src/tui/avatar.ts +188 -0
- package/src/tui/index.tsx +216 -0
- package/src/types.ts +147 -0
- 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
|
+
}
|
package/src/deliverer.ts
ADDED
|
@@ -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
|
+
}
|