opencode-collaboration 0.2.3

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 (47) hide show
  1. package/LICENSE +194 -0
  2. package/README.md +224 -0
  3. package/README.zh-CN.md +224 -0
  4. package/commands/list-agents.md +8 -0
  5. package/commands/peers-inbox.md +8 -0
  6. package/commands/peers-name.md +8 -0
  7. package/commands/peers-outbox.md +8 -0
  8. package/commands/peers.md +8 -0
  9. package/dist/commands.d.ts +29 -0
  10. package/dist/commands.js +95 -0
  11. package/dist/config.d.ts +31 -0
  12. package/dist/config.js +50 -0
  13. package/dist/delivery.d.ts +42 -0
  14. package/dist/delivery.js +177 -0
  15. package/dist/feedback.d.ts +8 -0
  16. package/dist/feedback.js +40 -0
  17. package/dist/format.d.ts +32 -0
  18. package/dist/format.js +107 -0
  19. package/dist/gating.d.ts +4 -0
  20. package/dist/gating.js +16 -0
  21. package/dist/index.d.ts +43 -0
  22. package/dist/index.js +410 -0
  23. package/dist/listener.d.ts +37 -0
  24. package/dist/listener.js +335 -0
  25. package/dist/outbox.d.ts +12 -0
  26. package/dist/outbox.js +110 -0
  27. package/dist/permissions.d.ts +47 -0
  28. package/dist/permissions.js +194 -0
  29. package/dist/queue.d.ts +89 -0
  30. package/dist/queue.js +824 -0
  31. package/dist/registry.d.ts +70 -0
  32. package/dist/registry.js +308 -0
  33. package/dist/sender.d.ts +27 -0
  34. package/dist/sender.js +139 -0
  35. package/dist/session-runtime.d.ts +40 -0
  36. package/dist/session-runtime.js +355 -0
  37. package/dist/session-tracker.d.ts +16 -0
  38. package/dist/session-tracker.js +39 -0
  39. package/dist/tools/peers-tools.d.ts +26 -0
  40. package/dist/tools/peers-tools.js +173 -0
  41. package/dist/transport.d.ts +20 -0
  42. package/dist/transport.js +46 -0
  43. package/dist/tui.d.ts +3 -0
  44. package/dist/tui.js +228 -0
  45. package/dist/types.d.ts +162 -0
  46. package/dist/types.js +1 -0
  47. package/package.json +93 -0
