opencode-crosstalk 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +460 -2
- package/assets/CursorGuard.png +0 -0
- package/assets/Wren.png +0 -0
- package/assets/avatars/ATTRIBUTION.md +18 -0
- package/assets/avatars/female/01-explorer-eeb4a4.png +0 -0
- package/assets/avatars/female/02-manager-b16a5b.png +0 -0
- package/assets/avatars/female/02-manager-e7a391.png +0 -0
- package/assets/avatars/female/03-coder-92594b.png +0 -0
- package/assets/avatars/female/03-coder-e5a07e.png +0 -0
- package/assets/avatars/female/05-analyst-b16a5b.png +0 -0
- package/assets/avatars/female/05-analyst-eeb4a4.png +0 -0
- package/assets/avatars/female/08-tester-d78774.png +0 -0
- package/assets/avatars/female/08-tester-eeb4a4.png +0 -0
- package/assets/avatars/female/10-builder-92594b.png +0 -0
- package/assets/avatars/female/10-builder-e5a07e.png +0 -0
- package/assets/avatars/female/13-researcher-92594b.png +0 -0
- package/assets/avatars/female/13-researcher-e7a391.png +0 -0
- package/assets/avatars/female/14-writer-623d36.png +0 -0
- package/assets/avatars/female/14-writer-e5a07e.png +0 -0
- package/assets/avatars/female/16-fixer-e7a391.png +0 -0
- package/assets/avatars/female/17-deployer-92594b.png +0 -0
- package/assets/avatars/female/17-deployer-e5a07e.png +0 -0
- package/assets/avatars/female/23-mentor-b16a5b.png +0 -0
- package/assets/avatars/female/23-mentor-e7a391.png +0 -0
- package/assets/avatars/female/25-pioneer-d78774.png +0 -0
- package/assets/avatars/female/26-strategist-b16a5b.png +0 -0
- package/assets/avatars/female/31-gardener-e5a07e.png +0 -0
- package/assets/avatars/female/33-simplifier-b16a5b.png +0 -0
- package/assets/avatars/female/33-simplifier-eeb4a4.png +0 -0
- package/assets/avatars/female/34-optimizer-92594b.png +0 -0
- package/assets/avatars/female/34-optimizer-e7a391.png +0 -0
- package/assets/avatars/female/36-integrator-d78774.png +0 -0
- package/assets/avatars/female/36-integrator-eeb4a4.png +0 -0
- package/assets/avatars/female/37-compiler-b16a5b.png +0 -0
- package/assets/avatars/female/37-compiler-e7a391.png +0 -0
- package/assets/avatars/female/40-formatter-b16a5b.png +0 -0
- package/assets/avatars/female/42-shipper-623d36.png +0 -0
- package/assets/avatars/female/46-steward-623d36.png +0 -0
- package/assets/avatars/female/46-steward-d78774.png +0 -0
- package/assets/avatars/female/47-shepherd-b16a5b.png +0 -0
- package/assets/avatars/female/47-shepherd-eeb4a4.png +0 -0
- package/assets/avatars/female/48-tinkerer-92594b.png +0 -0
- package/assets/avatars/female/50-wrangler-eeb4a4.png +0 -0
- package/assets/avatars/index.html +4 -0
- package/assets/avatars/male/01-explorer-d78774.png +0 -0
- package/assets/avatars/male/06-planner-92594b.png +0 -0
- package/assets/avatars/male/06-planner-e7a391.png +0 -0
- package/assets/avatars/male/07-reviewer-623d36.png +0 -0
- package/assets/avatars/male/07-reviewer-e5a07e.png +0 -0
- package/assets/avatars/male/09-migrator-b16a5b.png +0 -0
- package/assets/avatars/male/09-migrator-e7a391.png +0 -0
- package/assets/avatars/male/11-debugger-623d36.png +0 -0
- package/assets/avatars/male/11-debugger-d78774.png +0 -0
- package/assets/avatars/male/12-designer-b16a5b.png +0 -0
- package/assets/avatars/male/12-designer-eeb4a4.png +0 -0
- package/assets/avatars/male/15-editor-d78774.png +0 -0
- package/assets/avatars/male/15-editor-eeb4a4.png +0 -0
- package/assets/avatars/male/16-fixer-b16a5b.png +0 -0
- package/assets/avatars/male/18-scout-623d36.png +0 -0
- package/assets/avatars/male/18-scout-d78774.png +0 -0
- package/assets/avatars/male/19-architect-b16a5b.png +0 -0
- package/assets/avatars/male/19-architect-eeb4a4.png +0 -0
- package/assets/avatars/male/20-operator-92594b.png +0 -0
- package/assets/avatars/male/20-operator-e7a391.png +0 -0
- package/assets/avatars/male/21-coordinator-623d36.png +0 -0
- package/assets/avatars/male/21-coordinator-e5a07e.png +0 -0
- package/assets/avatars/male/22-auditor-d78774.png +0 -0
- package/assets/avatars/male/22-auditor-eeb4a4.png +0 -0
- package/assets/avatars/male/25-pioneer-623d36.png +0 -0
- package/assets/avatars/male/26-strategist-eeb4a4.png +0 -0
- package/assets/avatars/male/28-librarian-623d36.png +0 -0
- package/assets/avatars/male/28-librarian-e5a07e.png +0 -0
- package/assets/avatars/male/30-tracker-b16a5b.png +0 -0
- package/assets/avatars/male/30-tracker-e7a391.png +0 -0
- package/assets/avatars/male/31-gardener-92594b.png +0 -0
- package/assets/avatars/male/32-refactorer-623d36.png +0 -0
- package/assets/avatars/male/32-refactorer-d78774.png +0 -0
- package/assets/avatars/male/35-profiler-623d36.png +0 -0
- package/assets/avatars/male/35-profiler-e5a07e.png +0 -0
- package/assets/avatars/male/38-parser-92594b.png +0 -0
- package/assets/avatars/male/38-parser-e5a07e.png +0 -0
- package/assets/avatars/male/39-linter-623d36.png +0 -0
- package/assets/avatars/male/39-linter-d78774.png +0 -0
- package/assets/avatars/male/40-formatter-eeb4a4.png +0 -0
- package/assets/avatars/male/41-patcher-92594b.png +0 -0
- package/assets/avatars/male/41-patcher-e7a391.png +0 -0
- package/assets/avatars/male/42-shipper-e5a07e.png +0 -0
- package/assets/avatars/male/43-maintainer-d78774.png +0 -0
- package/assets/avatars/male/43-maintainer-eeb4a4.png +0 -0
- package/assets/avatars/male/44-guardian-b16a5b.png +0 -0
- package/assets/avatars/male/44-guardian-e7a391.png +0 -0
- package/assets/avatars/male/45-sentinel-92594b.png +0 -0
- package/assets/avatars/male/45-sentinel-e5a07e.png +0 -0
- package/assets/avatars/male/48-tinkerer-e7a391.png +0 -0
- package/assets/avatars/male/49-hacker-623d36.png +0 -0
- package/assets/avatars/male/49-hacker-e5a07e.png +0 -0
- package/assets/avatars/male/50-wrangler-d78774.png +0 -0
- package/assets/avatars/manifest.json +1202 -0
- package/index.ts +11 -0
- package/package.json +65 -4
- package/src/config.ts +176 -0
- package/src/core/avatar.ts +92 -0
- package/src/core/claims.ts +302 -0
- package/src/core/clock.ts +31 -0
- package/src/core/directory.ts +48 -0
- package/src/core/format.ts +382 -0
- package/src/core/mailbox.ts +239 -0
- package/src/core/mesh.ts +781 -0
- package/src/core/registry.ts +306 -0
- package/src/deliverer.ts +86 -0
- package/src/events.ts +66 -0
- package/src/index.ts +323 -0
- package/src/rpc.ts +52 -0
- package/src/storage.ts +105 -0
- package/src/tools/args.ts +132 -0
- package/src/tools/claim.ts +87 -0
- package/src/tools/heartbeat.ts +152 -0
- package/src/tools/inbox.ts +76 -0
- package/src/tools/index.ts +62 -0
- package/src/tools/peers.ts +101 -0
- package/src/tools/send.ts +84 -0
- package/src/tools/status.ts +137 -0
- package/src/tools/types.ts +72 -0
- package/src/tools/wait.ts +96 -0
- package/src/tui/avatar.ts +188 -0
- package/src/tui/index.tsx +216 -0
- package/src/types.ts +147 -0
- package/tui.ts +8 -0
|
@@ -0,0 +1,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
|
+
}
|