opencode-crosstalk 0.0.0-stage → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +460 -2
  3. package/assets/CursorGuard.png +0 -0
  4. package/assets/Wren.png +0 -0
  5. package/assets/avatars/ATTRIBUTION.md +18 -0
  6. package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
  7. package/assets/avatars/female/02-manager-b16a5b.png +0 -0
  8. package/assets/avatars/female/02-manager-e7a391.png +0 -0
  9. package/assets/avatars/female/03-coder-92594b.png +0 -0
  10. package/assets/avatars/female/03-coder-e5a07e.png +0 -0
  11. package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
  12. package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
  13. package/assets/avatars/female/08-tester-d78774.png +0 -0
  14. package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
  15. package/assets/avatars/female/10-builder-92594b.png +0 -0
  16. package/assets/avatars/female/10-builder-e5a07e.png +0 -0
  17. package/assets/avatars/female/13-researcher-92594b.png +0 -0
  18. package/assets/avatars/female/13-researcher-e7a391.png +0 -0
  19. package/assets/avatars/female/14-writer-623d36.png +0 -0
  20. package/assets/avatars/female/14-writer-e5a07e.png +0 -0
  21. package/assets/avatars/female/16-fixer-e7a391.png +0 -0
  22. package/assets/avatars/female/17-deployer-92594b.png +0 -0
  23. package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
  24. package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
  25. package/assets/avatars/female/23-mentor-e7a391.png +0 -0
  26. package/assets/avatars/female/25-pioneer-d78774.png +0 -0
  27. package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
  28. package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
  29. package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
  30. package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
  31. package/assets/avatars/female/34-optimizer-92594b.png +0 -0
  32. package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
  33. package/assets/avatars/female/36-integrator-d78774.png +0 -0
  34. package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
  35. package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
  36. package/assets/avatars/female/37-compiler-e7a391.png +0 -0
  37. package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
  38. package/assets/avatars/female/42-shipper-623d36.png +0 -0
  39. package/assets/avatars/female/46-steward-623d36.png +0 -0
  40. package/assets/avatars/female/46-steward-d78774.png +0 -0
  41. package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
  42. package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
  43. package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
  44. package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
  45. package/assets/avatars/index.html +4 -0
  46. package/assets/avatars/male/01-explorer-d78774.png +0 -0
  47. package/assets/avatars/male/06-planner-92594b.png +0 -0
  48. package/assets/avatars/male/06-planner-e7a391.png +0 -0
  49. package/assets/avatars/male/07-reviewer-623d36.png +0 -0
  50. package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
  51. package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
  52. package/assets/avatars/male/09-migrator-e7a391.png +0 -0
  53. package/assets/avatars/male/11-debugger-623d36.png +0 -0
  54. package/assets/avatars/male/11-debugger-d78774.png +0 -0
  55. package/assets/avatars/male/12-designer-b16a5b.png +0 -0
  56. package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
  57. package/assets/avatars/male/15-editor-d78774.png +0 -0
  58. package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
  59. package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
  60. package/assets/avatars/male/18-scout-623d36.png +0 -0
  61. package/assets/avatars/male/18-scout-d78774.png +0 -0
  62. package/assets/avatars/male/19-architect-b16a5b.png +0 -0
  63. package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
  64. package/assets/avatars/male/20-operator-92594b.png +0 -0
  65. package/assets/avatars/male/20-operator-e7a391.png +0 -0
  66. package/assets/avatars/male/21-coordinator-623d36.png +0 -0
  67. package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
  68. package/assets/avatars/male/22-auditor-d78774.png +0 -0
  69. package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
  70. package/assets/avatars/male/25-pioneer-623d36.png +0 -0
  71. package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
  72. package/assets/avatars/male/28-librarian-623d36.png +0 -0
  73. package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
  74. package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
  75. package/assets/avatars/male/30-tracker-e7a391.png +0 -0
  76. package/assets/avatars/male/31-gardener-92594b.png +0 -0
  77. package/assets/avatars/male/32-refactorer-623d36.png +0 -0
  78. package/assets/avatars/male/32-refactorer-d78774.png +0 -0
  79. package/assets/avatars/male/35-profiler-623d36.png +0 -0
  80. package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
  81. package/assets/avatars/male/38-parser-92594b.png +0 -0
  82. package/assets/avatars/male/38-parser-e5a07e.png +0 -0
  83. package/assets/avatars/male/39-linter-623d36.png +0 -0
  84. package/assets/avatars/male/39-linter-d78774.png +0 -0
  85. package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
  86. package/assets/avatars/male/41-patcher-92594b.png +0 -0
  87. package/assets/avatars/male/41-patcher-e7a391.png +0 -0
  88. package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
  89. package/assets/avatars/male/43-maintainer-d78774.png +0 -0
  90. package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
  91. package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
  92. package/assets/avatars/male/44-guardian-e7a391.png +0 -0
  93. package/assets/avatars/male/45-sentinel-92594b.png +0 -0
  94. package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
  95. package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
  96. package/assets/avatars/male/49-hacker-623d36.png +0 -0
  97. package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
  98. package/assets/avatars/male/50-wrangler-d78774.png +0 -0
  99. package/assets/avatars/manifest.json +1202 -0
  100. package/index.ts +11 -0
  101. package/package.json +65 -4
  102. package/src/config.ts +176 -0
  103. package/src/core/avatar.ts +92 -0
  104. package/src/core/claims.ts +302 -0
  105. package/src/core/clock.ts +31 -0
  106. package/src/core/directory.ts +48 -0
  107. package/src/core/format.ts +382 -0
  108. package/src/core/mailbox.ts +239 -0
  109. package/src/core/mesh.ts +781 -0
  110. package/src/core/registry.ts +306 -0
  111. package/src/deliverer.ts +86 -0
  112. package/src/events.ts +66 -0
  113. package/src/index.ts +323 -0
  114. package/src/rpc.ts +52 -0
  115. package/src/storage.ts +105 -0
  116. package/src/tools/args.ts +132 -0
  117. package/src/tools/claim.ts +87 -0
  118. package/src/tools/heartbeat.ts +152 -0
  119. package/src/tools/inbox.ts +76 -0
  120. package/src/tools/index.ts +62 -0
  121. package/src/tools/peers.ts +101 -0
  122. package/src/tools/send.ts +84 -0
  123. package/src/tools/status.ts +137 -0
  124. package/src/tools/types.ts +72 -0
  125. package/src/tools/wait.ts +96 -0
  126. package/src/tui/avatar.ts +188 -0
  127. package/src/tui/index.tsx +216 -0
  128. package/src/types.ts +147 -0
  129. package/tui.ts +8 -0
