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
package/src/index.ts ADDED
@@ -0,0 +1,323 @@
1
+ /**
2
+ * @fileoverview OpenCode plugin entry point.
3
+ *
4
+ * `setup` wires four things and returns the cleanup that undoes all of them:
5
+ *
6
+ * 1. a `Mesh` (presence registry, mailboxes, claim table) built on the plugin
7
+ * location, seeded from a persisted snapshot when one exists;
8
+ * 2. a subscription to the server event stream, which is the only way a plugin
9
+ * learns that other sessions exist — the plugin context has no
10
+ * `session.list()`;
11
+ * 3. the seven `crosstalk_*` tools, registered under one namespace and one
12
+ * permission action;
13
+ * 4. a `context` hook that tells the model the channel exists — in every
14
+ * session, including one that is currently alone — so the tools get used
15
+ * without the user having to ask.
16
+ *
17
+ * The OpenCode-specific types are imported through the published package, but
18
+ * only as types plus `Plugin.define`, keeping the core free of host coupling.
19
+ */
20
+
21
+ import { readFileSync } from "node:fs"
22
+ import { Plugin } from "@opencode/plugin"
23
+ import { parseOptions } from "./config.ts"
24
+ import { type AvatarEntry } from "./core/avatar.ts"
25
+ import { systemClock } from "./core/clock.ts"
26
+ import { directoryPayload } from "./core/directory.ts"
27
+ import { Mesh } from "./core/mesh.ts"
28
+ import { createSessionDeliverer, type SessionLike } from "./deliverer.ts"
29
+ import { pumpEvents } from "./events.ts"
30
+ import { CrosstalkRpc } from "./rpc.ts"
31
+ import { MeshStore, type StorageLike } from "./storage.ts"
32
+ import { registerTools } from "./tools/index.ts"
33
+
34
+ /** Plugin identity. Also the `plugins: ["-opencode.crosstalk"]` disable key. */
35
+ export const id = "opencode.crosstalk"
36
+
37
+ /** Debounce window for snapshot writes triggered by event traffic. */
38
+ const PERSIST_DEBOUNCE_MS = 2_000
39
+
40
+ /** Debounce window for RPC directory pushes triggered by mesh mutations. */
41
+ const RPC_DEBOUNCE_MS = 500
42
+
43
+ /**
44
+ * Bridge the real session domain to the narrow shape the deliverer needs.
45
+ * `Session.ID` is a branded string and the metadata index signature is typed
46
+ * as the server's `JsonValue`; the mesh works in plain strings and plain JSON,
47
+ * so both are narrowed once, here, instead of throughout the plugin.
48
+ */
49
+ function toSessionLike(session: Plugin.Context["session"]): SessionLike {
50
+ return {
51
+ synthetic: ({ sessionID, text, description, delivery, metadata }) =>
52
+ session.synthetic({
53
+ sessionID: sessionID as never,
54
+ text,
55
+ ...(description === undefined ? {} : { description }),
56
+ ...(delivery === undefined ? {} : { delivery }),
57
+ ...(metadata === undefined ? {} : { metadata: metadata as never }),
58
+ }),
59
+ }
60
+ }
61
+
62
+ /**
63
+ * The lone-session briefing. Every session has to learn that the channel
64
+ * exists: a session that never declares a name or role is not addressable when
65
+ * a peer appears later, and the first session in a project always starts alone.
66
+ * Short on purpose — it is repeated until a peer or a lease shows up.
67
+ */
68
+ function soloBriefing(): string {
69
+ return [
70
+ "Crosstalk is installed in this workspace, so other OpenCode sessions here can coordinate with you.",
71
+ "Declare once — briefly — with `crosstalk_status`: a unique human name (so peers can address you), your role,",
72
+ "a goal of a few words, and a one-line `summary` of what you are doing now.",
73
+ "`crosstalk_peers` shows who else is here; work that does not overlap theirs needs no coordination.",
74
+ "No other session is on this channel right now.",
75
+ ].join(" ")
76
+ }
77
+
78
+ function briefing(peers: number, claims: number): string {
79
+ if (peers === 0 && claims === 0) return soloBriefing()
80
+ return [
81
+ "Other OpenCode sessions can be working in this repository with you.",
82
+ "Coordinate in English only and keep it token-tight: one short sentence per status, message, or claim.",
83
+ "Declare once — briefly — then keep moving: `crosstalk_status` sets a unique human name (so the user can say",
84
+ '"tell George…" and peers can address you) plus your role and a goal of a few words; `crosstalk_peers` shows',
85
+ "active sessions and their leases, and work that does not overlap theirs needs no coordination.",
86
+ "Write `summary` — one or two short sentences on what you are doing right now — and refresh it when your",
87
+ "task changes: it shows under your avatar in the sidebar and peers read it.",
88
+ "Talk before you collide — as a notice, not an essay: `crosstalk_send` one short precise sentence addressed by",
89
+ "session id or name and continue elsewhere; replies are injected into live turns (`crosstalk_inbox` to catch up).",
90
+ "Never force a claim. Lease what you are editing now with `crosstalk_claim` (it refuses if another session already",
91
+ "holds it), renew if the work runs long, release when done. Only sessions in this project are reachable — peers",
92
+ "marked not addressable cannot receive mail.",
93
+ `Right now ${peers} other session${peers === 1 ? " is" : "s are"} on this channel` +
94
+ (claims > 0 ? ` and ${claims} file${claims === 1 ? " is" : "s are"} already leased.` : "."),
95
+ ].join(" ")
96
+ }
97
+
98
+ /** The session id an event is about, when it carries one. */
99
+ function eventSessionID(event: { data?: Readonly<Record<string, unknown>> }): string | undefined {
100
+ const value = event.data?.sessionID
101
+ return typeof value === "string" && value.length > 0 ? value : undefined
102
+ }
103
+
104
+ /** The bundled Personas pool; empty when the art is missing or unreadable. */
105
+ function loadAvatars(): AvatarEntry[] {
106
+ try {
107
+ const parsed = JSON.parse(readFileSync(new URL("../assets/avatars/manifest.json", import.meta.url), "utf8")) as {
108
+ entries?: AvatarEntry[]
109
+ }
110
+ return Array.isArray(parsed.entries) ? parsed.entries : []
111
+ } catch {
112
+ return []
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Which setup instance owns each location. `globalThis` rather than a module
118
+ * variable because the same plugin can be installed twice (a global link plus
119
+ * a config path to the same checkout), and two loads can get two module
120
+ * instances while sharing one process — the location guard must still see the
121
+ * whole truth.
122
+ */
123
+ interface CrosstalkGuard {
124
+ owners: Map<string, { token: symbol; source: string }>
125
+ /**
126
+ * Loads the guard refused because the location already had an owner. The
127
+ * status self-check surfaces it: a duplicate install is otherwise only
128
+ * visible in logs nobody reads.
129
+ */
130
+ refused: number
131
+ }
132
+
133
+ function crosstalkGuard(): CrosstalkGuard {
134
+ const holder = globalThis as { __crosstalk?: Partial<CrosstalkGuard> }
135
+ const guard = (holder.__crosstalk ??= {}) as CrosstalkGuard
136
+ guard.owners ??= new Map()
137
+ guard.refused ??= 0
138
+ return guard
139
+ }
140
+
141
+ export default Plugin.define({
142
+ id,
143
+ async setup(ctx) {
144
+ const { options, warnings } = parseOptions(ctx.options)
145
+ for (const warning of warnings) console.warn(`[crosstalk] ${warning}`)
146
+
147
+ const projectID = ctx.location.project?.id
148
+ const directory = ctx.location.directory
149
+
150
+ // One live instance per location. The same plugin can be installed twice
151
+ // (a global link plus a config path) and the host will happily load both;
152
+ // two meshes would serve tools and RPC from split state while clobbering
153
+ // one snapshot key. The first load owns the location, later ones stay
154
+ // inert and say so.
155
+ const place = directory ?? "__unknown__"
156
+ const guard = crosstalkGuard()
157
+ const owner = guard.owners.get(place)
158
+ if (owner) {
159
+ guard.refused += 1
160
+ console.warn(
161
+ `[crosstalk] ${id} is already loaded for ${place} by ${owner.source}; this copy (${import.meta.url}) registers nothing. Load the plugin from exactly one location.`,
162
+ )
163
+ return async () => {}
164
+ }
165
+ const ownerToken = Symbol(place)
166
+ guard.owners.set(place, { token: ownerToken, source: import.meta.url })
167
+
168
+ const mesh = new Mesh({
169
+ options: {
170
+ scope: options.scope,
171
+ staleAfterMs: options.staleAfterMs,
172
+ evictAfterMs: options.evictAfterMs,
173
+ maxMessages: options.maxMessages,
174
+ messageTtlMs: options.messageTtlMs,
175
+ claimTtlMs: options.claimTtlMs,
176
+ maxWaitMs: options.maxWaitMs,
177
+ pollMs: options.pollMs,
178
+ },
179
+ clock: systemClock,
180
+ deliverer: createSessionDeliverer(toSessionLike(ctx.session)),
181
+ defaults: { ...(projectID ? { projectID } : {}), ...(directory ? { directory } : {}) },
182
+ avatars: loadAvatars(),
183
+ })
184
+
185
+ // `ctx.storage` is JSON-typed; snapshots are plain JSON by construction,
186
+ // and a round trip guarantees nothing unserializable reaches it.
187
+ const storage: StorageLike = {
188
+ get: (key) => ctx.storage.get(key),
189
+ set: (key, value) => ctx.storage.set(key, JSON.parse(JSON.stringify(value))),
190
+ remove: (key) => ctx.storage.remove(key),
191
+ scan: async (query) => {
192
+ const page = await ctx.storage.scan({ prefix: query.prefix, after: query.after, limit: query.limit })
193
+ return { entries: page.entries, next: page.next }
194
+ },
195
+ }
196
+ const store = options.persist ? new MeshStore(storage, { key: options.storageKey }) : undefined
197
+ if (store) {
198
+ const snapshot = await store.load(projectID)
199
+ if (snapshot) mesh.restore(snapshot)
200
+ }
201
+ // Sessions that declared a task before the portrait feature existed (or
202
+ // whose snapshot predates it) get one now — the only place a portrait is
203
+ // assigned outside `declare`.
204
+ mesh.assignPortraits()
205
+
206
+ // Persist after quiet periods: the event stream is chatty and a write per
207
+ // event would hammer storage for state that is identical.
208
+ let persistTimer: ReturnType<typeof setTimeout> | undefined
209
+ const schedulePersist = () => {
210
+ if (!store || persistTimer) return
211
+ persistTimer = setTimeout(() => {
212
+ persistTimer = undefined
213
+ void store.save(mesh.snapshot(), projectID).catch((error) => {
214
+ console.warn(`[crosstalk] could not persist mesh state: ${String(error)}`)
215
+ })
216
+ }, PERSIST_DEBOUNCE_MS)
217
+ persistTimer.unref?.()
218
+ }
219
+
220
+ const controller = new AbortController()
221
+
222
+ // A session that started before the plugin loaded never emits
223
+ // `session.created` at us, so its record would stay without a project and
224
+ // be unaddressable under strict scope. Heal each such record once by
225
+ // reading it from the host.
226
+ const enrichAttempted = new Set<string>()
227
+ const enrichSession = async (sessionID: string) => {
228
+ try {
229
+ const info = await ctx.session.get({ sessionID: sessionID as never })
230
+ if (controller.signal.aborted) return
231
+ mesh.registry.update(sessionID, {
232
+ projectID: info.projectID,
233
+ directory: info.location?.directory,
234
+ title: info.title,
235
+ agent: info.agent,
236
+ parentID: info.parentID,
237
+ created: info.time?.created,
238
+ })
239
+ schedulePersist()
240
+ scheduleRpc()
241
+ } catch {
242
+ // A session owned by another location may not be readable; leave it.
243
+ }
244
+ }
245
+
246
+ const streaming = pumpEvents(ctx.event, controller.signal, {
247
+ onEvent: (event) => {
248
+ mesh.applyEvent(event)
249
+ schedulePersist()
250
+ const sessionID = eventSessionID(event)
251
+ if (
252
+ sessionID !== undefined &&
253
+ mesh.registry.get(sessionID)?.projectID === undefined &&
254
+ !enrichAttempted.has(sessionID)
255
+ ) {
256
+ enrichAttempted.add(sessionID)
257
+ void enrichSession(sessionID)
258
+ }
259
+ },
260
+ onError: (error) => {
261
+ console.warn(`[crosstalk] event stream: ${String(error)}`)
262
+ },
263
+ })
264
+
265
+ // Declarations are rare but precious: snapshot them like event traffic
266
+ // instead of waiting for the next event to happen by.
267
+ const unsubscribePersist = mesh.subscribe(schedulePersist)
268
+
269
+ const toolRegistration = await ctx.tool.transform((editor) => {
270
+ registerTools(editor, { mesh, options })
271
+ })
272
+
273
+ const briefingRegistration = options.announce
274
+ ? await ctx.session.hook("context", (event) => {
275
+ const peers = mesh.peers(event.sessionID).length
276
+ const claims = mesh.claims.list({ includeExpired: false }).length
277
+ event.system.push({ type: "text", text: briefing(peers, claims) })
278
+ })
279
+ : undefined
280
+
281
+ // The TUI plugin reads this directory over RPC. Pushes are debounced so a
282
+ // chatty event stream does not become a chatty channel.
283
+ const rpcRegistration = await ctx.rpc.register(CrosstalkRpc, {
284
+ directory: async () => directoryPayload(mesh),
285
+ })
286
+ let rpcTimer: ReturnType<typeof setTimeout> | undefined
287
+ const scheduleRpc = () => {
288
+ if (rpcTimer) return
289
+ rpcTimer = setTimeout(() => {
290
+ rpcTimer = undefined
291
+ void rpcRegistration.events.emit("changed", directoryPayload(mesh)).catch((error) => {
292
+ console.warn(`[crosstalk] could not emit directory update: ${String(error)}`)
293
+ })
294
+ }, RPC_DEBOUNCE_MS)
295
+ rpcTimer.unref?.()
296
+ }
297
+ const unsubscribeRpc = mesh.subscribe(scheduleRpc)
298
+
299
+ return async () => {
300
+ if (guard.owners.get(place)?.token === ownerToken) guard.owners.delete(place)
301
+ controller.abort()
302
+ await streaming
303
+ unsubscribeRpc()
304
+ unsubscribePersist()
305
+ if (rpcTimer) clearTimeout(rpcTimer)
306
+ await briefingRegistration?.dispose()
307
+ await rpcRegistration.dispose()
308
+ await toolRegistration.dispose()
309
+ if (persistTimer) clearTimeout(persistTimer)
310
+ if (store) {
311
+ await store.save(mesh.snapshot(), projectID).catch(() => {})
312
+ }
313
+ }
314
+ },
315
+ })
316
+
317
+ export { parseOptions, DEFAULTS } from "./config.ts"
318
+ export { Mesh } from "./core/mesh.ts"
319
+ export { MeshStore } from "./storage.ts"
320
+ export { pumpEvents, asCrosstalkEvent } from "./events.ts"
321
+ export { createSessionDeliverer, renderDelivery } from "./deliverer.ts"
322
+ export { registerTools, toolFactories, NAMESPACE_DESCRIPTION } from "./tools/index.ts"
323
+ export type * from "./types.ts"
package/src/rpc.ts ADDED
@@ -0,0 +1,52 @@
1
+ /**
2
+ * @fileoverview The RPC contract shared by the server plugin and the TUI plugin.
3
+ *
4
+ * Only a type is imported: `Rpc.define` is an identity function, so this plain
5
+ * object works on both sides and the TUI process never resolves a runtime
6
+ * plugin module for it. Keep the schemas in step with `DirectoryEntry`.
7
+ */
8
+
9
+ import type { Rpc } from "@opencode/plugin/rpc"
10
+
11
+ const sessionSchema = {
12
+ type: "object",
13
+ properties: {
14
+ sessionID: { type: "string" },
15
+ status: { type: "string" },
16
+ title: { type: "string" },
17
+ name: { type: "string" },
18
+ role: { type: "string" },
19
+ goal: { type: "string" },
20
+ summary: { type: "string" },
21
+ summaryAt: { type: "number" },
22
+ avatar: { type: "string" },
23
+ directory: { type: "string" },
24
+ projectID: { type: "string" },
25
+ portrait: { type: "string" },
26
+ },
27
+ required: ["sessionID", "status"],
28
+ additionalProperties: false,
29
+ } as const
30
+
31
+ const directorySchema = {
32
+ type: "object",
33
+ properties: {
34
+ sessions: { type: "array", items: sessionSchema },
35
+ },
36
+ required: ["sessions"],
37
+ additionalProperties: false,
38
+ } as const
39
+
40
+ /** Method + event surface the TUI uses; the server registers the handlers. */
41
+ export const CrosstalkRpc = {
42
+ id: "crosstalk",
43
+ methods: {
44
+ directory: {
45
+ input: { type: "object", properties: {}, additionalProperties: false },
46
+ output: directorySchema,
47
+ },
48
+ },
49
+ events: {
50
+ changed: { schema: directorySchema },
51
+ },
52
+ } as const satisfies Rpc.Definition
package/src/storage.ts ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * @fileoverview Durable mesh state through the plugin storage domain.
3
+ *
4
+ * `ctx.storage` is a plugin-scoped JSON key/value store with prefix scan, which
5
+ * is enough to keep peers and leases across a plugin reload. Messages are
6
+ * deliberately not persisted: a mailbox is a live coordination channel, and
7
+ * replaying stale mail after a restart is worse than an empty inbox.
8
+ */
9
+
10
+ import type { MeshSnapshot } from "./types.ts"
11
+ import { SNAPSHOT_VERSION, LEGACY_SNAPSHOT_VERSION } from "./core/mesh.ts"
12
+
13
+ export interface StorageScanPage {
14
+ entries: ReadonlyArray<{ key: string; value: unknown }>
15
+ next?: string
16
+ }
17
+
18
+ /** Structural subset of `ctx.storage`. */
19
+ export interface StorageLike {
20
+ get(key: string): Promise<unknown>
21
+ set(key: string, value: unknown): Promise<void>
22
+ remove(key: string): Promise<void>
23
+ scan(options: { prefix: string; limit?: number; after?: string }): Promise<StorageScanPage>
24
+ }
25
+
26
+ export interface MeshStoreOptions {
27
+ /** Key prefix; per-project keys are appended. */
28
+ key: string
29
+ }
30
+
31
+ /**
32
+ * The one storage-key formula, shared by the store and the status self-check
33
+ * so the line the model reads names the key the state actually lives under.
34
+ */
35
+ export function meshStorageKey(prefix: string, projectID: string | undefined): string {
36
+ return `${prefix}/${projectID ?? "global"}`
37
+ }
38
+
39
+ function isRecord(value: unknown): value is Record<string, unknown> {
40
+ return typeof value === "object" && value !== null && !Array.isArray(value)
41
+ }
42
+
43
+ export class MeshStore {
44
+ readonly #storage: StorageLike
45
+ readonly #key: string
46
+
47
+ constructor(storage: StorageLike, options: MeshStoreOptions) {
48
+ this.#storage = storage
49
+ this.#key = options.key
50
+ }
51
+
52
+ keyFor(projectID: string | undefined): string {
53
+ return meshStorageKey(this.#key, projectID)
54
+ }
55
+
56
+ async save(snapshot: MeshSnapshot, projectID?: string): Promise<void> {
57
+ await this.#storage.set(this.keyFor(projectID), snapshot)
58
+ }
59
+
60
+ /**
61
+ * Read a snapshot, tolerating anything unusable. A missing, malformed,
62
+ * unknown-version, or JSON-unserializable payload yields `undefined` rather
63
+ * than an error: a broken cache must never stop a plugin from loading. The
64
+ * legacy v1 version passes through untouched for `Mesh.restore` to migrate.
65
+ */
66
+ async load(projectID?: string): Promise<MeshSnapshot | undefined> {
67
+ let raw: unknown
68
+ try {
69
+ raw = await this.#storage.get(this.keyFor(projectID))
70
+ } catch {
71
+ return undefined
72
+ }
73
+ if (!isRecord(raw)) return undefined
74
+ const version = raw.version
75
+ if (version !== SNAPSHOT_VERSION && version !== LEGACY_SNAPSHOT_VERSION) return undefined
76
+ if (!Array.isArray(raw.peers) || !Array.isArray(raw.claims)) return undefined
77
+ if (typeof raw.savedAt !== "number") return undefined
78
+ return {
79
+ version: version === LEGACY_SNAPSHOT_VERSION ? LEGACY_SNAPSHOT_VERSION : SNAPSHOT_VERSION,
80
+ savedAt: raw.savedAt,
81
+ peers: raw.peers.filter(isRecord) as unknown as MeshSnapshot["peers"],
82
+ claims: raw.claims.filter(isRecord) as unknown as MeshSnapshot["claims"],
83
+ }
84
+ }
85
+
86
+ async remove(projectID?: string): Promise<void> {
87
+ try {
88
+ await this.#storage.remove(this.keyFor(projectID))
89
+ } catch {
90
+ // Removing a key that was never written is not a failure worth raising.
91
+ }
92
+ }
93
+
94
+ /** Every stored project key, for diagnostics and cleanup. */
95
+ async listProjectKeys(): Promise<string[]> {
96
+ try {
97
+ const page = await this.#storage.scan({ prefix: `${this.#key}/` })
98
+ return page.entries
99
+ .map((entry) => entry.key.slice(this.#key.length + 1))
100
+ .filter((id): id is string => id.length > 0)
101
+ } catch {
102
+ return []
103
+ }
104
+ }
105
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * @fileoverview Minimal runtime reader for tool input.
3
+ *
4
+ * Tool `execute` receives `any` (the JSON Schema shape is not a TypeScript
5
+ * type), so every tool narrows and validates its own arguments here instead of
6
+ * trusting the model. A rejected argument returns a message the model can act
7
+ * on rather than a thrown stack trace.
8
+ */
9
+
10
+ export class ArgError extends Error {
11
+ constructor(message: string) {
12
+ super(message)
13
+ this.name = "ArgError"
14
+ }
15
+ }
16
+
17
+ function record(value: unknown): Record<string, unknown> {
18
+ if (value && typeof value === "object" && !Array.isArray(value)) return value as Record<string, unknown>
19
+ return {}
20
+ }
21
+
22
+ export function readString(input: unknown, key: string, options: { required: true; max?: number }): string
23
+ export function readString(
24
+ input: unknown,
25
+ key: string,
26
+ options?: { required?: false; max?: number; fallback?: string },
27
+ ): string | undefined
28
+ export function readString(
29
+ input: unknown,
30
+ key: string,
31
+ options: { required?: boolean; max?: number; fallback?: string } = {},
32
+ ): string | undefined {
33
+ const raw = record(input)[key]
34
+ if (raw === undefined || raw === null) {
35
+ if (options.required) throw new ArgError(`"${key}" is required`)
36
+ return options.fallback
37
+ }
38
+ if (typeof raw !== "string") throw new ArgError(`"${key}" must be a string`)
39
+ const value = raw.trim()
40
+ if (value === "") {
41
+ if (options.required) throw new ArgError(`"${key}" must not be empty`)
42
+ return options.fallback
43
+ }
44
+ if (options.max !== undefined && value.length > options.max) {
45
+ throw new ArgError(`"${key}" is ${value.length} characters; the limit is ${options.max}`)
46
+ }
47
+ return value
48
+ }
49
+
50
+ export function readNumber(input: unknown, key: string, options: { required: true; min?: number; max?: number }): number
51
+ export function readNumber(
52
+ input: unknown,
53
+ key: string,
54
+ options?: { required?: false; min?: number; max?: number; fallback?: number },
55
+ ): number | undefined
56
+ export function readNumber(
57
+ input: unknown,
58
+ key: string,
59
+ options: { required?: boolean; min?: number; max?: number; fallback?: number } = {},
60
+ ): number | undefined {
61
+ const raw = record(input)[key]
62
+ if (raw === undefined || raw === null) {
63
+ if (options.required) throw new ArgError(`"${key}" is required`)
64
+ return options.fallback
65
+ }
66
+ const value = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : Number.NaN
67
+ if (!Number.isFinite(value)) throw new ArgError(`"${key}" must be a number`)
68
+ if (options.min !== undefined && value < options.min) throw new ArgError(`"${key}" must be >= ${options.min}`)
69
+ if (options.max !== undefined && value > options.max) throw new ArgError(`"${key}" must be <= ${options.max}`)
70
+ return value
71
+ }
72
+
73
+ export function readBoolean(input: unknown, key: string, fallback?: boolean): boolean | undefined {
74
+ const raw = record(input)[key]
75
+ if (raw === undefined || raw === null) return fallback
76
+ if (typeof raw === "boolean") return raw
77
+ if (raw === "true") return true
78
+ if (raw === "false") return false
79
+ throw new ArgError(`"${key}" must be true or false`)
80
+ }
81
+
82
+ export function readEnum<T extends string>(
83
+ input: unknown,
84
+ key: string,
85
+ allowed: readonly T[],
86
+ fallback?: T,
87
+ ): T | undefined {
88
+ const raw = record(input)[key]
89
+ if (raw === undefined || raw === null) return fallback
90
+ if (typeof raw !== "string" || !(allowed as readonly string[]).includes(raw)) {
91
+ throw new ArgError(`"${key}" must be one of: ${allowed.join(", ")}`)
92
+ }
93
+ return raw as T
94
+ }
95
+
96
+ export function readStringArray(
97
+ input: unknown,
98
+ key: string,
99
+ options: { required: true; max?: number; maxLength?: number },
100
+ ): string[]
101
+ export function readStringArray(
102
+ input: unknown,
103
+ key: string,
104
+ options?: { required?: false; max?: number; maxLength?: number; fallback?: string[] },
105
+ ): string[] | undefined
106
+ export function readStringArray(
107
+ input: unknown,
108
+ key: string,
109
+ options: { required?: boolean; max?: number; maxLength?: number; fallback?: string[] } = {},
110
+ ): string[] | undefined {
111
+ const raw = record(input)[key]
112
+ if (raw === undefined || raw === null) {
113
+ if (options.required) throw new ArgError(`"${key}" is required`)
114
+ return options.fallback
115
+ }
116
+ const values = Array.isArray(raw) ? raw : [raw]
117
+ const out: string[] = []
118
+ for (const value of values) {
119
+ if (typeof value !== "string") throw new ArgError(`"${key}" must be a list of strings`)
120
+ const trimmed = value.trim()
121
+ if (trimmed === "") continue
122
+ if (options.maxLength !== undefined && trimmed.length > options.maxLength) {
123
+ throw new ArgError(`"${key}" entries must be at most ${options.maxLength} characters`)
124
+ }
125
+ out.push(trimmed)
126
+ }
127
+ if (options.required && out.length === 0) throw new ArgError(`"${key}" must list at least one value`)
128
+ if (options.max !== undefined && out.length > options.max) {
129
+ throw new ArgError(`"${key}" accepts at most ${options.max} entries`)
130
+ }
131
+ return out
132
+ }