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,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_claim` — exclusive, expiring leases on files.
|
|
3
|
+
*
|
|
4
|
+
* This is the tool that makes two agents safe on one repository: a claim is a
|
|
5
|
+
* hard lease, not a note in the conversation, and the default atomic behaviour
|
|
6
|
+
* means a batch request that touches one busy file takes nothing at all.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { formatClaims } from "../core/format.ts"
|
|
10
|
+
import { readBoolean, readEnum, readNumber, readString, readStringArray } from "./args.ts"
|
|
11
|
+
import { jsonSchema, run, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
12
|
+
|
|
13
|
+
export function claimTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
14
|
+
return {
|
|
15
|
+
name: "claim",
|
|
16
|
+
description: [
|
|
17
|
+
"Take an exclusive, expiring lease on files or named resources so another session does not",
|
|
18
|
+
"edit them at the same time. Claim before you edit, release when you are done, renew if you",
|
|
19
|
+
"work longer than the lease. If a file is already leased the request is refused and tells you",
|
|
20
|
+
"who holds it — signal them with crosstalk_send, wait with crosstalk_wait, or pass force: true.",
|
|
21
|
+
"Paths are normalized, and relative paths resolve against this session's directory.",
|
|
22
|
+
"Claim keys are exact: there is no glob expansion, and leasing a directory",
|
|
23
|
+
"does not cover the files inside it — claim each file you edit.",
|
|
24
|
+
].join(" "),
|
|
25
|
+
input: jsonSchema({
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
action: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: ["claim", "renew", "release", "list"],
|
|
31
|
+
description: "Operation. 'list' reports the leases you hold and needs no resources.",
|
|
32
|
+
},
|
|
33
|
+
resources: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: { type: "string" },
|
|
36
|
+
description: "Exact normalized file paths (src/a.ts, src/auth/login.ts) or named resources (db:migrate). No globs: '**' is an ordinary path segment, not a pattern",
|
|
37
|
+
},
|
|
38
|
+
ttlSeconds: { type: "number", description: "Lease duration in seconds (default 300, max 86400)" },
|
|
39
|
+
note: { type: "string", description: "Why you hold it — shown to the peer you block" },
|
|
40
|
+
force: { type: "boolean", description: "Steal a lease held by another session" },
|
|
41
|
+
atomic: {
|
|
42
|
+
type: "boolean",
|
|
43
|
+
description: "On conflict take nothing (default true). false claims whatever is free",
|
|
44
|
+
},
|
|
45
|
+
includeExpired: { type: "boolean", description: "Include lapsed leases in list output" },
|
|
46
|
+
},
|
|
47
|
+
required: ["action"],
|
|
48
|
+
additionalProperties: false,
|
|
49
|
+
}),
|
|
50
|
+
async execute(input, context) {
|
|
51
|
+
return run(() => {
|
|
52
|
+
const action = readEnum(input, "action", ["claim", "renew", "release", "list"] as const, undefined)
|
|
53
|
+
if (action === undefined) {
|
|
54
|
+
return 'crosstalk: "action" is required (claim, renew, release, or list)'
|
|
55
|
+
}
|
|
56
|
+
const resources = readStringArray(input, "resources", { max: 64, maxLength: 400 })
|
|
57
|
+
const ttlSeconds = readNumber(input, "ttlSeconds", { min: 1, max: 86_400 })
|
|
58
|
+
const note = readString(input, "note", { max: 200 })
|
|
59
|
+
const force = readBoolean(input, "force", false)
|
|
60
|
+
const atomic = readBoolean(input, "atomic", true)
|
|
61
|
+
const includeExpired = readBoolean(input, "includeExpired", false)
|
|
62
|
+
|
|
63
|
+
if (action !== "list" && (resources === undefined || resources.length === 0)) {
|
|
64
|
+
return `crosstalk: action "${action}" needs "resources" (use action "list" to see what you hold)`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const result = deps.mesh.claim(context.sessionID, {
|
|
68
|
+
action,
|
|
69
|
+
...(resources ? { resources } : {}),
|
|
70
|
+
...(ttlSeconds !== undefined ? { ttlMs: ttlSeconds * 1000 } : {}),
|
|
71
|
+
...(note ? { note } : {}),
|
|
72
|
+
force,
|
|
73
|
+
atomic,
|
|
74
|
+
includeExpired,
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
return formatClaims({
|
|
78
|
+
self: deps.mesh.view(context.sessionID),
|
|
79
|
+
outcome: result.outcome,
|
|
80
|
+
ttlMs: result.ttlMs,
|
|
81
|
+
now: deps.mesh.now(),
|
|
82
|
+
held: result.held,
|
|
83
|
+
})
|
|
84
|
+
})
|
|
85
|
+
},
|
|
86
|
+
}
|
|
87
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_heartbeat` — send a message to a peer on an interval.
|
|
3
|
+
*
|
|
4
|
+
* Useful for "check in every 30 s" coordination without the caller having to
|
|
5
|
+
* poll. Each started heartbeat is tracked per sender session and can be stopped
|
|
6
|
+
* or listed. Timers are unref'd so they never keep the process alive.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readBoolean, readEnum, readNumber, readString } from "./args.ts"
|
|
10
|
+
import { jsonSchema, run, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
11
|
+
|
|
12
|
+
interface Heartbeat {
|
|
13
|
+
id: string
|
|
14
|
+
sessionID: string
|
|
15
|
+
to?: string
|
|
16
|
+
role?: string
|
|
17
|
+
all?: boolean
|
|
18
|
+
text: string
|
|
19
|
+
topic?: string
|
|
20
|
+
kind?: "message" | "status" | "request" | "answer" | "system"
|
|
21
|
+
intervalMs: number
|
|
22
|
+
count: number // 0 = forever
|
|
23
|
+
sent: number
|
|
24
|
+
timer: ReturnType<typeof setInterval>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const heartbeats = new Map<string, Heartbeat>()
|
|
28
|
+
let seq = 0
|
|
29
|
+
|
|
30
|
+
export function heartbeatTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
31
|
+
return {
|
|
32
|
+
name: "heartbeat",
|
|
33
|
+
description: [
|
|
34
|
+
"Send a message to another session on a repeating interval. Use this for periodic",
|
|
35
|
+
"check-ins (e.g. \"report status every 30 s\") without polling. Actions: 'start' begins a",
|
|
36
|
+
"heartbeat, 'stop' cancels one by id, 'list' shows this session's active heartbeats.",
|
|
37
|
+
"Timers are unref'd and stop automatically when the session ends.",
|
|
38
|
+
].join(" "),
|
|
39
|
+
input: jsonSchema({
|
|
40
|
+
type: "object",
|
|
41
|
+
properties: {
|
|
42
|
+
action: {
|
|
43
|
+
type: "string",
|
|
44
|
+
enum: ["start", "stop", "list"],
|
|
45
|
+
description: "What to do. Defaults to 'start'.",
|
|
46
|
+
},
|
|
47
|
+
id: { type: "string", description: "Heartbeat id (for 'stop')." },
|
|
48
|
+
to: { type: "string", description: "Recipient session id or declared name." },
|
|
49
|
+
role: { type: "string", description: "Send to every peer with this role." },
|
|
50
|
+
all: { type: "boolean", description: "Send to every peer." },
|
|
51
|
+
text: { type: "string", description: "Message body." },
|
|
52
|
+
topic: { type: "string", description: "Optional thread label." },
|
|
53
|
+
kind: {
|
|
54
|
+
type: "string",
|
|
55
|
+
enum: ["message", "status", "request", "answer", "system"],
|
|
56
|
+
description: "Intent.",
|
|
57
|
+
},
|
|
58
|
+
intervalSeconds: {
|
|
59
|
+
type: "number",
|
|
60
|
+
description: "Seconds between sends. Min 5, max 3600. Defaults to 30.",
|
|
61
|
+
},
|
|
62
|
+
count: {
|
|
63
|
+
type: "number",
|
|
64
|
+
description: "How many times to send. 0 = forever (until stopped). Defaults to 0.",
|
|
65
|
+
},
|
|
66
|
+
},
|
|
67
|
+
required: [],
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
}),
|
|
70
|
+
async execute(input, context) {
|
|
71
|
+
return run(async () => {
|
|
72
|
+
const action = readEnum(input, "action", ["start", "stop", "list"] as const, "start")
|
|
73
|
+
const sessionID = context.sessionID
|
|
74
|
+
|
|
75
|
+
if (action === "list") {
|
|
76
|
+
const mine = [...heartbeats.values()].filter((h) => h.sessionID === sessionID)
|
|
77
|
+
if (mine.length === 0) return "crosstalk: no active heartbeats"
|
|
78
|
+
return "crosstalk: active heartbeats:\n" + mine
|
|
79
|
+
.map((h) => ` ${h.id} every ${h.intervalMs / 1000}s sent ${h.sent}/${h.count || "∞"} to=${h.to ?? h.role ?? (h.all ? "all" : "?")}`)
|
|
80
|
+
.join("\n")
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
if (action === "stop") {
|
|
84
|
+
const id = readString(input, "id", { required: true, max: 64 })
|
|
85
|
+
const h = heartbeats.get(id)
|
|
86
|
+
if (!h || h.sessionID !== sessionID)
|
|
87
|
+
return `crosstalk: no heartbeat '${id}' owned by this session`
|
|
88
|
+
clearInterval(h.timer)
|
|
89
|
+
heartbeats.delete(id)
|
|
90
|
+
return `crosstalk: stopped heartbeat ${id} (sent ${h.sent})`
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// start
|
|
94
|
+
const text = readString(input, "text", { required: true, max: 8_000 })
|
|
95
|
+
const to = readString(input, "to", { max: 200 })
|
|
96
|
+
const role = readString(input, "role", { max: 64 })
|
|
97
|
+
const all = readBoolean(input, "all", false)
|
|
98
|
+
const topic = readString(input, "topic", { max: 80 })
|
|
99
|
+
const kind = readEnum(input, "kind", ["message", "status", "request", "answer", "system"] as const, "message")
|
|
100
|
+
if (!to && !role && !all)
|
|
101
|
+
return 'crosstalk: give one of "to", "role", or "all": true'
|
|
102
|
+
const intervalSeconds = Math.max(5, Math.min(3600, readNumber(input, "intervalSeconds", { fallback: 30 }) ?? 30))
|
|
103
|
+
const count = Math.max(0, Math.floor(readNumber(input, "count", { fallback: 0 }) ?? 0))
|
|
104
|
+
const intervalMs = intervalSeconds * 1000
|
|
105
|
+
|
|
106
|
+
const id = `hb-${++seq}`
|
|
107
|
+
let sent = 0
|
|
108
|
+
|
|
109
|
+
const sendOne = async () => {
|
|
110
|
+
sent++
|
|
111
|
+
const h = heartbeats.get(id)
|
|
112
|
+
if (!h) return
|
|
113
|
+
try {
|
|
114
|
+
await deps.mesh.send(sessionID, {
|
|
115
|
+
text,
|
|
116
|
+
...(to ? { to } : {}),
|
|
117
|
+
...(role ? { role } : {}),
|
|
118
|
+
...(all ? { all: true } : {}),
|
|
119
|
+
...(topic ? { topic } : {}),
|
|
120
|
+
...(kind ? { kind } : {}),
|
|
121
|
+
}, new AbortController().signal)
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore delivery failures; the next tick retries
|
|
124
|
+
}
|
|
125
|
+
h.sent = sent
|
|
126
|
+
if (count > 0 && sent >= count) {
|
|
127
|
+
clearInterval(h.timer)
|
|
128
|
+
heartbeats.delete(id)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const timer = setInterval(() => { void sendOne() }, intervalMs)
|
|
133
|
+
timer.unref?.()
|
|
134
|
+
|
|
135
|
+
const h: Heartbeat = {
|
|
136
|
+
id, sessionID, text, intervalMs, count, sent: 0, timer,
|
|
137
|
+
...(to ? { to } : {}),
|
|
138
|
+
...(role ? { role } : {}),
|
|
139
|
+
...(all ? { all: true } : {}),
|
|
140
|
+
...(topic ? { topic } : {}),
|
|
141
|
+
...(kind ? { kind } : {}),
|
|
142
|
+
}
|
|
143
|
+
heartbeats.set(id, h)
|
|
144
|
+
|
|
145
|
+
// Send the first one immediately.
|
|
146
|
+
void sendOne()
|
|
147
|
+
|
|
148
|
+
return `crosstalk: started heartbeat ${id} (every ${intervalSeconds}s, ${count || "∞"} sends) to ${to || role || "all"}`
|
|
149
|
+
})
|
|
150
|
+
},
|
|
151
|
+
}
|
|
152
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_inbox` — read and acknowledge mail from peers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatInbox } from "../core/format.ts"
|
|
6
|
+
import { readBoolean, readNumber, readString } from "./args.ts"
|
|
7
|
+
import { clampWait, jsonSchema, run, startHeartbeat, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
export function inboxTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
10
|
+
return {
|
|
11
|
+
name: "inbox",
|
|
12
|
+
description: [
|
|
13
|
+
"Read messages other sessions sent you. Set wait to block until something arrives (or the",
|
|
14
|
+
"timeout expires) instead of returning an empty inbox — useful when you are waiting on a peer",
|
|
15
|
+
"to finish. Messages are marked read unless you pass markRead: false.",
|
|
16
|
+
].join(" "),
|
|
17
|
+
input: jsonSchema({
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
wait: {
|
|
21
|
+
type: "number",
|
|
22
|
+
description: "Seconds to block for a new message. 0 returns immediately. Capped by plugin config.",
|
|
23
|
+
},
|
|
24
|
+
limit: { type: "number", description: "Maximum messages to return (most recent, default 20)" },
|
|
25
|
+
topic: { type: "string", description: "Only messages with this topic" },
|
|
26
|
+
unreadOnly: { type: "boolean", description: "Skip messages you already read" },
|
|
27
|
+
since: { type: "string", description: "Return only messages after this message ID" },
|
|
28
|
+
markRead: { type: "boolean", description: "Mark the returned messages read (default true)" },
|
|
29
|
+
},
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
}),
|
|
32
|
+
async execute(input, context) {
|
|
33
|
+
return run(async () => {
|
|
34
|
+
const waitSeconds = readNumber(input, "wait", { min: 0, max: 600, fallback: 0 })
|
|
35
|
+
const limit = readNumber(input, "limit", { min: 1, max: 200, fallback: 20 })
|
|
36
|
+
const topic = readString(input, "topic", { max: 80 })
|
|
37
|
+
const unreadOnly = readBoolean(input, "unreadOnly", false)
|
|
38
|
+
const since = readString(input, "since", { max: 80 })
|
|
39
|
+
const markRead = readBoolean(input, "markRead", true)
|
|
40
|
+
const waitMs = clampWait((waitSeconds ?? 0) * 1000, deps.options)
|
|
41
|
+
|
|
42
|
+
const stop = startHeartbeat(context, deps.options.heartbeatMs, () => ({
|
|
43
|
+
status: "waiting",
|
|
44
|
+
waitSeconds: Math.round(waitMs / 1000),
|
|
45
|
+
}))
|
|
46
|
+
let outcome
|
|
47
|
+
try {
|
|
48
|
+
outcome = await deps.mesh.inbox(
|
|
49
|
+
context.sessionID,
|
|
50
|
+
{
|
|
51
|
+
waitMs,
|
|
52
|
+
markRead,
|
|
53
|
+
limit,
|
|
54
|
+
...(unreadOnly ? { unreadOnly: true } : {}),
|
|
55
|
+
...(topic ? { topic } : {}),
|
|
56
|
+
...(since ? { since } : {}),
|
|
57
|
+
},
|
|
58
|
+
context.signal,
|
|
59
|
+
)
|
|
60
|
+
} finally {
|
|
61
|
+
stop()
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return formatInbox({
|
|
65
|
+
self: deps.mesh.view(context.sessionID),
|
|
66
|
+
messages: outcome.messages,
|
|
67
|
+
marked: outcome.marked,
|
|
68
|
+
unread: outcome.unreadBefore,
|
|
69
|
+
now: deps.mesh.now(),
|
|
70
|
+
reason: outcome.reason,
|
|
71
|
+
...(limit !== undefined ? { limit } : {}),
|
|
72
|
+
})
|
|
73
|
+
})
|
|
74
|
+
},
|
|
75
|
+
}
|
|
76
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Tool registration.
|
|
3
|
+
*
|
|
4
|
+
* All seven tools share one namespace and one permission action, so a project can
|
|
5
|
+
* allow or deny coordination with a single rule.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { ToolEditor } from "@opencode/plugin/promise/tool"
|
|
9
|
+
import { claimTool } from "./claim.ts"
|
|
10
|
+
import { heartbeatTool } from "./heartbeat.ts"
|
|
11
|
+
import { inboxTool } from "./inbox.ts"
|
|
12
|
+
import { peersTool } from "./peers.ts"
|
|
13
|
+
import { sendTool } from "./send.ts"
|
|
14
|
+
import { statusTool } from "./status.ts"
|
|
15
|
+
import { waitTool } from "./wait.ts"
|
|
16
|
+
import type { CrosstalkToolInfo, ToolDeps } from "./types.ts"
|
|
17
|
+
|
|
18
|
+
export const NAMESPACE_DESCRIPTION =
|
|
19
|
+
"Coordinate with the other OpenCode sessions on this project: discover peers, exchange messages, " +
|
|
20
|
+
"and lease files so two agents never edit the same path at once. Start with status, then peers."
|
|
21
|
+
|
|
22
|
+
/** Tool factories in registration order. */
|
|
23
|
+
export const toolFactories: Array<(deps: ToolDeps) => CrosstalkToolInfo> = [
|
|
24
|
+
statusTool,
|
|
25
|
+
peersTool,
|
|
26
|
+
sendTool,
|
|
27
|
+
inboxTool,
|
|
28
|
+
claimTool,
|
|
29
|
+
heartbeatTool,
|
|
30
|
+
waitTool,
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Register the namespace and every tool. Must run inside
|
|
35
|
+
* `ctx.tool.transform`, whose callback has to stay synchronous and cheap.
|
|
36
|
+
*
|
|
37
|
+
* Every executor is wrapped to retry queued injections first, so a message
|
|
38
|
+
* whose delivery failed — most likely because the recipient's session belongs
|
|
39
|
+
* to another location — still reaches its turn the moment that session next
|
|
40
|
+
* calls any crosstalk tool.
|
|
41
|
+
*/
|
|
42
|
+
export function registerTools(editor: ToolEditor, deps: ToolDeps): void {
|
|
43
|
+
editor.namespace({ name: deps.options.namespace, description: NAMESPACE_DESCRIPTION })
|
|
44
|
+
for (const factory of toolFactories) {
|
|
45
|
+
const tool = factory(deps)
|
|
46
|
+
const execute = tool.execute
|
|
47
|
+
editor.add({
|
|
48
|
+
...tool,
|
|
49
|
+
execute: async (input, context) => {
|
|
50
|
+
await deps.mesh.flushPendingInjection(context.sessionID, context.signal)
|
|
51
|
+
return execute(input, context)
|
|
52
|
+
},
|
|
53
|
+
options: {
|
|
54
|
+
namespace: deps.options.namespace,
|
|
55
|
+
permission: deps.options.permission,
|
|
56
|
+
codemode: deps.options.codemode,
|
|
57
|
+
},
|
|
58
|
+
})
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export { claimTool, heartbeatTool, inboxTool, peersTool, sendTool, statusTool, waitTool }
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_peers` — who else is running right now.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatPeers } from "../core/format.ts"
|
|
6
|
+
import { normalizeResource } from "../core/claims.ts"
|
|
7
|
+
import type { PeerView } from "../types.ts"
|
|
8
|
+
import { readBoolean, readEnum, readNumber, readStringArray } from "./args.ts"
|
|
9
|
+
import { jsonSchema, run, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Which peers touch any of `paths`: a session holding a lease on the exact key,
|
|
13
|
+
* or one that listed it in `workingOn`. Both sides are normalized to absolute
|
|
14
|
+
* keys — the caller's paths against the caller's directory, a peer's
|
|
15
|
+
* `workingOn` against the peer's own — so `src/auth.ts` typed by a session in
|
|
16
|
+
* another worktree does not match this one until it provably is the same file.
|
|
17
|
+
*
|
|
18
|
+
* Keys are exact, like every claim key: asking about a directory never matches
|
|
19
|
+
* the files inside it, and `**` is an ordinary path segment. An empty `paths`
|
|
20
|
+
* list matches every peer, which is "no filter" expressed as data.
|
|
21
|
+
*/
|
|
22
|
+
export function overlappingPeers(
|
|
23
|
+
peers: readonly PeerView[],
|
|
24
|
+
paths: readonly string[],
|
|
25
|
+
baseDir?: string,
|
|
26
|
+
): PeerView[] {
|
|
27
|
+
const wanted = paths.map((path) => normalizeResource(path, baseDir)).filter((key) => key !== "")
|
|
28
|
+
if (wanted.length === 0) return [...peers]
|
|
29
|
+
const claimed = new Set(wanted)
|
|
30
|
+
return peers.filter(
|
|
31
|
+
(peer) =>
|
|
32
|
+
peer.claims.some((claim) => claimed.has(claim)) ||
|
|
33
|
+
(peer.declared?.workingOn ?? []).some((entry) => claimed.has(normalizeResource(entry, peer.directory))),
|
|
34
|
+
)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function peersTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
38
|
+
return {
|
|
39
|
+
name: "peers",
|
|
40
|
+
description: [
|
|
41
|
+
"List the other OpenCode sessions on this channel with their liveness, what they declared",
|
|
42
|
+
"they are working on, unread mail counts, and the files they have leased.",
|
|
43
|
+
"Use this before editing shared code, and to find out which session to signal.",
|
|
44
|
+
'Pass "overlap" with exact paths to see only the peers whose leases or workingOn cover them.',
|
|
45
|
+
].join(" "),
|
|
46
|
+
input: jsonSchema({
|
|
47
|
+
type: "object",
|
|
48
|
+
properties: {
|
|
49
|
+
status: {
|
|
50
|
+
type: "string",
|
|
51
|
+
enum: ["all", "running", "idle"],
|
|
52
|
+
description: "Filter by liveness. 'all' (default) lists everyone the mesh knows.",
|
|
53
|
+
},
|
|
54
|
+
scope: {
|
|
55
|
+
type: "string",
|
|
56
|
+
enum: ["project", "location", "server"],
|
|
57
|
+
description: "Widen or narrow visibility for this call. Defaults to the configured scope.",
|
|
58
|
+
},
|
|
59
|
+
overlap: {
|
|
60
|
+
type: "array",
|
|
61
|
+
items: { type: "string" },
|
|
62
|
+
description:
|
|
63
|
+
"Only peers whose leases or workingOn cover one of these exact paths — no globs, relative paths resolve against your directory",
|
|
64
|
+
},
|
|
65
|
+
includeSelf: { type: "boolean", description: "Include this session in the list" },
|
|
66
|
+
limit: { type: "number", description: "Maximum peers to return (default 50)" },
|
|
67
|
+
},
|
|
68
|
+
additionalProperties: false,
|
|
69
|
+
}),
|
|
70
|
+
async execute(input, context) {
|
|
71
|
+
return run(() => {
|
|
72
|
+
const status = readEnum(input, "status", ["all", "running", "idle"] as const, "all")
|
|
73
|
+
const scope =
|
|
74
|
+
readEnum(input, "scope", ["project", "location", "server"] as const, deps.options.scope) ??
|
|
75
|
+
deps.options.scope
|
|
76
|
+
const overlap = readStringArray(input, "overlap", { max: 32, maxLength: 200 })
|
|
77
|
+
const includeSelf = readBoolean(input, "includeSelf", false)
|
|
78
|
+
const limit = readNumber(input, "limit", { min: 1, max: 500, fallback: 50 })
|
|
79
|
+
|
|
80
|
+
const known = deps.mesh.peers(context.sessionID, {
|
|
81
|
+
status,
|
|
82
|
+
scope,
|
|
83
|
+
includeSelf,
|
|
84
|
+
...(limit !== undefined ? { limit } : {}),
|
|
85
|
+
})
|
|
86
|
+
// An empty list is "no filter", not "match nothing".
|
|
87
|
+
const asked = overlap && overlap.length > 0 ? overlap : undefined
|
|
88
|
+
const peers = asked
|
|
89
|
+
? overlappingPeers(known, asked, deps.mesh.directoryOf(context.sessionID))
|
|
90
|
+
: known
|
|
91
|
+
return formatPeers({
|
|
92
|
+
self: deps.mesh.view(context.sessionID),
|
|
93
|
+
peers,
|
|
94
|
+
scope,
|
|
95
|
+
now: deps.mesh.now(),
|
|
96
|
+
...(asked ? { overlap: { paths: asked, considered: known.length } } : {}),
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
},
|
|
100
|
+
}
|
|
101
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview `crosstalk_send` — talk to another session.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { formatSend } from "../core/format.ts"
|
|
6
|
+
import { readBoolean, readEnum, readString } from "./args.ts"
|
|
7
|
+
import { jsonSchema, run, type CrosstalkToolInfo, type ToolDeps } from "./types.ts"
|
|
8
|
+
|
|
9
|
+
export function sendTool(deps: ToolDeps): CrosstalkToolInfo {
|
|
10
|
+
return {
|
|
11
|
+
name: "send",
|
|
12
|
+
description: [
|
|
13
|
+
"Send a message to another OpenCode session on this channel. The message is stored in the",
|
|
14
|
+
"recipient's mailbox and, when possible, injected into their running session so they see it",
|
|
15
|
+
"without waiting. Address it with a session id or a peer's declared human name (crosstalk_peers",
|
|
16
|
+
"shows both), to everyone with a declared role, or to all peers. Only sessions in this project",
|
|
17
|
+
"are reachable — peers marked \"not addressable\" are refused.",
|
|
18
|
+
"Give one reason per message and say what you need back; the recipient replies with crosstalk_send.",
|
|
19
|
+
].join(" "),
|
|
20
|
+
input: jsonSchema({
|
|
21
|
+
type: "object",
|
|
22
|
+
properties: {
|
|
23
|
+
to: { type: "string", description: "Session ID or declared human name of the recipient (see crosstalk_peers)" },
|
|
24
|
+
role: { type: "string", description: "Send to every peer that declared this role" },
|
|
25
|
+
all: { type: "boolean", description: "Send to every peer on this channel" },
|
|
26
|
+
text: { type: "string", description: "The message body. Be specific about what you need." },
|
|
27
|
+
topic: { type: "string", description: "Optional thread label, e.g. auth-refactor" },
|
|
28
|
+
kind: {
|
|
29
|
+
type: "string",
|
|
30
|
+
enum: ["message", "status", "request", "answer", "system"],
|
|
31
|
+
description: "Intent. 'request' expects a reply; 'answer' is a response to one.",
|
|
32
|
+
},
|
|
33
|
+
delivery: {
|
|
34
|
+
type: "string",
|
|
35
|
+
enum: ["steer", "queue"],
|
|
36
|
+
description: "'steer' (default) folds into the recipient's current turn; 'queue' waits for it to end",
|
|
37
|
+
},
|
|
38
|
+
replyTo: { type: "string", description: "Message ID this answers" },
|
|
39
|
+
},
|
|
40
|
+
required: ["text"],
|
|
41
|
+
additionalProperties: false,
|
|
42
|
+
}),
|
|
43
|
+
async execute(input, context) {
|
|
44
|
+
return run(async () => {
|
|
45
|
+
const text = readString(input, "text", { required: true, max: 8_000 })
|
|
46
|
+
const to = readString(input, "to", { max: 200 })
|
|
47
|
+
const role = readString(input, "role", { max: 64 })
|
|
48
|
+
const all = readBoolean(input, "all", false)
|
|
49
|
+
const topic = readString(input, "topic", { max: 80 })
|
|
50
|
+
const kind = readEnum(input, "kind", ["message", "status", "request", "answer", "system"] as const, "message")
|
|
51
|
+
const delivery = readEnum(input, "delivery", ["steer", "queue"] as const, "steer")
|
|
52
|
+
const replyTo = readString(input, "replyTo", { max: 80 })
|
|
53
|
+
|
|
54
|
+
// `all` defaults to false rather than undefined, so test the values.
|
|
55
|
+
if (!to && !role && !all) {
|
|
56
|
+
return 'crosstalk: give one of "to" (session id or name), "role", or "all": true'
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const outcome = await deps.mesh.send(
|
|
60
|
+
context.sessionID,
|
|
61
|
+
{
|
|
62
|
+
text,
|
|
63
|
+
...(to ? { to } : {}),
|
|
64
|
+
...(role ? { role } : {}),
|
|
65
|
+
...(all ? { all: true } : {}),
|
|
66
|
+
...(topic ? { topic } : {}),
|
|
67
|
+
...(kind ? { kind } : {}),
|
|
68
|
+
...(delivery ? { delivery } : {}),
|
|
69
|
+
...(replyTo ? { replyTo } : {}),
|
|
70
|
+
},
|
|
71
|
+
context.signal,
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
return formatSend({
|
|
75
|
+
self: deps.mesh.view(context.sessionID),
|
|
76
|
+
recipients: outcome.recipients,
|
|
77
|
+
skipped: outcome.skipped,
|
|
78
|
+
text,
|
|
79
|
+
now: deps.mesh.now(),
|
|
80
|
+
})
|
|
81
|
+
})
|
|
82
|
+
},
|
|
83
|
+
}
|
|
84
|
+
}
|