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,48 @@
1
+ /**
2
+ * @fileoverview The session directory the TUI plugin reads over RPC.
3
+ *
4
+ * Host-free: built straight from the mesh registry so it is unit-testable and
5
+ * stays in step with what `crosstalk_peers` sees.
6
+ */
7
+
8
+ import type { Mesh } from "./mesh.ts"
9
+
10
+ /** One session as the TUI needs it: identity, declared hints, location. */
11
+ export type DirectoryEntry = {
12
+ sessionID: string
13
+ status: "running" | "idle" | "unknown"
14
+ title?: string
15
+ name?: string
16
+ role?: string
17
+ goal?: string
18
+ /** Live status: 1-2 short sentences, refreshed as the session's work moves. */
19
+ summary?: string
20
+ /** When `summary` was declared, so a viewer can tell how current it is. */
21
+ summaryAt?: number
22
+ avatar?: string
23
+ directory?: string
24
+ projectID?: string
25
+ /** Frozen portrait (`pool/file` inside `assets/avatars/`); absent before the first task. */
26
+ portrait?: string
27
+ }
28
+
29
+ export type DirectoryPayload = { sessions: DirectoryEntry[] }
30
+
31
+ /** Every session the mesh has seen, with the fields a TUI needs. */
32
+ export function directoryPayload(mesh: Mesh): DirectoryPayload {
33
+ const sessions = mesh.registry.list().map((peer) => {
34
+ const entry: DirectoryEntry = { sessionID: peer.sessionID, status: peer.status }
35
+ if (peer.title !== undefined) entry.title = peer.title
36
+ if (peer.declared?.name !== undefined) entry.name = peer.declared.name
37
+ if (peer.declared?.role !== undefined) entry.role = peer.declared.role
38
+ if (peer.declared?.goal !== undefined) entry.goal = peer.declared.goal
39
+ if (peer.declared?.summary !== undefined) entry.summary = peer.declared.summary
40
+ if (peer.declared?.summaryAt !== undefined) entry.summaryAt = peer.declared.summaryAt
41
+ if (peer.declared?.avatar !== undefined) entry.avatar = peer.declared.avatar
42
+ if (peer.directory !== undefined) entry.directory = peer.directory
43
+ if (peer.projectID !== undefined) entry.projectID = peer.projectID
44
+ if (peer.portrait !== undefined) entry.portrait = peer.portrait
45
+ return entry
46
+ })
47
+ return { sessions }
48
+ }
@@ -0,0 +1,382 @@
1
+ /**
2
+ * @fileoverview Agent-facing rendering.
3
+ *
4
+ * Tool output is the only thing an agent sees, so these strings are part of
5
+ * the plugin's contract: compact, stable, and free of absolute paths the model
6
+ * cannot act on. Tests assert exact output, which is deliberate — a silent
7
+ * format change would degrade coordination without failing anything.
8
+ */
9
+
10
+ import type { ClaimOutcome, CrosstalkMessage, PeerView, Scope } from "../types.ts"
11
+
12
+ /** Compact relative age, e.g. `4s`, `12m`, `3h`. */
13
+ export function relativeAge(from: number, now: number): string {
14
+ const seconds = Math.max(0, Math.round((now - from) / 1000))
15
+ if (seconds < 60) return `${seconds}s`
16
+ const minutes = Math.round(seconds / 60)
17
+ if (minutes < 60) return `${minutes}m`
18
+ const hours = Math.round(minutes / 60)
19
+ if (hours < 48) return `${hours}h`
20
+ return `${Math.round(hours / 24)}d`
21
+ }
22
+
23
+ /** Trim a long value for one-line display. */
24
+ export function ellipsis(value: string, max: number): string {
25
+ const flat = value.replaceAll(/\s+/g, " ").trim()
26
+ if (flat.length <= max) return flat
27
+ return `${flat.slice(0, Math.max(0, max - 1))}…`
28
+ }
29
+
30
+ /**
31
+ * A summary younger than this is current. The peer line annotates its age only
32
+ * once a session has stopped refreshing it: a running session's live status
33
+ * needs no timestamp to be trusted, and every peer line paying for a clock it
34
+ * does not need is noise.
35
+ */
36
+ export const SUMMARY_FRESH_MS = 5 * 60_000
37
+
38
+ /**
39
+ * A summary older than this is *stale*: the sidebar already dims it (see
40
+ * `STALE_SUMMARY_MS` in `src/tui/avatar.ts`, which mirrors this value on
41
+ * purpose — the dim the user sees and the nudge the agent gets should land
42
+ * together), and the declaring session is told to refresh it.
43
+ */
44
+ export const SUMMARY_STALE_MS = 15 * 60_000
45
+
46
+ /**
47
+ * How long ago the peer declared its summary — ` (14m ago)`, or an empty
48
+ * string while it is fresh, missing (declared before the feature, or restored
49
+ * from a snapshot that predates it), or clock-skewed into the future. The
50
+ * window is closed at the boundary: exactly `SUMMARY_FRESH_MS` is still fresh,
51
+ * one millisecond past it is not — the same convention as the dim rule in
52
+ * `src/tui/avatar.ts`.
53
+ */
54
+ function summaryAge(peer: PeerView, now: number): string {
55
+ const at = peer.declared?.summaryAt
56
+ if (at === undefined || now - at <= SUMMARY_FRESH_MS) return ""
57
+ return ` (${relativeAge(at, now)} ago)`
58
+ }
59
+
60
+ function peerLine(peer: PeerView, now: number, scope: Scope): string {
61
+ const parts = [`- ${peer.sessionID}`]
62
+ if (peer.declared?.name) parts.push(`(${ellipsis(peer.declared.name, 32)})`)
63
+ if (peer.isSelf) parts.push("(you)")
64
+ parts.push(peer.stale ? `${peer.status}?` : peer.status)
65
+ if (peer.declared?.role) parts.push(`role=${ellipsis(peer.declared.role, 24)}`)
66
+ if (peer.declared?.goal) parts.push(`goal="${ellipsis(peer.declared.goal, 60)}"`)
67
+ // The self view prints role → goal → summary; keep a peer's line in the same
68
+ // order, with the summary's age once it stops being fresh.
69
+ if (peer.declared?.summary) {
70
+ parts.push(`summary="${ellipsis(peer.declared.summary, 60)}"${summaryAge(peer, now)}`)
71
+ }
72
+ if (peer.title) parts.push(`"${ellipsis(peer.title, 48)}"`)
73
+ if (peer.agent) parts.push(peer.agent)
74
+ parts.push(`active ${relativeAge(peer.lastSeen, now)} ago`)
75
+ if (peer.unread > 0) parts.push(`unread ${peer.unread}`)
76
+ if (peer.claims.length > 0) parts.push(`claims ${peer.claims.join(", ")}`)
77
+ if (!peer.addressable) parts.push(notAddressable(peer, scope))
78
+ return parts.join(" ")
79
+ }
80
+
81
+ /** Why a listed peer cannot be messaged, shown on its line. */
82
+ function notAddressable(peer: PeerView, scope: Scope): string {
83
+ return peer.projectID === undefined || peer.directory === undefined
84
+ ? "location unknown — not addressable"
85
+ : `outside the ${scope} scope — not addressable`
86
+ }
87
+
88
+ /**
89
+ * Plugin health for the calling location, rendered as one diagnostic line so
90
+ * install problems (double loads, lost snapshots) are visible to the model
91
+ * without spelunking logs.
92
+ */
93
+ export interface SelfCheck {
94
+ /** Snapshot format this build writes and restores, e.g. `v2`. */
95
+ snapshot: string
96
+ /** Mesh storage key; only meaningful when `persist` is true. */
97
+ storageKey?: string
98
+ /** False when persistence is off: nothing survives a restart. */
99
+ persist: boolean
100
+ /** Locations the live plugin instances in this process own. Absent when unknown. */
101
+ locations?: number
102
+ /** Loads the location guard refused — more than zero means a duplicate install. */
103
+ refused?: number
104
+ }
105
+
106
+ export interface StatusViewInput {
107
+ self: PeerView
108
+ peers: PeerView[]
109
+ scope: Scope
110
+ now: number
111
+ hints?: string[]
112
+ selfCheck?: SelfCheck
113
+ }
114
+
115
+ export function formatStatus(input: StatusViewInput): string {
116
+ const { self, peers, now } = input
117
+ const lines: string[] = []
118
+ const header = [`crosstalk: you are ${self.sessionID}`]
119
+ if (self.declared?.name) header.push(`(${ellipsis(self.declared.name, 32)})`)
120
+ if (self.agent) header.push(`(${self.agent})`)
121
+ header.push(self.stale ? `${self.status}?` : self.status)
122
+ lines.push(header.join(" "))
123
+
124
+ if (self.title) lines.push(` session: ${ellipsis(self.title, 72)}`)
125
+ if (self.declared?.role) lines.push(` role: ${self.declared.role}`)
126
+ if (self.declared?.goal) lines.push(` goal: ${self.declared.goal}`)
127
+ // The own record annotates the summary by the same rule as a peer's line:
128
+ // silent while fresh, explicit once it is worth re-reading before trusting.
129
+ if (self.declared?.summary) {
130
+ lines.push(` summary: ${self.declared.summary}${summaryAge(self, now)}`)
131
+ }
132
+ if (self.declared?.workingOn && self.declared.workingOn.length > 0) {
133
+ lines.push(` working on: ${self.declared.workingOn.join(", ")}`)
134
+ }
135
+ if (self.claims.length > 0) lines.push(` your claims: ${self.claims.join(", ")}`)
136
+
137
+ if (input.selfCheck) {
138
+ const check = input.selfCheck
139
+ const parts = [`snapshot ${check.snapshot}`]
140
+ if (check.persist && check.storageKey !== undefined) parts.push(`storage ${check.storageKey}`)
141
+ if (!check.persist) parts.push("persistence off")
142
+ if (check.locations !== undefined) parts.push(`${check.locations} location${check.locations === 1 ? "" : "s"} owned`)
143
+ if (check.refused !== undefined && check.refused > 0) parts.push(`${check.refused} refused load${check.refused === 1 ? "" : "s"}`)
144
+ lines.push(` self-check: ${parts.join(" · ")}`)
145
+ }
146
+
147
+ const running = peers.filter((p) => p.status === "running").length
148
+ const idle = peers.filter((p) => p.status === "idle").length
149
+ const stale = peers.filter((p) => p.stale).length
150
+ const counts = [`${peers.length} peer${peers.length === 1 ? "" : "s"}`, `${running} running`, `${idle} idle`]
151
+ if (stale > 0) counts.push(`${stale} stale`)
152
+ lines.push(` peers on this channel (${input.scope}): ${counts.join(", ")}`)
153
+ for (const peer of peers) lines.push(` ${peerLine(peer, now, input.scope)}`)
154
+ if (peers.length === 0) {
155
+ lines.push(" (none — you are the only session here; crosstalk_wait will just time out)")
156
+ }
157
+ for (const hint of input.hints ?? []) lines.push(` ${hint}`)
158
+ return lines.join("\n")
159
+ }
160
+
161
+ export interface PeersViewInput {
162
+ self: PeerView
163
+ peers: PeerView[]
164
+ scope: Scope
165
+ now: number
166
+ /**
167
+ * Set when the caller asked for an overlap filter. The `paths` are echoed in
168
+ * the header and in the empty answer, so the model sees what the filter was
169
+ * about; `considered` is how many peers the channel held before the filter
170
+ * narrowed them, which distinguishes "nobody is here" from "nobody overlaps".
171
+ */
172
+ overlap?: { paths: readonly string[]; considered: number }
173
+ }
174
+
175
+ export function formatPeers(input: PeersViewInput): string {
176
+ const { peers, now } = input
177
+ const lines: string[] = []
178
+ // The paths are echoed so an empty answer still says what the filter was about.
179
+ const paths = input.overlap ? ellipsis(input.overlap.paths.join(", "), 120) : ""
180
+ if (peers.length === 0) {
181
+ if (input.overlap) {
182
+ const others = input.overlap.considered
183
+ return [
184
+ `crosstalk: no peers overlap ${paths} (scope ${input.scope}).`,
185
+ others > 0
186
+ ? `${others} other session${others === 1 ? "" : "s"} on this channel, but none leases or works on those exact` +
187
+ " paths — you do not need to claim anything."
188
+ : "Nobody else is on this channel, so nothing can overlap — you do not need to claim anything.",
189
+ ].join("\n")
190
+ }
191
+ return [
192
+ `crosstalk: no other sessions on this channel (scope ${input.scope}).`,
193
+ "Nobody is competing for files right now; you do not need to claim anything.",
194
+ ].join("\n")
195
+ }
196
+ const running = peers.filter((p) => p.status === "running").length
197
+ lines.push(
198
+ `crosstalk peers (scope ${input.scope}, ${peers.length} found, ${running} running${input.overlap ? `, overlap ${paths}` : ""}):`,
199
+ )
200
+ const you = [`you: ${input.self.sessionID}`]
201
+ if (input.self.declared?.name) you.push(`(${ellipsis(input.self.declared.name, 32)})`)
202
+ you.push(input.self.status)
203
+ you.push(input.self.declared?.role ? `role=${input.self.declared.role}` : "role=undeclared")
204
+ lines.push(` ${you.join(" ")}`)
205
+ for (const peer of peers) lines.push(` ${peerLine(peer, now, input.scope)}`)
206
+ return lines.join("\n")
207
+ }
208
+
209
+ export interface MessageViewInput {
210
+ message: CrosstalkMessage
211
+ now: number
212
+ }
213
+
214
+ export function formatMessage(input: MessageViewInput): string {
215
+ const { message, now } = input
216
+ const parts = [`from ${message.from}`]
217
+ if (message.fromName) parts.push(`(${message.fromName})`)
218
+ if (message.fromRole) parts.push(`(${message.fromRole})`)
219
+ parts.push(`· ${message.kind}`)
220
+ if (message.topic) parts.push(`· topic ${message.topic}`)
221
+ parts.push(`· ${relativeAge(message.created, now)} ago`)
222
+ return parts.join(" ")
223
+ }
224
+
225
+ export interface InboxViewInput {
226
+ self: PeerView
227
+ messages: CrosstalkMessage[]
228
+ marked: number
229
+ now: number
230
+ reason?: "messages" | "timeout" | "aborted" | "immediate"
231
+ limit?: number
232
+ /** Unread count observed *before* this call marked anything read. */
233
+ unread?: number
234
+ }
235
+
236
+ export function formatInbox(input: InboxViewInput): string {
237
+ const { messages, now } = input
238
+ const lines: string[] = []
239
+ const unread = input.unread ?? input.self.unread
240
+ const head = `crosstalk inbox for ${input.self.sessionID}: ${messages.length} message${messages.length === 1 ? "" : "s"}`
241
+ if (unread > 0) lines.push(`${head} (${unread} unread)`)
242
+ else lines.push(head)
243
+
244
+ if (messages.length === 0) {
245
+ if (input.reason === "timeout") lines.push(" nothing arrived before the wait expired")
246
+ else if (input.reason === "aborted") lines.push(" wait cancelled before a message arrived")
247
+ else lines.push(" empty")
248
+ return lines.join("\n")
249
+ }
250
+
251
+ for (const message of messages) {
252
+ lines.push(` [${message.id}] ${formatMessage({ message, now })}`)
253
+ for (const line of message.text.split("\n")) lines.push(` ${line}`)
254
+ if (message.kind === "request") {
255
+ lines.push(` ↳ answer with crosstalk_send to ${message.fromName ?? message.from}`)
256
+ }
257
+ }
258
+ if (input.limit !== undefined && messages.length >= input.limit) {
259
+ lines.push(` (showing the most recent ${input.limit}; raise the limit for more)`)
260
+ }
261
+ if (input.marked > 0) lines.push(` marked ${input.marked} message${input.marked === 1 ? "" : "s"} read`)
262
+ return lines.join("\n")
263
+ }
264
+
265
+ export interface SendViewInput {
266
+ self: PeerView
267
+ recipients: Array<{ sessionID: string; delivered: boolean; reason?: string }>
268
+ skipped: Array<{ target: string; reason: string }>
269
+ text: string
270
+ now: number
271
+ }
272
+
273
+ export function formatSend(input: SendViewInput): string {
274
+ const lines: string[] = []
275
+ const delivered = input.recipients.filter((r) => r.delivered).length
276
+ const queued = input.recipients.length - delivered
277
+ const head = `crosstalk: signalled ${input.recipients.length} session${input.recipients.length === 1 ? "" : "s"}`
278
+ lines.push(`${head} (${delivered} delivered now, ${queued} queued in mailbox)`)
279
+
280
+ for (const recipient of input.recipients) {
281
+ lines.push(
282
+ recipient.delivered
283
+ ? ` ✓ ${recipient.sessionID}`
284
+ : ` · ${recipient.sessionID} — queued only${recipient.reason ? `: ${recipient.reason}` : ""}`,
285
+ )
286
+ }
287
+ for (const skip of input.skipped) lines.push(` - ${skip.target}: ${skip.reason}`)
288
+ if (input.skipped.length === 0 && input.recipients.length === 0) {
289
+ lines.push(" no matching peers — check crosstalk_peers, or send with all: true")
290
+ }
291
+ lines.push(` message: ${ellipsis(input.text, 160)}`)
292
+ return lines.join("\n")
293
+ }
294
+
295
+ export interface ClaimViewInput {
296
+ self: PeerView
297
+ outcome: ClaimOutcome
298
+ ttlMs: number
299
+ now: number
300
+ held: Array<{ key: string; expires: number; note?: string }>
301
+ }
302
+
303
+ export function formatClaims(input: ClaimViewInput): string {
304
+ const { outcome, now } = input
305
+ const lines: string[] = []
306
+
307
+ if (outcome.conflicts.length > 0) {
308
+ lines.push(
309
+ `crosstalk claim: refused — ${outcome.conflicts.length} resource${outcome.conflicts.length === 1 ? "" : "s"} already leased`,
310
+ )
311
+ for (const conflict of outcome.conflicts) {
312
+ const who = conflict.holder || "unknown"
313
+ const note = conflict.note ? ` (${conflict.note})` : ""
314
+ const expires = conflict.expires > 0 ? `, free in ${relativeAge(now, conflict.expires)}` : ""
315
+ lines.push(` - ${conflict.raw} held by ${who}${note}${expires}`)
316
+ }
317
+ lines.push(" wait for them with crosstalk_wait, signal them with crosstalk_send, or pass force: true to steal.")
318
+ if (outcome.rolledBack) lines.push(" nothing was claimed (atomic request).")
319
+ return lines.join("\n")
320
+ }
321
+
322
+ const acquired = outcome.claimed.length
323
+ const renewed = outcome.renewed.length
324
+ const released = outcome.released.length
325
+ const stolen = outcome.stolen.length
326
+ const parts: string[] = []
327
+ if (acquired > 0) parts.push(`${acquired} claimed`)
328
+ if (renewed > 0) parts.push(`${renewed} renewed`)
329
+ if (released > 0) parts.push(`${released} released`)
330
+ if (stolen > 0) parts.push(`${stolen} stolen from another session`)
331
+ lines.push(`crosstalk claim: ${parts.length > 0 ? parts.join(", ") : "nothing to do"}`)
332
+
333
+ for (const key of outcome.claimed) lines.push(` + ${key} (lease ${Math.round(input.ttlMs / 1000)}s)`)
334
+ for (const key of outcome.renewed) lines.push(` ~ ${key}`)
335
+ for (const key of outcome.released) lines.push(` - ${key} released`)
336
+
337
+ if (input.held.length > 0) {
338
+ lines.push(" you currently hold:")
339
+ for (const claim of input.held) {
340
+ const note = claim.note ? ` (${claim.note})` : ""
341
+ lines.push(` ${claim.key} expires in ${relativeAge(now, claim.expires)}${note}`)
342
+ }
343
+ } else {
344
+ lines.push(" you hold no leases")
345
+ }
346
+ return lines.join("\n")
347
+ }
348
+
349
+ export interface WaitViewInput {
350
+ forWhat: "peer_idle" | "claim_free"
351
+ reason: WaitReasonLike
352
+ elapsedMs: number
353
+ detail?: string
354
+ }
355
+
356
+ type WaitReasonLike = "done" | "timeout" | "aborted" | "gone" | "outside"
357
+
358
+ export function formatWait(input: WaitViewInput): string {
359
+ const lines: string[] = []
360
+ const what = input.forWhat === "peer_idle" ? "peer idle" : "claim free"
361
+ const seconds = Math.round(input.elapsedMs / 100) / 10
362
+ if (input.reason === "done") {
363
+ lines.push(`crosstalk wait: ${what} after ${seconds}s${input.detail ? ` — ${input.detail}` : ""}`)
364
+ return lines.join("\n")
365
+ }
366
+ if (input.reason === "gone") {
367
+ lines.push(`crosstalk wait: ${input.detail ?? "target is gone"} after ${seconds}s`)
368
+ return lines.join("\n")
369
+ }
370
+ if (input.reason === "outside") {
371
+ lines.push(`crosstalk wait: ${input.detail ?? "target is outside the communication scope"} after ${seconds}s`)
372
+ return lines.join("\n")
373
+ }
374
+ if (input.reason === "aborted") {
375
+ lines.push(`crosstalk wait: cancelled after ${seconds}s while waiting for ${what}`)
376
+ return lines.join("\n")
377
+ }
378
+ lines.push(
379
+ `crosstalk wait: timed out after ${seconds}s waiting for ${what}${input.detail ? ` — ${input.detail}` : ""}`,
380
+ )
381
+ return lines.join("\n")
382
+ }
@@ -0,0 +1,239 @@
1
+ /**
2
+ * @fileoverview Per-session mailboxes with unread tracking and long-poll
3
+ * wakeups.
4
+ *
5
+ * Every send fans out one message copy per recipient, so a broadcast is just a
6
+ * send to several session IDs and each mailbox stays independently readable
7
+ * and ackable. Delivery into a live session is a host concern (see
8
+ * `src/deliverer.ts`); a message that cannot be injected right now still sits
9
+ * in the mailbox and is picked up by the next `crosstalk_inbox`.
10
+ */
11
+
12
+ import type { CrosstalkMessage, Delivery, MessageKind } from "../types.ts"
13
+
14
+ export interface MailboxOptions {
15
+ now(): number
16
+ /** Retained messages per session. */
17
+ maxPerSession: number
18
+ /** Messages older than this are pruned on write and on read. */
19
+ ttlMs: number
20
+ /** Injectable for deterministic IDs in tests. */
21
+ idFactory?(seq: number): string
22
+ }
23
+
24
+ export interface SendInput {
25
+ from: string
26
+ fromName?: string
27
+ fromRole?: string
28
+ to: readonly string[]
29
+ text: string
30
+ topic?: string
31
+ kind?: MessageKind
32
+ delivery?: Delivery
33
+ meta?: Record<string, unknown>
34
+ }
35
+
36
+ export interface ListOptions {
37
+ unreadOnly?: boolean
38
+ topic?: string
39
+ limit?: number
40
+ /** Return messages strictly after this ID. */
41
+ since?: string
42
+ }
43
+
44
+ export type WaitReason = "messages" | "timeout" | "aborted"
45
+
46
+ export interface WaitResult {
47
+ messages: CrosstalkMessage[]
48
+ reason: WaitReason
49
+ }
50
+
51
+ interface Waiter {
52
+ resolve(result: WaitResult): void
53
+ timer: ReturnType<typeof setTimeout> | undefined
54
+ onAbort: (() => void) | undefined
55
+ signal: AbortSignal | undefined
56
+ }
57
+
58
+ export class Mailbox {
59
+ readonly #boxes = new Map<string, CrosstalkMessage[]>()
60
+ readonly #waiters = new Map<string, Set<Waiter>>()
61
+ readonly #now: () => number
62
+ readonly #max: number
63
+ readonly #ttlMs: number
64
+ readonly #idFactory: (seq: number) => string
65
+ #seq = 0
66
+
67
+ constructor(options: MailboxOptions) {
68
+ this.#now = options.now
69
+ this.#max = Math.max(1, options.maxPerSession)
70
+ this.#ttlMs = Math.max(1, options.ttlMs)
71
+ this.#idFactory = options.idFactory ?? ((seq) => `msg-${seq.toString(36)}`)
72
+ }
73
+
74
+ get size(): number {
75
+ let total = 0
76
+ for (const box of this.#boxes.values()) total += box.length
77
+ return total
78
+ }
79
+
80
+ /** Append one copy per recipient and wake anyone blocked on that mailbox. */
81
+ send(input: SendInput): CrosstalkMessage[] {
82
+ const created = this.#now()
83
+ const delivered: CrosstalkMessage[] = []
84
+ for (const to of input.to) {
85
+ this.#seq += 1
86
+ const message: CrosstalkMessage = {
87
+ id: this.#idFactory(this.#seq),
88
+ seq: this.#seq,
89
+ from: input.from,
90
+ fromName: input.fromName,
91
+ fromRole: input.fromRole,
92
+ to,
93
+ text: input.text,
94
+ topic: input.topic,
95
+ kind: input.kind ?? "message",
96
+ delivery: input.delivery ?? "steer",
97
+ created,
98
+ meta: input.meta,
99
+ }
100
+ this.#push(to, message)
101
+ delivered.push(message)
102
+ }
103
+ return delivered
104
+ }
105
+
106
+ /** Messages for a session, newest last. Prunes expired entries first. */
107
+ list(sessionID: string, options: ListOptions = {}): CrosstalkMessage[] {
108
+ this.#pruneBox(sessionID)
109
+ let messages = [...(this.#boxes.get(sessionID) ?? [])]
110
+ if (options.unreadOnly) messages = messages.filter((m) => m.readAt === undefined)
111
+ if (options.topic !== undefined) messages = messages.filter((m) => m.topic === options.topic)
112
+ if (options.since !== undefined) {
113
+ const index = messages.findIndex((m) => m.id === options.since)
114
+ // Unknown cursor: treat as "give me everything" rather than nothing.
115
+ messages = index >= 0 ? messages.slice(index + 1) : messages
116
+ }
117
+ if (options.limit !== undefined && options.limit >= 0 && messages.length > options.limit) {
118
+ messages = messages.slice(messages.length - options.limit)
119
+ }
120
+ return messages
121
+ }
122
+
123
+ unreadCount(sessionID: string): number {
124
+ this.#pruneBox(sessionID)
125
+ return (this.#boxes.get(sessionID) ?? []).filter((m) => m.readAt === undefined).length
126
+ }
127
+
128
+ hasUnread(sessionID: string): boolean {
129
+ return this.unreadCount(sessionID) > 0
130
+ }
131
+
132
+ /**
133
+ * Mark messages read. With no IDs, marks everything currently unread.
134
+ * Returns the number of messages transitioned to read.
135
+ */
136
+ markRead(sessionID: string, ids?: readonly string[]): number {
137
+ const box = this.#boxes.get(sessionID)
138
+ if (!box) return 0
139
+ const wanted = ids ? new Set(ids) : undefined
140
+ const at = this.#now()
141
+ let marked = 0
142
+ for (const message of box) {
143
+ if (message.readAt !== undefined) continue
144
+ if (wanted && !wanted.has(message.id)) continue
145
+ message.readAt = at
146
+ marked += 1
147
+ }
148
+ return marked
149
+ }
150
+
151
+ clear(sessionID: string): void {
152
+ this.#boxes.delete(sessionID)
153
+ }
154
+
155
+ /** Drop expired messages everywhere. Returns how many were removed. */
156
+ prune(): number {
157
+ let removed = 0
158
+ for (const sessionID of [...this.#boxes.keys()]) {
159
+ removed += this.#pruneBox(sessionID)
160
+ }
161
+ return removed
162
+ }
163
+
164
+ /**
165
+ * Resolve as soon as the mailbox holds an unread message, otherwise after
166
+ * `timeoutMs`. Never rejects: an aborted or expired wait resolves with an
167
+ * empty list and the reason, so tool executors can report it plainly.
168
+ */
169
+ async wait(sessionID: string, timeoutMs: number, signal?: AbortSignal): Promise<WaitResult> {
170
+ if (signal?.aborted) return { messages: [], reason: "aborted" }
171
+ const pending = this.list(sessionID, { unreadOnly: true })
172
+ if (pending.length > 0) return { messages: pending, reason: "messages" }
173
+
174
+ return new Promise<WaitResult>((resolve) => {
175
+ const waiter: Waiter = { resolve, timer: undefined, onAbort: undefined, signal }
176
+ const finish = (reason: WaitReason) => {
177
+ const set = this.#waiters.get(sessionID)
178
+ set?.delete(waiter)
179
+ if (set && set.size === 0) this.#waiters.delete(sessionID)
180
+ if (waiter.timer !== undefined) clearTimeout(waiter.timer)
181
+ if (waiter.signal && waiter.onAbort) waiter.signal.removeEventListener("abort", waiter.onAbort)
182
+ resolve({ messages: reason === "messages" ? this.list(sessionID, { unreadOnly: true }) : [], reason })
183
+ }
184
+ waiter.resolve = (result) => finish(result.reason)
185
+
186
+ if (timeoutMs > 0) {
187
+ waiter.timer = setTimeout(() => {
188
+ waiter.resolve({ messages: [], reason: "timeout" })
189
+ }, timeoutMs)
190
+ }
191
+ if (signal) {
192
+ waiter.onAbort = () => waiter.resolve({ messages: [], reason: "aborted" })
193
+ signal.addEventListener("abort", waiter.onAbort, { once: true })
194
+ }
195
+
196
+ const set = this.#waiters.get(sessionID) ?? new Set<Waiter>()
197
+ set.add(waiter)
198
+ this.#waiters.set(sessionID, set)
199
+ })
200
+ }
201
+
202
+ #push(sessionID: string, message: CrosstalkMessage): void {
203
+ const box = this.#boxes.get(sessionID) ?? []
204
+ this.#pruneBox(sessionID)
205
+ box.push(message)
206
+ // Evict the oldest already-read message; fall back to the oldest overall
207
+ // only when everything is unread, so a burst never silently drops mail the
208
+ // agent has not seen.
209
+ const index = box.findIndex((m) => m.readAt !== undefined)
210
+ const at = index >= 0 ? index : 0
211
+ while (box.length > this.#max) box.splice(at, 1)
212
+ this.#boxes.set(sessionID, box)
213
+ this.#wake(sessionID)
214
+ }
215
+
216
+ #wake(sessionID: string): void {
217
+ const set = this.#waiters.get(sessionID)
218
+ if (!set || set.size === 0) return
219
+ for (const waiter of [...set]) {
220
+ waiter.resolve({ messages: [], reason: "messages" })
221
+ }
222
+ }
223
+
224
+ #pruneBox(sessionID: string): number {
225
+ const box = this.#boxes.get(sessionID)
226
+ if (!box || box.length === 0) return 0
227
+ const cutoff = this.#now() - this.#ttlMs
228
+ let removed = 0
229
+ for (let index = box.length - 1; index >= 0; index -= 1) {
230
+ const message = box[index]
231
+ if (message && message.created < cutoff) {
232
+ box.splice(index, 1)
233
+ removed += 1
234
+ }
235
+ }
236
+ if (box.length === 0) this.#boxes.delete(sessionID)
237
+ return removed
238
+ }
239
+ }