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.
- package/LICENSE +194 -0
- package/README.md +224 -0
- package/README.zh-CN.md +224 -0
- package/commands/list-agents.md +8 -0
- package/commands/peers-inbox.md +8 -0
- package/commands/peers-name.md +8 -0
- package/commands/peers-outbox.md +8 -0
- package/commands/peers.md +8 -0
- package/dist/commands.d.ts +29 -0
- package/dist/commands.js +95 -0
- package/dist/config.d.ts +31 -0
- package/dist/config.js +50 -0
- package/dist/delivery.d.ts +42 -0
- package/dist/delivery.js +177 -0
- package/dist/feedback.d.ts +8 -0
- package/dist/feedback.js +40 -0
- package/dist/format.d.ts +32 -0
- package/dist/format.js +107 -0
- package/dist/gating.d.ts +4 -0
- package/dist/gating.js +16 -0
- package/dist/index.d.ts +43 -0
- package/dist/index.js +410 -0
- package/dist/listener.d.ts +37 -0
- package/dist/listener.js +335 -0
- package/dist/outbox.d.ts +12 -0
- package/dist/outbox.js +110 -0
- package/dist/permissions.d.ts +47 -0
- package/dist/permissions.js +194 -0
- package/dist/queue.d.ts +89 -0
- package/dist/queue.js +824 -0
- package/dist/registry.d.ts +70 -0
- package/dist/registry.js +308 -0
- package/dist/sender.d.ts +27 -0
- package/dist/sender.js +139 -0
- package/dist/session-runtime.d.ts +40 -0
- package/dist/session-runtime.js +355 -0
- package/dist/session-tracker.d.ts +16 -0
- package/dist/session-tracker.js +39 -0
- package/dist/tools/peers-tools.d.ts +26 -0
- package/dist/tools/peers-tools.js +173 -0
- package/dist/transport.d.ts +20 -0
- package/dist/transport.js +46 -0
- package/dist/tui.d.ts +3 -0
- package/dist/tui.js +228 -0
- package/dist/types.d.ts +162 -0
- package/dist/types.js +1 -0
- package/package.json +93 -0
package/dist/gating.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
export function gateMessage(policy, msg, receiverDirectory) {
|
|
3
|
+
if (policy === "refuse")
|
|
4
|
+
return "refuse";
|
|
5
|
+
if (policy === "hold")
|
|
6
|
+
return "hold";
|
|
7
|
+
if (policy === "auto") {
|
|
8
|
+
if (!receiverDirectory)
|
|
9
|
+
return "hold";
|
|
10
|
+
return resolve(msg.from.directory) === resolve(receiverDirectory) ? "queue" : "hold";
|
|
11
|
+
}
|
|
12
|
+
return "queue";
|
|
13
|
+
}
|
|
14
|
+
export function isLoopMessage(msg, maxHops = 4) {
|
|
15
|
+
return msg.via.length > maxHops;
|
|
16
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-collaboration — cross-session messaging for opencode.
|
|
3
|
+
*
|
|
4
|
+
* Lets independent opencode instances on the same machine discover each
|
|
5
|
+
* other and exchange plain-text messages, modeled after Claude Code's
|
|
6
|
+
* cross-session messaging:
|
|
7
|
+
*
|
|
8
|
+
* - list_agents / send_message tools for the agent
|
|
9
|
+
* - /peers, /peers-name, /peers-inbox commands for the user
|
|
10
|
+
* - accept/hold/refuse inbound gating
|
|
11
|
+
* - each session is independently addressable and receives peer messages immediately
|
|
12
|
+
* - messages are ordinary (synthetic) user messages: no shared history,
|
|
13
|
+
* no file transfer; permissions in peer-triggered turns are governed
|
|
14
|
+
* by the peerPermissions option (default: auto-allow)
|
|
15
|
+
*
|
|
16
|
+
* Architecture: each instance writes a registry file in
|
|
17
|
+
* $XDG_DATA_HOME/opencode-collaboration/peers.d/ and runs a 127.0.0.1-only
|
|
18
|
+
* inbox HTTP listener (random port, bearer token). Peers POST to the
|
|
19
|
+
* listener; the receiving instance injects into its own session via the
|
|
20
|
+
* opencode SDK immediately, including while the target session is busy.
|
|
21
|
+
*/
|
|
22
|
+
import type { Plugin, PluginModule } from "@opencode-ai/plugin";
|
|
23
|
+
import type { QueueInstance } from "./queue.js";
|
|
24
|
+
import type { DeliveryInstance } from "./delivery.js";
|
|
25
|
+
export declare function runReliabilitySweep(queue: QueueInstance, delivery: Pick<DeliveryInstance, "flush">): Promise<void>;
|
|
26
|
+
export declare const PeersPlugin: Plugin;
|
|
27
|
+
export declare const plugin: PluginModule;
|
|
28
|
+
export default plugin;
|
|
29
|
+
export { Registry, uniqueName } from "./registry.js";
|
|
30
|
+
export { MessageQueue, RateLimiter, createProcessMessageQueue, createSessionMessageQueue, hasSpoolRecords, migrateWorkspaceSpool, stableSessionEndpointId, stableSpoolEndpointId, } from "./queue.js";
|
|
31
|
+
export { SessionTracker } from "./session-tracker.js";
|
|
32
|
+
export { SessionRuntime } from "./session-runtime.js";
|
|
33
|
+
export { Delivery, deterministicPeerMessageId, formatMessages } from "./delivery.js";
|
|
34
|
+
export { Sender, buildMessage, buildMessageV2 } from "./sender.js";
|
|
35
|
+
export { InboxListener } from "./listener.js";
|
|
36
|
+
export { LocalTransport } from "./transport.js";
|
|
37
|
+
export type { LocalTransportOptions, Transport, TransportResponse, TransportTarget, } from "./transport.js";
|
|
38
|
+
export { gateMessage } from "./gating.js";
|
|
39
|
+
export { PeerPermissions, isProtectedPermission } from "./permissions.js";
|
|
40
|
+
export { Outbox } from "./outbox.js";
|
|
41
|
+
export { collapseToProcesses, formatSessionList, relativeAge, sortPeers } from "./format.js";
|
|
42
|
+
export { resolveConfig, validateName } from "./config.js";
|
|
43
|
+
export * from "./types.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* opencode-collaboration — cross-session messaging for opencode.
|
|
3
|
+
*
|
|
4
|
+
* Lets independent opencode instances on the same machine discover each
|
|
5
|
+
* other and exchange plain-text messages, modeled after Claude Code's
|
|
6
|
+
* cross-session messaging:
|
|
7
|
+
*
|
|
8
|
+
* - list_agents / send_message tools for the agent
|
|
9
|
+
* - /peers, /peers-name, /peers-inbox commands for the user
|
|
10
|
+
* - accept/hold/refuse inbound gating
|
|
11
|
+
* - each session is independently addressable and receives peer messages immediately
|
|
12
|
+
* - messages are ordinary (synthetic) user messages: no shared history,
|
|
13
|
+
* no file transfer; permissions in peer-triggered turns are governed
|
|
14
|
+
* by the peerPermissions option (default: auto-allow)
|
|
15
|
+
*
|
|
16
|
+
* Architecture: each instance writes a registry file in
|
|
17
|
+
* $XDG_DATA_HOME/opencode-collaboration/peers.d/ and runs a 127.0.0.1-only
|
|
18
|
+
* inbox HTTP listener (random port, bearer token). Peers POST to the
|
|
19
|
+
* listener; the receiving instance injects into its own session via the
|
|
20
|
+
* opencode SDK immediately, including while the target session is busy.
|
|
21
|
+
*/
|
|
22
|
+
import { resolveConfig, defaultPeerName } from "./config.js";
|
|
23
|
+
import { Registry, newInboxToken, newInstanceId } from "./registry.js";
|
|
24
|
+
import { InboxListener } from "./listener.js";
|
|
25
|
+
import { RateLimiter } from "./queue.js";
|
|
26
|
+
import { SessionRuntime } from "./session-runtime.js";
|
|
27
|
+
import { Sender } from "./sender.js";
|
|
28
|
+
import { LocalTransport } from "./transport.js";
|
|
29
|
+
import { Outbox } from "./outbox.js";
|
|
30
|
+
import { PeerPermissions } from "./permissions.js";
|
|
31
|
+
import { buildPeerTools } from "./tools/peers-tools.js";
|
|
32
|
+
import { handlePeersCommand } from "./commands.js";
|
|
33
|
+
import { consumeCommand, createLogger, errorMessage } from "./feedback.js";
|
|
34
|
+
const PLUGIN_VERSION = "0.2.2";
|
|
35
|
+
const COMMAND_NAMES = new Set(["peers", "list-agents", "peers-name", "peers-inbox", "peers-outbox"]);
|
|
36
|
+
/**
|
|
37
|
+
* Command definitions injected through the `config` hook. opencode 1.18 does
|
|
38
|
+
* NOT scan plugin packages for commands/*.md, so without this a fresh install
|
|
39
|
+
* has no /peers* commands at all (the shipped commands/ directory only serves
|
|
40
|
+
* users who copy it into their config dir). User-defined commands with the
|
|
41
|
+
* same name always win.
|
|
42
|
+
*/
|
|
43
|
+
const INJECTED_COMMANDS = {
|
|
44
|
+
peers: {
|
|
45
|
+
description: "List same-machine opencode peers you can exchange messages with (cross-session messaging)",
|
|
46
|
+
template: "$ARGUMENTS\n\nIf this command was not intercepted by the opencode-collaboration plugin, call the list_agents tool and show the result to the user verbatim.",
|
|
47
|
+
},
|
|
48
|
+
"list-agents": {
|
|
49
|
+
description: "List same-machine opencode peers you can exchange messages with (alias of /peers, compatible with Claude Code's /list-agents)",
|
|
50
|
+
template: "$ARGUMENTS\n\nIf this command was not intercepted by the opencode-collaboration plugin, call the list_agents tool and show the result to the user verbatim.",
|
|
51
|
+
},
|
|
52
|
+
"peers-name": {
|
|
53
|
+
description: "Show or set this instance's peer name (used by other sessions to address you)",
|
|
54
|
+
template: "$ARGUMENTS\n\nIf this command was not intercepted by the opencode-collaboration plugin, tell the user the plugin is not loaded and no action was taken.",
|
|
55
|
+
},
|
|
56
|
+
"peers-inbox": {
|
|
57
|
+
description: "Review held peer messages. Usage: /peers-inbox [accept <n|all> | drop <n|all>]",
|
|
58
|
+
template: "$ARGUMENTS\n\nIf this command was not intercepted by the opencode-collaboration plugin, tell the user the plugin is not loaded and no action was taken.",
|
|
59
|
+
},
|
|
60
|
+
"peers-outbox": {
|
|
61
|
+
description: "Show transport receipts and final ACK outcomes for messages sent by this session",
|
|
62
|
+
template: "$ARGUMENTS\n\nIf this command was not intercepted by the opencode-collaboration plugin, tell the user the plugin is not loaded and no action was taken.",
|
|
63
|
+
},
|
|
64
|
+
};
|
|
65
|
+
export async function runReliabilitySweep(queue, delivery) {
|
|
66
|
+
await queue.expireHeld();
|
|
67
|
+
await delivery.flush();
|
|
68
|
+
}
|
|
69
|
+
export const PeersPlugin = async (ctx, pluginOptions) => {
|
|
70
|
+
const logger = createLogger(ctx.client);
|
|
71
|
+
// Options arrive either as the tuple's second element (pluginOptions) or,
|
|
72
|
+
// on some versions, attached to the context.
|
|
73
|
+
const opts = pluginOptions ??
|
|
74
|
+
ctx.options;
|
|
75
|
+
const config = resolveConfig(opts);
|
|
76
|
+
const instanceId = newInstanceId();
|
|
77
|
+
const inboxToken = newInboxToken();
|
|
78
|
+
// Default name: <dir-name>-<hex4> (matches Claude Code's "my-app-3f" pattern).
|
|
79
|
+
// Only auto-generated names get the suffix; an explicit config.name or
|
|
80
|
+
// /peers-name override replaces it entirely, keeping the user's choice.
|
|
81
|
+
let currentName = config.name || defaultPeerName(ctx.directory, instanceId);
|
|
82
|
+
let policy = config.inboundPolicy;
|
|
83
|
+
const runtime = SessionRuntime({
|
|
84
|
+
client: ctx.client,
|
|
85
|
+
config,
|
|
86
|
+
directory: ctx.directory,
|
|
87
|
+
name: () => currentName,
|
|
88
|
+
logger,
|
|
89
|
+
});
|
|
90
|
+
const recvLimit = RateLimiter(config.recvRatePerMin);
|
|
91
|
+
const sendLimit = RateLimiter(config.sendRatePerMin);
|
|
92
|
+
const outbox = Outbox({ storageDir: config.storageDir });
|
|
93
|
+
let dispatchAcknowledgements = async () => { };
|
|
94
|
+
const listener = InboxListener({
|
|
95
|
+
token: inboxToken,
|
|
96
|
+
maxBodyBytes: config.maxMessageBytes * 2 + 4096,
|
|
97
|
+
maxMessageBytes: config.maxMessageBytes,
|
|
98
|
+
maxMessageAgeMs: config.maxMessageAgeMs,
|
|
99
|
+
processId: instanceId,
|
|
100
|
+
resolveEndpoint: async ({ version, toEndpointId }) => {
|
|
101
|
+
const resolve = () => version === 1
|
|
102
|
+
? runtime.compatibilityEndpointId()
|
|
103
|
+
: toEndpointId && runtime.hasEndpoint(toEndpointId)
|
|
104
|
+
? toEndpointId
|
|
105
|
+
: null;
|
|
106
|
+
const immediate = resolve();
|
|
107
|
+
if (immediate)
|
|
108
|
+
return immediate;
|
|
109
|
+
// Startup window: the listener is up before deferred session discovery
|
|
110
|
+
// finishes. Wait (bounded) for it instead of returning a terminal 404
|
|
111
|
+
// for an endpoint that exists moments later.
|
|
112
|
+
let timeout = null;
|
|
113
|
+
try {
|
|
114
|
+
await Promise.race([
|
|
115
|
+
runtime.whenReady(),
|
|
116
|
+
new Promise((res) => {
|
|
117
|
+
timeout = setTimeout(res, 10_000);
|
|
118
|
+
timeout.unref?.();
|
|
119
|
+
}),
|
|
120
|
+
]);
|
|
121
|
+
}
|
|
122
|
+
finally {
|
|
123
|
+
if (timeout)
|
|
124
|
+
clearTimeout(timeout);
|
|
125
|
+
}
|
|
126
|
+
return resolve();
|
|
127
|
+
},
|
|
128
|
+
logger,
|
|
129
|
+
onMessage: async (msg, endpointId) => {
|
|
130
|
+
if (!recvLimit(msg.from.instanceId))
|
|
131
|
+
return "full";
|
|
132
|
+
const status = await runtime.receive(msg, endpointId, policy);
|
|
133
|
+
// Fire-and-forget, but never let a rejection escape: an unhandled
|
|
134
|
+
// rejection would crash the host opencode process.
|
|
135
|
+
void dispatchAcknowledgements().catch((err) => logger("warn", "acknowledgement dispatch failed; will retry", { error: String(err) }));
|
|
136
|
+
return status;
|
|
137
|
+
},
|
|
138
|
+
onAcknowledgement: async (acknowledgement) => {
|
|
139
|
+
if (!(await outbox.applyAcknowledgement(acknowledgement))) {
|
|
140
|
+
await logger("warn", "ignored unmatched peer acknowledgement", {
|
|
141
|
+
messageId: acknowledgement.messageId,
|
|
142
|
+
fromEndpointId: acknowledgement.fromEndpointId,
|
|
143
|
+
toEndpointId: acknowledgement.toEndpointId,
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
const { url: transportUrl, compatibilityUrl: inboxUrl, address: transport } = await listener.start();
|
|
149
|
+
const latestEndpoint = () => {
|
|
150
|
+
const compatibilityId = runtime.compatibilityEndpointId();
|
|
151
|
+
return runtime.registryEndpoints().find((endpoint) => endpoint.endpointId === compatibilityId);
|
|
152
|
+
};
|
|
153
|
+
const registry = Registry({
|
|
154
|
+
peersDir: config.peersDir,
|
|
155
|
+
instanceId,
|
|
156
|
+
pid: process.pid,
|
|
157
|
+
directory: ctx.directory,
|
|
158
|
+
serverUrl: ctx.serverUrl?.toString() ?? "",
|
|
159
|
+
inboxUrl,
|
|
160
|
+
inboxToken,
|
|
161
|
+
pluginVersion: PLUGIN_VERSION,
|
|
162
|
+
heartbeatMs: config.heartbeatMs,
|
|
163
|
+
staleMs: config.staleMs,
|
|
164
|
+
getDynamic: () => {
|
|
165
|
+
const latest = latestEndpoint();
|
|
166
|
+
return {
|
|
167
|
+
name: currentName,
|
|
168
|
+
inboundPolicy: policy,
|
|
169
|
+
activeSessionId: latest?.sessionId ?? null,
|
|
170
|
+
activeSessionTitle: latest?.title ?? null,
|
|
171
|
+
busy: latest ? latest.status !== "idle" : false,
|
|
172
|
+
queuedCount: latest?.queuedCount ?? 0,
|
|
173
|
+
};
|
|
174
|
+
},
|
|
175
|
+
getEndpoints: () => {
|
|
176
|
+
const base = runtime.publishableEndpoints();
|
|
177
|
+
const seen = new Set(base.map((endpoint) => endpoint.endpointId));
|
|
178
|
+
// Also keep advertising sessions that still owe an outbound message
|
|
179
|
+
// (no final ACK yet). Otherwise a sender that goes idle on a
|
|
180
|
+
// non-representative session would stop being advertised, and the
|
|
181
|
+
// receiver could never route the final ACK back to it — the sender's
|
|
182
|
+
// outbox would stay stuck on "awaiting final ACK" forever.
|
|
183
|
+
const owing = runtime
|
|
184
|
+
.registryEndpoints()
|
|
185
|
+
.filter((endpoint) => !seen.has(endpoint.endpointId) &&
|
|
186
|
+
(outbox.list(endpoint.endpointId) ?? []).some((record) => !record.finalStatus));
|
|
187
|
+
return [...base, ...owing];
|
|
188
|
+
},
|
|
189
|
+
getCompatibilityEndpointId: runtime.compatibilityEndpointId,
|
|
190
|
+
transport,
|
|
191
|
+
peerPermissions: config.peerPermissions,
|
|
192
|
+
logger,
|
|
193
|
+
});
|
|
194
|
+
await registry.start();
|
|
195
|
+
const acknowledgementTransport = LocalTransport();
|
|
196
|
+
dispatchAcknowledgements = async () => {
|
|
197
|
+
const pending = runtime.pendingAcknowledgements();
|
|
198
|
+
if (pending.length === 0)
|
|
199
|
+
return;
|
|
200
|
+
const peers = await registry.list();
|
|
201
|
+
for (const { queue, acknowledgement } of pending) {
|
|
202
|
+
const target = peers.find((peer) => peer.alive && peer.entry.version === 2 &&
|
|
203
|
+
peer.entry.endpointId === acknowledgement.fromEndpointId)?.entry;
|
|
204
|
+
if (!target || target.version !== 2)
|
|
205
|
+
continue;
|
|
206
|
+
try {
|
|
207
|
+
await acknowledgementTransport.ack(target, acknowledgement);
|
|
208
|
+
await queue.markAcknowledgementSent(acknowledgement);
|
|
209
|
+
}
|
|
210
|
+
catch (err) {
|
|
211
|
+
await logger("warn", "failed to return peer acknowledgement; will retry", {
|
|
212
|
+
error: String(err),
|
|
213
|
+
messageId: acknowledgement.messageId,
|
|
214
|
+
senderEndpointId: acknowledgement.fromEndpointId,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
const sender = Sender({
|
|
220
|
+
self: {
|
|
221
|
+
get instanceId() {
|
|
222
|
+
return instanceId;
|
|
223
|
+
},
|
|
224
|
+
get name() {
|
|
225
|
+
return currentName;
|
|
226
|
+
},
|
|
227
|
+
directory: ctx.directory,
|
|
228
|
+
},
|
|
229
|
+
outbox,
|
|
230
|
+
});
|
|
231
|
+
const sweepReliability = async () => {
|
|
232
|
+
if (disposing)
|
|
233
|
+
return;
|
|
234
|
+
try {
|
|
235
|
+
await runtime.sweep();
|
|
236
|
+
await dispatchAcknowledgements();
|
|
237
|
+
}
|
|
238
|
+
catch (err) {
|
|
239
|
+
await logger("error", "reliability sweep failed", { error: errorMessage(err) });
|
|
240
|
+
}
|
|
241
|
+
};
|
|
242
|
+
const permissions = PeerPermissions({
|
|
243
|
+
client: ctx.client,
|
|
244
|
+
mode: () => config.peerPermissions,
|
|
245
|
+
directory: ctx.directory,
|
|
246
|
+
logger,
|
|
247
|
+
});
|
|
248
|
+
let disposing = false;
|
|
249
|
+
let disposePromise = null;
|
|
250
|
+
let discoveryTimer = null;
|
|
251
|
+
// Fallback sweep: session.idle is not guaranteed on every version/scenario,
|
|
252
|
+
// so poll idle state periodically as a safety net.
|
|
253
|
+
const sweeper = setInterval(() => {
|
|
254
|
+
void sweepReliability();
|
|
255
|
+
}, config.sweepMs);
|
|
256
|
+
sweeper.unref?.();
|
|
257
|
+
const hooks = {
|
|
258
|
+
config: async (input) => {
|
|
259
|
+
// Register our slash commands server-side; without this a fresh install
|
|
260
|
+
// has no /peers* commands (opencode does not scan plugin packages for
|
|
261
|
+
// commands/*.md). Never override a user-defined command.
|
|
262
|
+
input.command = input.command ?? {};
|
|
263
|
+
for (const [name, definition] of Object.entries(INJECTED_COMMANDS)) {
|
|
264
|
+
input.command[name] ??= definition;
|
|
265
|
+
}
|
|
266
|
+
},
|
|
267
|
+
event: async ({ event }) => {
|
|
268
|
+
if (disposing)
|
|
269
|
+
return;
|
|
270
|
+
const e = event;
|
|
271
|
+
const changed = await runtime.handleEvent(e);
|
|
272
|
+
if (changed && !disposing)
|
|
273
|
+
await registry.heartbeat();
|
|
274
|
+
if (disposing)
|
|
275
|
+
return;
|
|
276
|
+
await permissions.handleEvent(e);
|
|
277
|
+
},
|
|
278
|
+
"chat.message": async (input) => {
|
|
279
|
+
if (disposing)
|
|
280
|
+
return;
|
|
281
|
+
await runtime.noteActivity(input.sessionID);
|
|
282
|
+
if (!disposing)
|
|
283
|
+
await registry.heartbeat();
|
|
284
|
+
},
|
|
285
|
+
"command.execute.before": async (input, output) => {
|
|
286
|
+
if (disposing)
|
|
287
|
+
return;
|
|
288
|
+
if (!COMMAND_NAMES.has(input.command))
|
|
289
|
+
return;
|
|
290
|
+
await runtime.noteActivity(input.sessionID);
|
|
291
|
+
const queue = runtime.queueForSession(input.sessionID);
|
|
292
|
+
const delivery = runtime.deliveryForSession(input.sessionID);
|
|
293
|
+
if (!queue || !delivery)
|
|
294
|
+
return;
|
|
295
|
+
let message;
|
|
296
|
+
try {
|
|
297
|
+
const result = await handlePeersCommand({
|
|
298
|
+
registry,
|
|
299
|
+
queue,
|
|
300
|
+
delivery,
|
|
301
|
+
getName: () => currentName,
|
|
302
|
+
setName: async (name) => {
|
|
303
|
+
if (name === currentName) {
|
|
304
|
+
await registry.heartbeat();
|
|
305
|
+
return { name, taken: false };
|
|
306
|
+
}
|
|
307
|
+
const peers = await registry.list();
|
|
308
|
+
const taken = peers.some((p) => p.alive && p.entry.name === name);
|
|
309
|
+
if (taken) {
|
|
310
|
+
return { name, taken: true };
|
|
311
|
+
}
|
|
312
|
+
currentName = name;
|
|
313
|
+
await registry.heartbeat();
|
|
314
|
+
return { name, taken: false };
|
|
315
|
+
},
|
|
316
|
+
selfInstanceId: instanceId,
|
|
317
|
+
selfEndpointId: runtime.endpointIdForSession(input.sessionID) ?? instanceId,
|
|
318
|
+
outbox,
|
|
319
|
+
}, input.command, input.arguments || "");
|
|
320
|
+
message = result.message ?? "✅ Done.";
|
|
321
|
+
await dispatchAcknowledgements();
|
|
322
|
+
}
|
|
323
|
+
catch (err) {
|
|
324
|
+
message = `❌ /${input.command} failed: ${errorMessage(err)}`;
|
|
325
|
+
}
|
|
326
|
+
consumeCommand(output.parts, message);
|
|
327
|
+
await logger(message.startsWith("❌") ? "error" : "info", message, {
|
|
328
|
+
command: input.command,
|
|
329
|
+
});
|
|
330
|
+
},
|
|
331
|
+
};
|
|
332
|
+
hooks.tool = buildPeerTools({
|
|
333
|
+
registry,
|
|
334
|
+
sender,
|
|
335
|
+
sendLimit,
|
|
336
|
+
maxMessageBytes: config.maxMessageBytes,
|
|
337
|
+
selfName: () => currentName,
|
|
338
|
+
selfInstanceId: instanceId,
|
|
339
|
+
endpointForSession: (sessionId) => {
|
|
340
|
+
const endpointId = runtime.endpointIdForSession(sessionId);
|
|
341
|
+
const endpoint = runtime.registryEndpoints().find((entry) => entry.sessionId === sessionId);
|
|
342
|
+
return endpointId && endpoint
|
|
343
|
+
? { endpointId, name: endpoint.name, directory: endpoint.directory }
|
|
344
|
+
: null;
|
|
345
|
+
},
|
|
346
|
+
outbox,
|
|
347
|
+
});
|
|
348
|
+
hooks.dispose = () => {
|
|
349
|
+
if (disposePromise)
|
|
350
|
+
return disposePromise;
|
|
351
|
+
disposing = true;
|
|
352
|
+
if (discoveryTimer)
|
|
353
|
+
clearTimeout(discoveryTimer);
|
|
354
|
+
discoveryTimer = null;
|
|
355
|
+
clearInterval(sweeper);
|
|
356
|
+
const registryStopping = registry.stop();
|
|
357
|
+
const runtimeStopping = runtime.stop();
|
|
358
|
+
disposePromise = Promise.all([
|
|
359
|
+
registryStopping,
|
|
360
|
+
runtimeStopping,
|
|
361
|
+
listener.stop(),
|
|
362
|
+
acknowledgementTransport.close(),
|
|
363
|
+
]).then(() => undefined);
|
|
364
|
+
return disposePromise;
|
|
365
|
+
};
|
|
366
|
+
await logger("info", "opencode-collaboration started", {
|
|
367
|
+
instanceId,
|
|
368
|
+
name: currentName,
|
|
369
|
+
inboxUrl,
|
|
370
|
+
transportUrl,
|
|
371
|
+
policy,
|
|
372
|
+
});
|
|
373
|
+
// OpenCode constructs plugins while servicing the first session request.
|
|
374
|
+
// Calling this server's session API before returning hooks deadlocks that
|
|
375
|
+
// request, so discover pre-existing sessions only after bootstrap unwinds.
|
|
376
|
+
discoveryTimer = setTimeout(() => {
|
|
377
|
+
discoveryTimer = null;
|
|
378
|
+
if (disposing)
|
|
379
|
+
return;
|
|
380
|
+
void runtime.initialize()
|
|
381
|
+
.then(async () => {
|
|
382
|
+
if (!disposing)
|
|
383
|
+
await registry.heartbeat();
|
|
384
|
+
})
|
|
385
|
+
.catch((err) => logger("warn", "deferred session discovery failed", { error: String(err) }));
|
|
386
|
+
}, 0);
|
|
387
|
+
discoveryTimer.unref?.();
|
|
388
|
+
return hooks;
|
|
389
|
+
};
|
|
390
|
+
// OpenCode v1 detects the default {id, server} object before its legacy
|
|
391
|
+
// loader scans named exports.
|
|
392
|
+
export const plugin = {
|
|
393
|
+
id: "opencode-collaboration",
|
|
394
|
+
server: PeersPlugin,
|
|
395
|
+
};
|
|
396
|
+
export default plugin;
|
|
397
|
+
export { Registry, uniqueName } from "./registry.js";
|
|
398
|
+
export { MessageQueue, RateLimiter, createProcessMessageQueue, createSessionMessageQueue, hasSpoolRecords, migrateWorkspaceSpool, stableSessionEndpointId, stableSpoolEndpointId, } from "./queue.js";
|
|
399
|
+
export { SessionTracker } from "./session-tracker.js";
|
|
400
|
+
export { SessionRuntime } from "./session-runtime.js";
|
|
401
|
+
export { Delivery, deterministicPeerMessageId, formatMessages } from "./delivery.js";
|
|
402
|
+
export { Sender, buildMessage, buildMessageV2 } from "./sender.js";
|
|
403
|
+
export { InboxListener } from "./listener.js";
|
|
404
|
+
export { LocalTransport } from "./transport.js";
|
|
405
|
+
export { gateMessage } from "./gating.js";
|
|
406
|
+
export { PeerPermissions, isProtectedPermission } from "./permissions.js";
|
|
407
|
+
export { Outbox } from "./outbox.js";
|
|
408
|
+
export { collapseToProcesses, formatSessionList, relativeAge, sortPeers } from "./format.js";
|
|
409
|
+
export { resolveConfig, validateName } from "./config.js";
|
|
410
|
+
export * from "./types.js";
|
|
@@ -0,0 +1,37 @@
|
|
|
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 type { InboundMessage, LocalTransportAddress, Logger, PeerAcknowledgementV2, ReceiveStatus } from "./types.js";
|
|
7
|
+
export interface MessageRoute {
|
|
8
|
+
version: 1 | 2;
|
|
9
|
+
toEndpointId?: string;
|
|
10
|
+
}
|
|
11
|
+
export interface ListenerOptions {
|
|
12
|
+
token: string;
|
|
13
|
+
maxBodyBytes: number;
|
|
14
|
+
/** Receiver-side text limit, measured as UTF-8 bytes. */
|
|
15
|
+
maxMessageBytes?: number;
|
|
16
|
+
/** Maximum permitted clock age/skew for a sender timestamp. */
|
|
17
|
+
maxMessageAgeMs?: number;
|
|
18
|
+
runtimeDir?: string;
|
|
19
|
+
processId?: string;
|
|
20
|
+
platform?: NodeJS.Platform;
|
|
21
|
+
resolveEndpoint?: (route: MessageRoute) => string | null | Promise<string | null>;
|
|
22
|
+
onMessage: (msg: InboundMessage, endpointId?: string) => Promise<ReceiveStatus>;
|
|
23
|
+
onAcknowledgement?: (ack: PeerAcknowledgementV2) => Promise<void>;
|
|
24
|
+
logger: Logger;
|
|
25
|
+
}
|
|
26
|
+
export interface ListenerInstance {
|
|
27
|
+
start: () => Promise<{
|
|
28
|
+
port: number;
|
|
29
|
+
url: string;
|
|
30
|
+
address: LocalTransportAddress;
|
|
31
|
+
/** Ordinary loopback HTTP URL published to protocol-v1 peers. */
|
|
32
|
+
compatibilityUrl: string;
|
|
33
|
+
}>;
|
|
34
|
+
stop: () => Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
export declare function defaultRuntimeDirectory(env?: NodeJS.ProcessEnv, uid?: number): string;
|
|
37
|
+
export declare function InboxListener(opts: ListenerOptions): ListenerInstance;
|