@@ -0,0 +1,216 @@
1
+ /**
2
+ * @fileoverview CLI (TUI) plugin: the session avatar in the sidebar, with the
3
+ * session's live status summary under it.
4
+ *
5
+ * The server plugin owns the directory (who declared what); this side renders
6
+ * it. The RPC contract is shared as a plain object, so that no runtime plugin
7
+ * module has to resolve in the TUI process.
8
+ */
9
+
10
+ import { readFileSync } from "node:fs"
11
+ import { Plugin, usePlugin } from "@opencode/plugin/tui"
12
+ import { Show, createMemo, createSignal } from "solid-js"
13
+ import { TextAttributes } from "@opentui/core"
14
+ import type { DirectoryEntry, DirectoryPayload } from "../core/directory.ts"
15
+ import { normalizeResource } from "../core/claims.ts"
16
+ import { CrosstalkRpc } from "../rpc.ts"
17
+ import { emoticonFor, entryForPortrait, isSummaryStale, summaryText, titleWithEmoticon, type AvatarEntry } from "./avatar.ts"
18
+
19
+ const manifestUrl = new URL("../../assets/avatars/manifest.json", import.meta.url)
20
+
21
+ /** Width of the avatar art, and so of the column the summary wraps inside. */
22
+ const AVATAR_WIDTH = 29
23
+
24
+ /**
25
+ * How often the sidebar re-checks whether a summary went stale. The directory
26
+ * is only rebuilt on mesh mutations, and a session nobody talks to emits
27
+ * none — without this tick an untouched summary could stay undimmed forever.
28
+ */
29
+ const SUMMARY_TICK_MS = 30_000
30
+
31
+ function loadEntries(): AvatarEntry[] {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(manifestUrl, "utf8")) as { entries?: AvatarEntry[] }
34
+ return Array.isArray(parsed.entries) ? parsed.entries : []
35
+ } catch {
36
+ return []
37
+ }
38
+ }
39
+
40
+ const sourceCache = new Map<string, URL>()
41
+
42
+ function sourceFor(entry: AvatarEntry): URL {
43
+ const key = `${entry.pool}/${entry.file}`
44
+ let url = sourceCache.get(key)
45
+ if (!url) {
46
+ url = new URL(`../../assets/avatars/${key}`, import.meta.url)
47
+ sourceCache.set(key, url)
48
+ }
49
+ return url
50
+ }
51
+
52
+ function asPayload(value: unknown): DirectoryPayload {
53
+ const sessions = (value as { sessions?: unknown } | undefined)?.sessions
54
+ return { sessions: Array.isArray(sessions) ? (sessions as DirectoryEntry[]) : [] }
55
+ }
56
+
57
+ /**
58
+ * Whether a session sits in this TUI's directory or below it, compared with the
59
+ * same normalisation claims use so a Windows worktree does not look like a
60
+ * different place because of a separator or a letter case.
61
+ */
62
+ function inPlace(candidate: string | undefined, home: string): boolean {
63
+ if (candidate === undefined) return false
64
+ const base = normalizeResource(home)
65
+ const other = normalizeResource(candidate)
66
+ return other === base || other.startsWith(`${base}/`)
67
+ }
68
+
69
+ function AvatarLine(props: {
70
+ sessionID: string
71
+ directory: () => DirectoryPayload
72
+ entries: readonly AvatarEntry[]
73
+ /** Starts a fresh staleness evaluation; see `SUMMARY_TICK_MS`. */
74
+ tick: () => number
75
+ }) {
76
+ const context = usePlugin()
77
+ const [failed, setFailed] = createSignal(false)
78
+ const session = () => props.directory().sessions.find((entry) => entry.sessionID === props.sessionID)
79
+ // The live status line under the art: one or two short sentences on what the
80
+ // session is doing right now, re-read from the directory so every refreshed
81
+ // declaration shows up without a reload.
82
+ const summary = () => summaryText(session())
83
+ // Dimmed once the summary stops being refreshed: read on the tick so the dim
84
+ // arrives on its own, without waiting for the next directory push.
85
+ const stale = createMemo(() => {
86
+ props.tick()
87
+ return isSummaryStale(session()?.summaryAt, Date.now())
88
+ })
89
+ // The server froze the portrait when the session first declared a task;
90
+ // a session without one has no task and shows no avatar. A portrait whose
91
+ // art left the manifest is not swapped for another — it shows nothing.
92
+ const picked = createMemo(() => entryForPortrait(props.entries, session()?.portrait))
93
+ // Stable source: a fresh URL on every directory update would make the
94
+ // renderable re-decode the PNG on each change event.
95
+ const source = createMemo(() => {
96
+ const entry = picked()
97
+ return entry ? sourceFor(entry) : undefined
98
+ })
99
+
100
+ return (
101
+ <Show when={picked()}>
102
+ {() => (
103
+ <box flexDirection="column" alignItems="center" gap={0}>
104
+ {/*
105
+ Blocks on purpose: the sidebar sits in a scrollbox and the terminal
106
+ we target (VS Code) announces sixel but does not paint it, so cell
107
+ drawing is the portable path. On kitty-graphics terminals this can
108
+ be switched to "auto".
109
+ */}
110
+ <image
111
+ source={source()}
112
+ width={AVATAR_WIDTH}
113
+ height={15}
114
+ fit="cover"
115
+ protocol="blocks"
116
+ onError={() => setFailed(true)}
117
+ />
118
+ <text fg={context.theme.text.base}>{`${emoticonFor(picked())} ${session()?.name ?? "crosstalk"}`}</text>
119
+ <text fg={context.theme.text.muted}>{session()?.role ?? ""}</text>
120
+ <Show when={summary()}>
121
+ {() => (
122
+ <text
123
+ fg={context.theme.text.muted}
124
+ width={AVATAR_WIDTH}
125
+ wrapMode="word"
126
+ attributes={stale() ? TextAttributes.DIM : TextAttributes.NONE}
127
+ >
128
+ {summary()}
129
+ </text>
130
+ )}
131
+ </Show>
132
+ <Show when={failed()}>
133
+ <text fg={context.theme.text.muted}>[image failed]</text>
134
+ </Show>
135
+ </box>
136
+ )}
137
+ </Show>
138
+ )
139
+ }
140
+
141
+ export default Plugin.define({
142
+ id: "opencode.crosstalk.tui",
143
+ async setup(context) {
144
+ const entries = loadEntries()
145
+ const [directory, setDirectory] = createSignal<DirectoryPayload>({ sessions: [] })
146
+ // Slow tick for the summary's staleness dim; see `SUMMARY_TICK_MS`.
147
+ const [tick, setTick] = createSignal(0)
148
+ const summaryClock = setInterval(() => setTick((value) => value + 1), SUMMARY_TICK_MS)
149
+ summaryClock.unref?.()
150
+ const rpc = context.client.rpc(CrosstalkRpc)
151
+ const location = context.location ?? context.data.location.default()
152
+
153
+ /**
154
+ * The tab header has no slot a plugin can render into — its label is the
155
+ * session title — so the emoticon is written there instead.
156
+ *
157
+ * Deliberately narrow: only a session that declared a crosstalk name, and
158
+ * only one in this TUI's directory or below it, may be renamed; `titleWithEmoticon`
159
+ * returns nothing to write once the title already carries the glyph, so the
160
+ * `session.renamed` event this triggers comes back as a no-op instead of a
161
+ * loop. Anything the user titled themselves is left as they wrote it.
162
+ */
163
+ const written = new Map<string, string>()
164
+ async function decorate(payload: DirectoryPayload): Promise<void> {
165
+ const home = location?.directory
166
+ if (home === undefined) return
167
+ for (const entry of payload.sessions) {
168
+ if (entry.name === undefined || !inPlace(entry.directory, home)) continue
169
+ // The glyph freezes with the portrait: a later role change never
170
+ // silently rewrites the user's tab title.
171
+ const portrait = entryForPortrait(entries, entry.portrait)
172
+ if (portrait === undefined) continue
173
+ const wanted = titleWithEmoticon(entry.title, emoticonFor(portrait), entry.name)
174
+ if (wanted === undefined || written.get(entry.sessionID) === wanted) continue
175
+ written.set(entry.sessionID, wanted)
176
+ try {
177
+ await context.client.session.update({ sessionID: entry.sessionID, title: wanted })
178
+ } catch {
179
+ // Gone, or not ours to rename: forget the attempt and let the next
180
+ // directory update retry it.
181
+ written.delete(entry.sessionID)
182
+ }
183
+ }
184
+ }
185
+
186
+ const apply = (payload: unknown) => {
187
+ const next = asPayload(payload)
188
+ setDirectory(next)
189
+ void decorate(next)
190
+ }
191
+
192
+ try {
193
+ apply(await rpc.directory({}, { location }))
194
+ } catch {
195
+ // The sidebar stays empty until the next change event arrives.
196
+ }
197
+ const unsubscribe = rpc.events.on("changed", (event) => {
198
+ const eventDirectory = (event as { location?: { directory?: string } }).location?.directory
199
+ if (location?.directory !== undefined && eventDirectory !== location.directory) return
200
+ apply(event.data)
201
+ })
202
+
203
+ const unregister = context.ui.slot({
204
+ append: "sidebar.content",
205
+ render: (input) => (
206
+ <AvatarLine sessionID={input.sessionID} directory={directory} entries={entries} tick={tick} />
207
+ ),
208
+ })
209
+
210
+ return () => {
211
+ unsubscribe()
212
+ unregister()
213
+ clearInterval(summaryClock)
214
+ }
215
+ },
216
+ })
package/src/types.ts ADDED
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @fileoverview Shared value types for the crosstalk mesh.
3
+ *
4
+ * Nothing in `src/core` imports an OpenCode package: the host adapter in
5
+ * `src/index.ts` translates OpenCode events and tool calls into these plain
6
+ * shapes, which keeps the coordination logic unit-testable in isolation.
7
+ */
8
+
9
+ /** Coarse liveness of a peer session, derived from the server event stream. */
10
+ export type PeerStatus = "running" | "idle" | "unknown"
11
+
12
+ export type Scope = "project" | "location" | "server"
13
+
14
+ /** What a session told the mesh about itself through `crosstalk_status`. */
15
+ export interface Declared {
16
+ /**
17
+ * A unique human name chosen by the session, so the user can say "tell
18
+ * George…" and peers can address it in `crosstalk_send`. Optional; the
19
+ * session id stays the authoritative identity.
20
+ */
21
+ name?: string
22
+ role?: string
23
+ goal?: string
24
+ /**
25
+ * What the session is doing *right now*: one or two short sentences, kept
26
+ * current as the work moves. Unlike `goal` — the overall objective, declared
27
+ * once — this is the live status line shown under the avatar in the TUI
28
+ * sidebar and read by peers through `crosstalk_peers`, so it is meant to be
29
+ * re-declared whenever the task changes.
30
+ */
31
+ summary?: string
32
+ /**
33
+ * When `summary` was last declared, in ms. Refreshing any other declared
34
+ * field leaves it alone, so it ages the *summary* rather than the
35
+ * declaration. Absent for summaries restored from a snapshot written by an
36
+ * older version — those are neither annotated nor dimmed, just shown.
37
+ */
38
+ summaryAt?: number
39
+ workingOn?: string[]
40
+ note?: string
41
+ /**
42
+ * Optional avatar hint (an emoji such as `👩` or `👨`) that steers which
43
+ * character pool the portrait is drawn from. The concrete artwork is chosen
44
+ * by the mesh on the session's first task-bearing declaration and then
45
+ * frozen on the peer record — see `Peer.portrait`.
46
+ */
47
+ avatar?: string
48
+ updatedAt?: number
49
+ }
50
+
51
+ /** One session the mesh knows about. */
52
+ export interface Peer {
53
+ sessionID: string
54
+ title?: string
55
+ agent?: string
56
+ /** Human readable `provider/model#variant` label. */
57
+ model?: string
58
+ parentID?: string
59
+ projectID?: string
60
+ directory?: string
61
+ status: PeerStatus
62
+ created?: number
63
+ /** Timestamp of the most recent event seen for this session. */
64
+ lastSeen: number
65
+ declared?: Declared
66
+ /**
67
+ * Frozen portrait: `pool/file` inside `assets/avatars/`, assigned by the
68
+ * mesh when the session first declares a task and never changed again —
69
+ * re-declaring a different role, name, or avatar hint cannot move it.
70
+ */
71
+ portrait?: string
72
+ }
73
+
74
+ /** A peer enriched with mesh-derived state, as returned to tools. */
75
+ export interface PeerView extends Peer {
76
+ isSelf: boolean
77
+ /** No event for longer than the stale threshold. */
78
+ stale: boolean
79
+ unread: number
80
+ claims: string[]
81
+ /**
82
+ * Provably within the configured communication scope. Only addressable
83
+ * peers can be messaged or awaited; the rest are listed for visibility but
84
+ * marked "not addressable".
85
+ */
86
+ addressable: boolean
87
+ }
88
+
89
+ export type MessageKind = "message" | "status" | "request" | "answer" | "system"
90
+
91
+ export type Delivery = "steer" | "queue"
92
+
93
+ /** A message delivered into one recipient's mailbox. */
94
+ export interface CrosstalkMessage {
95
+ id: string
96
+ seq: number
97
+ from: string
98
+ /** The sender's declared human name, stamped at send time. */
99
+ fromName?: string
100
+ fromRole?: string
101
+ to: string
102
+ text: string
103
+ topic?: string
104
+ kind: MessageKind
105
+ delivery: Delivery
106
+ created: number
107
+ readAt?: number
108
+ meta?: Record<string, unknown>
109
+ }
110
+
111
+ /** An exclusive lease on a path or named resource. */
112
+ export interface Claim {
113
+ /** Normalized comparison key. */
114
+ key: string
115
+ /** The resource string as the holder wrote it. */
116
+ raw: string
117
+ holder: string
118
+ note?: string
119
+ acquired: number
120
+ expires: number
121
+ }
122
+
123
+ export interface ClaimConflict {
124
+ key: string
125
+ raw: string
126
+ holder: string
127
+ note?: string
128
+ expires: number
129
+ }
130
+
131
+ export interface ClaimOutcome {
132
+ claimed: string[]
133
+ renewed: string[]
134
+ released: string[]
135
+ stolen: string[]
136
+ conflicts: ClaimConflict[]
137
+ /** True when a single conflict aborted the whole request. */
138
+ rolledBack: boolean
139
+ }
140
+
141
+ /** Serialized mesh state. Messages are intentionally not persisted. */
142
+ export interface MeshSnapshot {
143
+ version: number
144
+ savedAt: number
145
+ peers: Peer[]
146
+ claims: Claim[]
147
+ }
package/tui.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * @fileoverview Host entrypoint for the CLI (TUI) plugin.
3
+ *
4
+ * The host discovers a TUI entrypoint beside the server entrypoint (`index.ts`),
5
+ * so this file only re-exports the implementation.
6
+ */
7
+
8
+ export { default } from "./src/tui/index.tsx"