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
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
|
+
}
|