package/dist/tui.d.ts ADDED
@@ -0,0 +1,3 @@
1
+ import type { TuiPluginModule } from "@opencode-ai/plugin/tui";
2
+ declare const mod: TuiPluginModule;
3
+ export default mod;
package/dist/tui.js ADDED
@@ -0,0 +1,228 @@
1
+ /**
2
+ * TUI entrypoint (loaded via package.json exports["./tui"]).
3
+ *
4
+ * Server-defined slash commands never execute on the first Enter in the
5
+ * opencode TUI — selecting one in the autocomplete only inserts "/name " and
6
+ * waits for a second Enter. To get single-Enter execution WITHOUT a duplicate
7
+ * autocomplete row, this module does two things:
8
+ *
9
+ * 1. Registers the five commands as palette commands (no `slashName`, so
10
+ * they do NOT add rows to the autocomplete — the menu keeps only the
11
+ * server-defined row).
12
+ * 2. Registers a high-priority "return" binding whose handler checks the
13
+ * focused prompt's text. When it is exactly one of our slash commands
14
+ * (no arguments), the handler executes it via `client.session.command`
15
+ * — the same `command.execute.before` interception as a normal submit —
16
+ * and reports the key handled. On the home route (no session yet) it
17
+ * first creates a session and navigates to it, replicating the stock
18
+ * home-submit flow. For any other text it returns `false`, and the
19
+ * keymap falls through to the normal bindings (autocomplete select /
20
+ * input submit), so every other keypress behaves exactly as stock.
21
+ *
22
+ * Commands typed WITH arguments (e.g. `/peers-name foo`) never match the
23
+ * exact-text check, so they submit normally with the argument intact.
24
+ *
25
+ * CONSTRAINT: this file must compile to a zero-runtime-import dist/tui.js.
26
+ * `@opencode-ai/plugin/tui` re-exports @opentui/keymap at runtime, which is
27
+ * not resolvable inside the TUI process — `import type` only. That is also
28
+ * why the command names are duplicated here instead of imported from
29
+ * ./index.js (which would drag the whole server dependency chain along).
30
+ */
31
+ const COMMANDS = [
32
+ { cmd: "peers", title: "List peers", desc: "List same-machine opencode peers" },
33
+ { cmd: "list-agents", title: "List agents", desc: "Alias of /peers" },
34
+ { cmd: "peers-name", title: "Show/set peer name", desc: "Show or set this instance's peer name" },
35
+ { cmd: "peers-inbox", title: "Peer inbox", desc: "Review held peer messages" },
36
+ { cmd: "peers-outbox", title: "Peer outbox", desc: "Review peer delivery acknowledgements" },
37
+ ];
38
+ const EXACT = new Set(COMMANDS.map(({ cmd }) => `/${cmd}`));
39
+ /** Must outrank the autocomplete's select binding (the TUI uses 0..1). */
40
+ const LAYER_PRIORITY = 10;
41
+ /**
42
+ * Slash-typeable names that are NOT ours (server-defined commands + TUI slash
43
+ * entries). Used to decide whether a partially typed name uniquely highlights
44
+ * one of our rows in the autocomplete. Refreshed opportunistically; while it
45
+ * is unknown we stay conservative and only intercept exact matches.
46
+ */
47
+ let otherNames = null;
48
+ let otherNamesAt = 0;
49
+ async function refreshOtherNames(api) {
50
+ try {
51
+ const names = new Set();
52
+ const res = await api.client.command.list();
53
+ for (const c of res.data ?? []) {
54
+ if (c?.name)
55
+ names.add(c.name);
56
+ }
57
+ const km = api.keymap;
58
+ for (const e of km.getCommandEntries?.({ visibility: "reachable", namespace: "palette" }) ?? []) {
59
+ const slash = e.command?.slashName;
60
+ if (typeof slash === "string" && slash)
61
+ names.add(slash);
62
+ const aliases = e.command?.slashAliases;
63
+ if (Array.isArray(aliases))
64
+ for (const a of aliases)
65
+ if (typeof a === "string" && a)
66
+ names.add(a);
67
+ }
68
+ for (const { cmd } of COMMANDS)
69
+ names.delete(cmd);
70
+ otherNames = names;
71
+ otherNamesAt = Date.now();
72
+ }
73
+ catch {
74
+ // Keep the previous (possibly empty) cache; a retry happens on the next Enter.
75
+ }
76
+ }
77
+ /**
78
+ * Resolve the prompt text to one of our command names, or return null.
79
+ * Exact matches always resolve. A partial name resolves only when it is a
80
+ * prefix of exactly one of our commands and of no other slash-typeable name —
81
+ * i.e. when our row is the unambiguous highlighted autocomplete entry (the
82
+ * TUI's scorer doubles prefix matches, so that entry is strictly on top).
83
+ */
84
+ function resolveTyped(text) {
85
+ if (EXACT.has(text))
86
+ return text.slice(1);
87
+ if (!text.startsWith("/"))
88
+ return null;
89
+ const name = text.slice(1);
90
+ if (!name || /\s/.test(name))
91
+ return null;
92
+ const ours = COMMANDS.filter(({ cmd }) => cmd.startsWith(name));
93
+ if (ours.length !== 1)
94
+ return null;
95
+ if (!otherNames)
96
+ return null;
97
+ for (const n of otherNames) {
98
+ if (n.startsWith(name))
99
+ return null;
100
+ }
101
+ return ours[0].cmd;
102
+ }
103
+ async function runCommand(api, command, commandArguments = "") {
104
+ const route = api.route.current;
105
+ let sessionID = route.name === "session"
106
+ ? route.params?.sessionID
107
+ : undefined;
108
+ try {
109
+ if (!sessionID) {
110
+ // Home route: replicate the stock submit flow — create a session,
111
+ // switch to it, then run the command there. Server defaults apply
112
+ // for agent/model; our commands are consumed before any model call.
113
+ const res = await api.client.session.create();
114
+ sessionID = res.data?.id;
115
+ if (!sessionID)
116
+ throw new Error("session.create returned no session id");
117
+ api.route.navigate("session", { sessionID });
118
+ }
119
+ await api.client.session.command({ sessionID, command, arguments: commandArguments });
120
+ }
121
+ catch (err) {
122
+ api.ui.toast({
123
+ variant: "error",
124
+ title: `/${command} failed`,
125
+ message: err instanceof Error ? err.message : String(err),
126
+ });
127
+ }
128
+ }
129
+ async function runControl(api, command) {
130
+ const dialogs = api.ui;
131
+ if (command === "peers-name") {
132
+ const name = (await dialogs.DialogPrompt({ title: "Rename this peer", placeholder: "1-32 safe characters" }))?.trim();
133
+ if (!name)
134
+ return;
135
+ if (!(await dialogs.DialogConfirm({ title: "Confirm peer rename", message: `Rename this peer to "${name}"?` })))
136
+ return;
137
+ return runCommand(api, command, name);
138
+ }
139
+ if (command === "peers-inbox") {
140
+ const action = await dialogs.DialogSelect({
141
+ title: "Peer inbox action",
142
+ options: [
143
+ { title: "List held messages", value: "list" },
144
+ { title: "Accept held message", value: "accept" },
145
+ { title: "Drop held message", value: "drop" },
146
+ ],
147
+ });
148
+ if (!action)
149
+ return;
150
+ if (action === "list")
151
+ return runCommand(api, command);
152
+ const target = (await dialogs.DialogPrompt({ title: `${action === "accept" ? "Accept" : "Drop"} which message?`, placeholder: "number or all" }))?.trim();
153
+ if (!target || (!/^\d+$/.test(target) && target !== "all"))
154
+ return;
155
+ if (action === "drop" && !(await dialogs.DialogConfirm({ title: "Confirm message drop", message: `Permanently drop held message ${target}?` })))
156
+ return;
157
+ return runCommand(api, command, `${action} ${target}`);
158
+ }
159
+ const selected = await dialogs.DialogSelect({
160
+ title: command === "peers-outbox" ? "Peer outbox" : "Peer sessions",
161
+ options: [{ title: command === "peers-outbox" ? "View delivery status" : "List peer sessions", value: "list" }],
162
+ });
163
+ if (selected !== "list")
164
+ return;
165
+ return runCommand(api, command);
166
+ }
167
+ /**
168
+ * Sync Enter handler: returns true (handled) only when the focused prompt
169
+ * holds exactly one of our commands; anything else returns false so the
170
+ * keymap falls through to the stock bindings. Must stay synchronous — a
171
+ * Promise result is always treated as handled by the keymap.
172
+ */
173
+ function onEnter(api, ctx) {
174
+ // Never hijack Enter inside dialogs (rename prompts, palette search, …).
175
+ if (api.ui.dialog.open)
176
+ return false;
177
+ const route = api.route.current;
178
+ // Only the session prompt and the home prompt are command-entry points;
179
+ // plugin routes and anything else keep stock behavior.
180
+ if (route.name !== "session" && route.name !== "home")
181
+ return false;
182
+ const focused = ctx.focused;
183
+ if (typeof focused?.plainText !== "string")
184
+ return false;
185
+ if (Date.now() - otherNamesAt > 60_000)
186
+ void refreshOtherNames(api);
187
+ const command = resolveTyped(focused.plainText.trim());
188
+ if (!command)
189
+ return false;
190
+ try {
191
+ focused.setText?.("");
192
+ }
193
+ catch {
194
+ // Cosmetic only — prompt.clear below is the fallback.
195
+ }
196
+ // Drop the typed text from the prompt store as well, like a normal submit.
197
+ try {
198
+ api.keymap.dispatchCommand("prompt.clear");
199
+ }
200
+ catch {
201
+ // Command name may change in a future opencode version.
202
+ }
203
+ void runCommand(api, command);
204
+ return true;
205
+ }
206
+ const mod = {
207
+ id: "opencode-collaboration",
208
+ tui: async (api) => {
209
+ const unregister = api.keymap.registerLayer({
210
+ priority: LAYER_PRIORITY,
211
+ commands: COMMANDS.map(({ cmd, title, desc }) => ({
212
+ namespace: "palette",
213
+ name: `opencode-collaboration.${cmd}`,
214
+ title,
215
+ desc,
216
+ // No slashName on purpose: slash entries would add a second,
217
+ // duplicate row to the autocomplete menu. Instant execution comes
218
+ // from the Enter binding below; these stay reachable via the
219
+ // command palette.
220
+ run: () => runControl(api, cmd),
221
+ })),
222
+ bindings: [{ key: "return", cmd: (ctx) => onEnter(api, ctx) }],
223
+ });
224
+ api.lifecycle.onDispose(() => unregister());
225
+ void refreshOtherNames(api);
226
+ },
227
+ };
228
+ export default mod;
@@ -0,0 +1,162 @@
1
+ export type InboundPolicy = "accept" | "auto" | "hold" | "refuse";
2
+ export type PeerPermissionMode = "allow" | "ask" | "deny";
3
+ export interface PeerFrom {
4
+ instanceId: string;
5
+ name: string;
6
+ directory: string;
7
+ }
8
+ export interface PeerEntry {
9
+ version: 1;
10
+ instanceId: string;
11
+ name: string;
12
+ pid: number;
13
+ hostname: string;
14
+ directory: string;
15
+ serverUrl: string;
16
+ inboxUrl: string;
17
+ inboxToken: string;
18
+ activeSessionId: string | null;
19
+ activeSessionTitle: string | null;
20
+ /** True while a turn is running in the active session. Optional (v0.1.4+). */
21
+ busy?: boolean;
22
+ /** Messages queued locally awaiting delivery. Optional (v0.1.4+). */
23
+ queuedCount?: number;
24
+ inboundPolicy: InboundPolicy;
25
+ startedAt: number;
26
+ heartbeatAt: number;
27
+ pluginVersion: string;
28
+ }
29
+ export type SessionEndpointStatus = "idle" | "busy" | "retry";
30
+ export interface PeerRegistryV2 {
31
+ version: 2;
32
+ endpointId: string;
33
+ processId: string;
34
+ pid: number;
35
+ sessionId: string;
36
+ parentSessionId?: string;
37
+ title: string;
38
+ name: string;
39
+ hostname: string;
40
+ directory: string;
41
+ status: SessionEndpointStatus;
42
+ transport: LocalTransportAddress;
43
+ serverUrl: string;
44
+ inboxUrl: string;
45
+ inboxToken: string;
46
+ capabilities: string[];
47
+ timestamps: {
48
+ startedAt: number;
49
+ updatedAt: number;
50
+ heartbeatAt: number;
51
+ };
52
+ policy: {
53
+ inboundPolicy: InboundPolicy;
54
+ peerPermissions: PeerPermissionMode;
55
+ };
56
+ pluginVersion: string;
57
+ /** Compatibility aliases retained for existing formatters and commands. */
58
+ activeSessionId: string;
59
+ activeSessionTitle: string;
60
+ busy: boolean;
61
+ queuedCount: number;
62
+ inboundPolicy: InboundPolicy;
63
+ startedAt: number;
64
+ heartbeatAt: number;
65
+ }
66
+ export type PeerRegistryEntry = PeerEntry | PeerRegistryV2;
67
+ /** Protocol-v1 input accepted from existing peers. */
68
+ export interface InboundMessageV1 {
69
+ id: string;
70
+ from: PeerFrom;
71
+ text: string;
72
+ via: string[];
73
+ sentAt: number;
74
+ }
75
+ /** Backward-compatible name for protocol-v1 inbound input. */
76
+ export type InboundMessage = InboundMessageV1;
77
+ /** Protocol-v2 message shape used by endpoint-addressed transports. */
78
+ export interface PeerMessageV2 {
79
+ version: 2;
80
+ messageId: string;
81
+ fromEndpointId: string;
82
+ toEndpointId: string;
83
+ from: PeerFrom;
84
+ text: string;
85
+ via: string[];
86
+ sentAt: number;
87
+ }
88
+ export type AcknowledgementStatus = "delivered" | "refused" | "expired" | "dropped" | "duplicate";
89
+ /** Durable final outcome for a protocol-v2 message. */
90
+ export interface PeerAcknowledgementV2 {
91
+ version: 2;
92
+ messageId: string;
93
+ fromEndpointId: string;
94
+ toEndpointId: string;
95
+ status: AcknowledgementStatus;
96
+ acknowledgedAt: number;
97
+ }
98
+ export interface OutboxRecord {
99
+ version: 1;
100
+ messageId: string;
101
+ fromEndpointId: string;
102
+ toEndpointId: string;
103
+ toName: string;
104
+ text: string;
105
+ createdAt: number;
106
+ updatedAt: number;
107
+ receiptStatus?: ReceiveStatus;
108
+ finalStatus?: AcknowledgementStatus;
109
+ acknowledgedAt?: number;
110
+ error?: string;
111
+ }
112
+ export type LocalTransportAddress = {
113
+ type: "unix";
114
+ path: string;
115
+ } | {
116
+ type: "tcp";
117
+ host: "127.0.0.1";
118
+ port: number;
119
+ };
120
+ export interface HeldMessage extends InboundMessage {
121
+ heldAt: number;
122
+ expiresAt: number;
123
+ }
124
+ export type ReceiveStatus = "delivered" | "queued" | "held" | "refused" | "expired" | "dropped" | "full" | "duplicate";
125
+ export interface PluginConfig {
126
+ /** Override the storage dir (defaults to $XDG_DATA_HOME/opencode-collaboration). */
127
+ storageDir?: string;
128
+ /** Display name for this instance (default: basename of directory). */
129
+ name?: string;
130
+ /** What to do with inbound messages. Default "accept". */
131
+ inboundPolicy?: InboundPolicy;
132
+ /**
133
+ * How to resolve permission requests raised while acting on a peer
134
+ * message (a turn started by an injected peer message). Default "allow":
135
+ * auto-approve so cross-session tasks run unattended. "ask" restores the
136
+ * default behavior (local user confirms); "deny" blocks tool use in
137
+ * peer-triggered turns.
138
+ */
139
+ peerPermissions?: PeerPermissionMode;
140
+ /** Heartbeat interval ms. Default 10_000. */
141
+ heartbeatMs?: number;
142
+ /** A peer is stale if its heartbeat is older than this. Default 30_000. */
143
+ staleMs?: number;
144
+ /** Max queued messages awaiting an immediate-delivery retry. Default 50. */
145
+ maxQueue?: number;
146
+ /** Max held messages. Default 100. */
147
+ maxHeld?: number;
148
+ /** Max bytes for a single message body. Default 8192. */
149
+ maxMessageBytes?: number;
150
+ /** Expiry for messages awaiting local approval. Default 300000 ms. */
151
+ heldExpiryMs?: number;
152
+ /** Maximum sender timestamp age/skew accepted by the receiver. Default 300000 ms. */
153
+ maxMessageAgeMs?: number;
154
+ /** Outbound rate limit per peer per minute. Default 10. */
155
+ sendRatePerMin?: number;
156
+ /** Inbound rate limit per sender per minute. Default 20. */
157
+ recvRatePerMin?: number;
158
+ /** Fallback sweep interval ms. Default 15_000. */
159
+ sweepMs?: number;
160
+ }
161
+ export type LogLevel = "debug" | "info" | "warn" | "error";
162
+ export type Logger = (level: LogLevel, message: string, extra?: Record<string, unknown>) => Promise<void>;
package/dist/types.js ADDED
@@ -0,0 +1 @@
1
+ export {};
package/package.json ADDED
@@ -0,0 +1,93 @@
1
+ {
2
+ "name": "opencode-collaboration",
3
+ "version": "0.2.3",
4
+ "description": "Cross-session messaging for opencode — let independent sessions discover and text each other, modeled after Claude Code's cross-session messaging",
5
+ "type": "module",
6
+ "main": "./dist/index.js",
7
+ "module": "./dist/index.js",
8
+ "types": "./dist/index.d.ts",
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "import": "./dist/index.js"
13
+ },
14
+ "./server": {
15
+ "types": "./dist/index.d.ts",
16
+ "import": "./dist/index.js"
17
+ },
18
+ "./tui": {
19
+ "types": "./dist/tui.d.ts",
20
+ "import": "./dist/tui.js"
21
+ },
22
+ "./commands/peers.md": "./commands/peers.md",
23
+ "./commands/list-agents.md": "./commands/list-agents.md",
24
+ "./commands/peers-name.md": "./commands/peers-name.md",
25
+ "./commands/peers-inbox.md": "./commands/peers-inbox.md",
26
+ "./commands/peers-outbox.md": "./commands/peers-outbox.md",
27
+ "./commands/*": "./commands/*"
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "commands",
32
+ "README.md",
33
+ "README.zh-CN.md",
34
+ "LICENSE"
35
+ ],
36
+ "keywords": [
37
+ "opencode",
38
+ "opencode-plugin",
39
+ "claude-code",
40
+ "cross-session",
41
+ "messaging",
42
+ "multi-agent",
43
+ "ai-coding-agent"
44
+ ],
45
+ "license": "MIT",
46
+ "author": {
47
+ "name": "opencode-collaboration authors"
48
+ },
49
+ "repository": {
50
+ "type": "git",
51
+ "url": "git+https://gitee.com/cqy0/opencode-collaboration.git"
52
+ },
53
+ "bugs": {
54
+ "url": "https://gitee.com/cqy0/opencode-collaboration/issues"
55
+ },
56
+ "homepage": "https://gitee.com/cqy0/opencode-collaboration#readme",
57
+ "engines": {
58
+ "node": ">=18",
59
+ "opencode": ">=1.18.0"
60
+ },
61
+ "scripts": {
62
+ "build": "tsc -p tsconfig.json",
63
+ "prepare": "npm run build",
64
+ "test": "npm run build && node --test tests/*.test.mjs",
65
+ "typecheck": "tsc --noEmit -p tsconfig.json",
66
+ "dry-run": "npm publish --dry-run",
67
+ "prepublishOnly": "npm run build"
68
+ },
69
+ "peerDependencies": {
70
+ "@opencode-ai/plugin": ">=1.18.0"
71
+ },
72
+ "dependencies": {
73
+ "zod": "^4.1.8"
74
+ },
75
+ "devDependencies": {
76
+ "@opencode-ai/plugin": "^1.18.0",
77
+ "@opentui/core": "^0.4.5",
78
+ "@opentui/keymap": "^0.4.5",
79
+ "@opentui/solid": "^0.4.5",
80
+ "@types/node": "^22.0.0",
81
+ "typescript": "^5.4.0"
82
+ },
83
+ "opencode": {
84
+ "plugin": true,
85
+ "commands": [
86
+ "commands/peers.md",
87
+ "commands/list-agents.md",
88
+ "commands/peers-name.md",
89
+ "commands/peers-inbox.md",
90
+ "commands/peers-outbox.md"
91
+ ]
92
+ }
93
+ }