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
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Per-instance inbox HTTP listener (127.0.0.1 only, bearer-token auth).
3
+ * Peers POST messages here instead of touching the opencode server directly,
4
+ * so inbound gating (accept/hold/refuse) and queueing stay under our control.
5
+ */
6
+ import { chmod, lstat, mkdir, rm } from "node:fs/promises";
7
+ import { createServer } from "node:http";
8
+ import { connect } from "node:net";
9
+ import { tmpdir } from "node:os";
10
+ import { join } from "node:path";
11
+ import { z } from "zod";
12
+ const MAX_HOPS = 4;
13
+ const peerFromSchema = z
14
+ .object({
15
+ instanceId: z.string().min(1),
16
+ name: z.string().min(1),
17
+ directory: z.string().min(1),
18
+ })
19
+ .passthrough();
20
+ const inboundMessageV1Schema = z
21
+ .object({
22
+ id: z.string().min(1),
23
+ from: peerFromSchema,
24
+ text: z.string(),
25
+ via: z.array(z.string()).max(MAX_HOPS),
26
+ sentAt: z.number().finite(),
27
+ })
28
+ .passthrough();
29
+ const inboundMessageV2Schema = z
30
+ .object({
31
+ version: z.literal(2),
32
+ messageId: z.string().min(1),
33
+ fromEndpointId: z.string().min(1),
34
+ toEndpointId: z.string().min(1),
35
+ from: peerFromSchema,
36
+ text: z.string(),
37
+ via: z.array(z.string()).max(MAX_HOPS),
38
+ sentAt: z.number().finite(),
39
+ })
40
+ .passthrough();
41
+ const acknowledgementV2Schema = z.object({
42
+ version: z.literal(2),
43
+ messageId: z.string().min(1),
44
+ fromEndpointId: z.string().min(1),
45
+ toEndpointId: z.string().min(1),
46
+ status: z.enum(["delivered", "refused", "expired", "dropped", "duplicate"]),
47
+ acknowledgedAt: z.number().finite(),
48
+ }).passthrough();
49
+ function statusToHttp(status) {
50
+ switch (status) {
51
+ case "refused":
52
+ return 403;
53
+ case "full":
54
+ return 429;
55
+ default:
56
+ return 202;
57
+ }
58
+ }
59
+ function parseMessage(body) {
60
+ if (typeof body === "object" && body !== null && "version" in body && body.version === 2) {
61
+ const parsed = inboundMessageV2Schema.safeParse(body);
62
+ if (!parsed.success)
63
+ return null;
64
+ const message = parsed.data;
65
+ return {
66
+ message: {
67
+ id: message.messageId,
68
+ from: {
69
+ instanceId: message.fromEndpointId,
70
+ name: message.from.name,
71
+ directory: message.from.directory,
72
+ },
73
+ text: message.text,
74
+ via: message.via,
75
+ sentAt: message.sentAt,
76
+ },
77
+ route: { version: 2, toEndpointId: message.toEndpointId },
78
+ };
79
+ }
80
+ const parsed = inboundMessageV1Schema.safeParse(body);
81
+ return parsed.success ? { message: parsed.data, route: { version: 1 } } : null;
82
+ }
83
+ export function defaultRuntimeDirectory(env = process.env, uid = typeof process.getuid === "function" ? process.getuid() : 0) {
84
+ return env.XDG_RUNTIME_DIR
85
+ ? join(env.XDG_RUNTIME_DIR, "opencode-collaboration")
86
+ : join(tmpdir(), `ocp-${uid}`);
87
+ }
88
+ export function InboxListener(opts) {
89
+ let primaryServer = null;
90
+ let compatibilityServer = null;
91
+ let ownedSocketPath = null;
92
+ let lifecycle = "new";
93
+ function authorized(authHeader) {
94
+ return authHeader === `Bearer ${opts.token}`;
95
+ }
96
+ async function handle(req, res) {
97
+ const send = (code, body) => {
98
+ res.writeHead(code, { "content-type": "application/json" });
99
+ res.end(JSON.stringify(body));
100
+ };
101
+ if (!authorized(req.headers.authorization)) {
102
+ send(401, { error: "unauthorized" });
103
+ return;
104
+ }
105
+ if (req.method === "GET" && req.url === "/health") {
106
+ send(200, { ok: true });
107
+ return;
108
+ }
109
+ if (req.method !== "POST" || (req.url !== "/message" && req.url !== "/ack")) {
110
+ send(404, { error: "not found" });
111
+ return;
112
+ }
113
+ const chunks = [];
114
+ let size = 0;
115
+ for await (const chunk of req) {
116
+ size += chunk.length;
117
+ if (size > opts.maxBodyBytes) {
118
+ send(413, { error: "message too large" });
119
+ req.destroy();
120
+ return;
121
+ }
122
+ chunks.push(chunk);
123
+ }
124
+ let parsed;
125
+ try {
126
+ parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
127
+ }
128
+ catch {
129
+ send(400, { error: "invalid json" });
130
+ return;
131
+ }
132
+ if (req.url === "/ack") {
133
+ const ack = acknowledgementV2Schema.safeParse(parsed);
134
+ if (!ack.success) {
135
+ send(400, { error: "invalid acknowledgement shape" });
136
+ return;
137
+ }
138
+ await opts.onAcknowledgement?.(ack.data);
139
+ send(202, { status: "accepted" });
140
+ return;
141
+ }
142
+ const envelope = parseMessage(parsed);
143
+ if (!envelope) {
144
+ send(400, { error: "invalid message shape" });
145
+ return;
146
+ }
147
+ const msg = envelope.message;
148
+ if (Buffer.byteLength(msg.text, "utf8") > (opts.maxMessageBytes ?? 8192)) {
149
+ send(413, { error: "message too large" });
150
+ return;
151
+ }
152
+ if (Math.abs(Date.now() - msg.sentAt) > (opts.maxMessageAgeMs ?? 300_000)) {
153
+ send(400, { error: "stale message" });
154
+ return;
155
+ }
156
+ try {
157
+ const endpointId = await opts.resolveEndpoint?.(envelope.route);
158
+ if (opts.resolveEndpoint && !endpointId) {
159
+ send(404, { error: "endpoint not found" });
160
+ return;
161
+ }
162
+ const status = await opts.onMessage(msg, endpointId ?? envelope.route.toEndpointId);
163
+ send(statusToHttp(status), { status });
164
+ }
165
+ catch (err) {
166
+ await opts.logger("error", "onMessage handler failed", { error: String(err) });
167
+ send(500, { error: "internal error" });
168
+ }
169
+ }
170
+ function listenerServer() {
171
+ return createServer((req, res) => {
172
+ handle(req, res).catch((err) => {
173
+ void opts.logger("error", "listener error", { error: String(err) });
174
+ if (!res.headersSent)
175
+ res.writeHead(500).end();
176
+ else
177
+ res.end();
178
+ });
179
+ });
180
+ }
181
+ function listenUnix(server, socketPath) {
182
+ return new Promise((resolve, reject) => {
183
+ const onError = (err) => {
184
+ server.off("listening", onListening);
185
+ reject(err);
186
+ };
187
+ const onListening = () => {
188
+ server.off("error", onError);
189
+ resolve();
190
+ };
191
+ server.once("error", onError);
192
+ server.once("listening", onListening);
193
+ server.listen(socketPath);
194
+ });
195
+ }
196
+ function listenTcp(server) {
197
+ return new Promise((resolve, reject) => {
198
+ const onError = (err) => {
199
+ server.off("listening", onListening);
200
+ reject(err);
201
+ };
202
+ const onListening = () => {
203
+ server.off("error", onError);
204
+ resolve(server.address().port);
205
+ };
206
+ server.once("error", onError);
207
+ server.once("listening", onListening);
208
+ server.listen(0, "127.0.0.1");
209
+ });
210
+ }
211
+ function closeServer(server) {
212
+ return new Promise((resolve, reject) => {
213
+ if (!server?.listening) {
214
+ resolve();
215
+ return;
216
+ }
217
+ server.close((err) => err ? reject(err) : resolve());
218
+ });
219
+ }
220
+ function liveUnixSocket(socketPath) {
221
+ return new Promise((resolve, reject) => {
222
+ const socket = connect({ path: socketPath });
223
+ let settled = false;
224
+ const finish = (result, err) => {
225
+ if (settled)
226
+ return;
227
+ settled = true;
228
+ socket.destroy();
229
+ if (err)
230
+ reject(err);
231
+ else
232
+ resolve(result);
233
+ };
234
+ socket.once("connect", () => finish(true));
235
+ socket.once("error", (err) => {
236
+ if (err.code === "ECONNREFUSED" || err.code === "ENOENT")
237
+ finish(false);
238
+ else
239
+ finish(false, err);
240
+ });
241
+ socket.setTimeout(500, () => finish(true));
242
+ });
243
+ }
244
+ async function prepareUnixSocket(socketPath) {
245
+ try {
246
+ const info = await lstat(socketPath);
247
+ if (!info.isSocket())
248
+ throw new Error(`runtime socket path collision: ${socketPath}`);
249
+ if (await liveUnixSocket(socketPath))
250
+ throw new Error(`runtime socket already in use: ${socketPath}`);
251
+ await rm(socketPath);
252
+ }
253
+ catch (err) {
254
+ if (err.code !== "ENOENT")
255
+ throw err;
256
+ }
257
+ }
258
+ return {
259
+ async start() {
260
+ if (lifecycle !== "new")
261
+ throw new Error(`listener cannot start while ${lifecycle}`);
262
+ lifecycle = "starting";
263
+ const platform = opts.platform ?? (opts.processId ? process.platform : "win32");
264
+ if (platform === "win32") {
265
+ primaryServer = listenerServer();
266
+ try {
267
+ const port = await listenTcp(primaryServer);
268
+ lifecycle = "running";
269
+ const url = `http://127.0.0.1:${port}`;
270
+ return {
271
+ port,
272
+ url,
273
+ compatibilityUrl: url,
274
+ address: { type: "tcp", host: "127.0.0.1", port },
275
+ };
276
+ }
277
+ catch (err) {
278
+ await closeServer(primaryServer).catch(() => { });
279
+ primaryServer = null;
280
+ lifecycle = "stopped";
281
+ throw err;
282
+ }
283
+ }
284
+ const directory = opts.runtimeDir ?? defaultRuntimeDirectory();
285
+ const socketPath = join(directory, `${opts.processId ?? process.pid}.sock`);
286
+ let boundUnixSocket = false;
287
+ try {
288
+ await mkdir(directory, { recursive: true, mode: 0o700 });
289
+ await chmod(directory, 0o700);
290
+ await prepareUnixSocket(socketPath);
291
+ primaryServer = listenerServer();
292
+ await listenUnix(primaryServer, socketPath);
293
+ boundUnixSocket = true;
294
+ ownedSocketPath = socketPath;
295
+ await chmod(socketPath, 0o600);
296
+ compatibilityServer = listenerServer();
297
+ const compatibilityPort = await listenTcp(compatibilityServer);
298
+ lifecycle = "running";
299
+ return {
300
+ port: compatibilityPort,
301
+ url: `http+unix://${encodeURIComponent(socketPath)}`,
302
+ compatibilityUrl: `http://127.0.0.1:${compatibilityPort}`,
303
+ address: { type: "unix", path: socketPath },
304
+ };
305
+ }
306
+ catch (err) {
307
+ await Promise.all([
308
+ closeServer(compatibilityServer).catch(() => { }),
309
+ closeServer(primaryServer).catch(() => { }),
310
+ ]);
311
+ compatibilityServer = null;
312
+ primaryServer = null;
313
+ if (boundUnixSocket)
314
+ await rm(socketPath, { force: true }).catch(() => { });
315
+ ownedSocketPath = null;
316
+ lifecycle = "stopped";
317
+ throw err;
318
+ }
319
+ },
320
+ async stop() {
321
+ if (lifecycle === "stopped")
322
+ return;
323
+ lifecycle = "stopping";
324
+ const unixPath = ownedSocketPath;
325
+ const servers = [compatibilityServer, primaryServer];
326
+ compatibilityServer = null;
327
+ primaryServer = null;
328
+ ownedSocketPath = null;
329
+ await Promise.all(servers.map((server) => closeServer(server)));
330
+ if (unixPath)
331
+ await rm(unixPath, { force: true });
332
+ lifecycle = "stopped";
333
+ },
334
+ };
335
+ }
@@ -0,0 +1,12 @@
1
+ import type { OutboxRecord, PeerAcknowledgementV2, PeerMessageV2, ReceiveStatus } from "./types.js";
2
+ export interface OutboxInstance {
3
+ recordPending: (message: PeerMessageV2, toName: string) => Promise<OutboxRecord>;
4
+ recordReceipt: (messageId: string, fromEndpointId: string, status: ReceiveStatus) => Promise<OutboxRecord | null>;
5
+ recordFailure: (messageId: string, fromEndpointId: string, error: string) => Promise<OutboxRecord | null>;
6
+ applyAcknowledgement: (ack: PeerAcknowledgementV2) => Promise<boolean>;
7
+ get: (fromEndpointId: string, messageId: string) => OutboxRecord | null;
8
+ list: (fromEndpointId: string) => OutboxRecord[];
9
+ }
10
+ export declare function Outbox(opts: {
11
+ storageDir: string;
12
+ }): OutboxInstance;
package/dist/outbox.js ADDED
@@ -0,0 +1,110 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, writeSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ function safe(value) {
5
+ return value.replace(/[^a-zA-Z0-9_-]/g, "_");
6
+ }
7
+ export function Outbox(opts) {
8
+ const root = join(opts.storageDir, "outbox");
9
+ function ensure(directory) {
10
+ mkdirSync(directory, { recursive: true, mode: 0o700 });
11
+ chmodSync(directory, 0o700);
12
+ }
13
+ function pathFor(endpointId, messageId) {
14
+ return join(root, safe(endpointId), `${safe(messageId)}.json`);
15
+ }
16
+ function read(endpointId, messageId) {
17
+ try {
18
+ const value = JSON.parse(readFileSync(pathFor(endpointId, messageId), "utf8"));
19
+ return value.version === 1 && value.messageId === messageId && value.fromEndpointId === endpointId ? value : null;
20
+ }
21
+ catch {
22
+ return null;
23
+ }
24
+ }
25
+ function persist(record) {
26
+ const target = pathFor(record.fromEndpointId, record.messageId);
27
+ const directory = dirname(target);
28
+ ensure(root);
29
+ ensure(directory);
30
+ const temp = join(directory, `.${process.pid}.${randomBytes(8).toString("hex")}.tmp`);
31
+ const fd = openSync(temp, "wx", 0o600);
32
+ try {
33
+ writeSync(fd, JSON.stringify(record));
34
+ fsyncSync(fd);
35
+ }
36
+ finally {
37
+ closeSync(fd);
38
+ }
39
+ chmodSync(temp, 0o600);
40
+ renameSync(temp, target);
41
+ if (process.platform !== "win32") {
42
+ const dirFd = openSync(directory, "r");
43
+ try {
44
+ fsyncSync(dirFd);
45
+ }
46
+ finally {
47
+ closeSync(dirFd);
48
+ }
49
+ }
50
+ }
51
+ function update(endpointId, messageId, values) {
52
+ const current = read(endpointId, messageId);
53
+ if (!current)
54
+ return null;
55
+ const next = { ...current, ...values, updatedAt: Date.now() };
56
+ persist(next);
57
+ return next;
58
+ }
59
+ return {
60
+ async recordPending(message, toName) {
61
+ const existing = read(message.fromEndpointId, message.messageId);
62
+ if (existing)
63
+ return existing;
64
+ const record = {
65
+ version: 1,
66
+ messageId: message.messageId,
67
+ fromEndpointId: message.fromEndpointId,
68
+ toEndpointId: message.toEndpointId,
69
+ toName,
70
+ text: message.text,
71
+ createdAt: Date.now(),
72
+ updatedAt: Date.now(),
73
+ };
74
+ persist(record);
75
+ return record;
76
+ },
77
+ async recordReceipt(messageId, fromEndpointId, status) {
78
+ return update(fromEndpointId, messageId, { receiptStatus: status, error: undefined });
79
+ },
80
+ async recordFailure(messageId, fromEndpointId, error) {
81
+ return update(fromEndpointId, messageId, { error });
82
+ },
83
+ async applyAcknowledgement(ack) {
84
+ const record = read(ack.fromEndpointId, ack.messageId);
85
+ if (!record || record.toEndpointId !== ack.toEndpointId)
86
+ return false;
87
+ update(ack.fromEndpointId, ack.messageId, {
88
+ finalStatus: ack.status,
89
+ acknowledgedAt: ack.acknowledgedAt,
90
+ error: undefined,
91
+ });
92
+ return true;
93
+ },
94
+ get: read,
95
+ list(fromEndpointId) {
96
+ const directory = join(root, safe(fromEndpointId));
97
+ if (!existsSync(directory))
98
+ return [];
99
+ return readdirSync(directory).filter((file) => file.endsWith(".json")).flatMap((file) => {
100
+ try {
101
+ const record = JSON.parse(readFileSync(join(directory, file), "utf8"));
102
+ return record.version === 1 && record.fromEndpointId === fromEndpointId ? [record] : [];
103
+ }
104
+ catch {
105
+ return [];
106
+ }
107
+ }).sort((a, b) => b.createdAt - a.createdAt || b.messageId.localeCompare(a.messageId));
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,47 @@
1
+ /**
2
+ * Auto-resolve permission requests that originate from a peer-triggered
3
+ * turn, modeled after Claude Code's per-source permission modes.
4
+ *
5
+ * A turn counts as peer-triggered when the user message that started it was
6
+ * injected by this plugin — detectable via the `peerMessage: true` metadata
7
+ * stamped on the injected part (see delivery.ts). Permission requests point
8
+ * at the *assistant* message holding the tool call, so the lookup walks up
9
+ * via parentID to the originating user message before checking its parts.
10
+ *
11
+ * Mechanism: opencode 1.18 does not invoke the plugin SDK's `permission.ask`
12
+ * hook, but it publishes permission request events on the bus and exposes a
13
+ * reply endpoint — the same pair the TUI uses. The plugin listens for
14
+ * `permission.v2.asked` (and the legacy `permission.asked`) and replies
15
+ * "once" (allow) or "reject" (deny) when the requesting turn is
16
+ * peer-triggered. Local user turns get no reply and fall through to
17
+ * opencode's normal prompt flow untouched.
18
+ */
19
+ import type { PluginInput } from "@opencode-ai/plugin";
20
+ import type { Logger, PeerPermissionMode } from "./types.js";
21
+ type Client = PluginInput["client"];
22
+ export interface PeerPermissionsOptions {
23
+ client: Client;
24
+ /** Read dynamically so a future config reload could change behavior. */
25
+ mode: () => PeerPermissionMode;
26
+ directory: string;
27
+ logger: Logger;
28
+ }
29
+ export interface PeerPermissionsInstance {
30
+ /** Feed every bus event here; only permission requests are acted on. */
31
+ handleEvent: (event: {
32
+ type?: string;
33
+ properties?: Record<string, unknown>;
34
+ }) => Promise<void>;
35
+ }
36
+ /**
37
+ * Requests in these categories always remain under OpenCode's native policy/UI.
38
+ *
39
+ * This is a best-effort denylist, not a security boundary: it matches on the
40
+ * flattened event text, so a determined peer message can phrase a request to
41
+ * avoid these patterns (e.g. `npm config set` never names `.npmrc`). Treat
42
+ * `peerPermissions: "allow"` as fully trusting your peers; use "ask" for
43
+ * anything sensitive.
44
+ */
45
+ export declare function isProtectedPermission(props: Record<string, unknown>): boolean;
46
+ export declare function PeerPermissions(opts: PeerPermissionsOptions): PeerPermissionsInstance;
47
+ export {};