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,355 @@
1
+ import { Delivery } from "./delivery.js";
2
+ import { gateMessage } from "./gating.js";
3
+ import { createSessionMessageQueue, hasSpoolRecords, migrateWorkspaceSpool, stableSessionEndpointId, } from "./queue.js";
4
+ import { SessionTracker } from "./session-tracker.js";
5
+ function responseData(response) {
6
+ return response?.data;
7
+ }
8
+ function normalizeStatus(status) {
9
+ const type = status?.type;
10
+ return type === "busy" || type === "retry" ? type : "idle";
11
+ }
12
+ export function SessionRuntime(opts) {
13
+ const endpoints = new Map();
14
+ const pendingOperations = new Set();
15
+ let lifecycle = "running";
16
+ let stopPromise = null;
17
+ let readyPromise = null;
18
+ let markReady = () => { };
19
+ function whileRunning(fallback, operation) {
20
+ if (lifecycle !== "running")
21
+ return Promise.resolve(fallback);
22
+ const pending = operation();
23
+ pendingOperations.add(pending);
24
+ void pending.finally(() => pendingOperations.delete(pending)).catch(() => { });
25
+ return pending;
26
+ }
27
+ function compatibilityEndpoint(candidates = [...endpoints.values()]) {
28
+ const roots = candidates.filter((candidate) => !candidate.session.parentID);
29
+ return (roots.length > 0 ? roots : candidates).slice().sort((a, b) => b.updatedAt - a.updatedAt ||
30
+ b.session.time.created - a.session.time.created ||
31
+ b.session.id.localeCompare(a.session.id))[0] ?? null;
32
+ }
33
+ async function upsert(session, status) {
34
+ const current = endpoints.get(session.id);
35
+ if (current) {
36
+ current.session = session;
37
+ current.updatedAt = Math.max(current.updatedAt, session.time.updated);
38
+ if (status)
39
+ setStatus(current, status);
40
+ return current;
41
+ }
42
+ const queue = createSessionMessageQueue({ config: opts.config, sessionId: session.id, logger: opts.logger });
43
+ await queue.loadHeld();
44
+ const tracker = SessionTracker();
45
+ tracker.noteIdle(session.id);
46
+ const endpoint = {
47
+ session,
48
+ endpointId: stableSessionEndpointId(session.id),
49
+ status: status ?? "idle",
50
+ updatedAt: session.time.updated,
51
+ queue,
52
+ tracker,
53
+ delivery: undefined,
54
+ };
55
+ if (endpoint.status !== "idle")
56
+ tracker.noteBusy(session.id);
57
+ endpoint.delivery = Delivery({
58
+ client: opts.client,
59
+ tracker,
60
+ queue,
61
+ directory: session.directory || opts.directory,
62
+ logger: opts.logger,
63
+ immediate: true,
64
+ });
65
+ endpoints.set(session.id, endpoint);
66
+ return endpoint;
67
+ }
68
+ function setStatus(endpoint, status) {
69
+ endpoint.status = status;
70
+ endpoint.updatedAt = Math.max(endpoint.updatedAt, Date.now());
71
+ if (status === "idle")
72
+ endpoint.tracker.noteIdle(endpoint.session.id);
73
+ else
74
+ endpoint.tracker.noteBusy(endpoint.session.id);
75
+ }
76
+ async function loadChildren(root, statuses = {}) {
77
+ const seen = new Set();
78
+ const pending = [root];
79
+ while (pending.length > 0) {
80
+ const parent = pending.shift();
81
+ if (seen.has(parent.id))
82
+ continue;
83
+ seen.add(parent.id);
84
+ try {
85
+ const response = await opts.client.session.children({
86
+ path: { id: parent.id },
87
+ query: { directory: parent.directory || opts.directory },
88
+ });
89
+ for (const child of responseData(response) ?? []) {
90
+ const childStatus = Object.prototype.hasOwnProperty.call(statuses, child.id)
91
+ ? normalizeStatus(statuses[child.id])
92
+ : undefined;
93
+ await upsert(child, childStatus);
94
+ pending.push(child);
95
+ }
96
+ }
97
+ catch (err) {
98
+ await opts.logger("debug", "failed to list session children", {
99
+ error: String(err),
100
+ sessionId: parent.id,
101
+ });
102
+ }
103
+ }
104
+ }
105
+ async function findSession(sessionId) {
106
+ const known = endpoints.get(sessionId);
107
+ if (known)
108
+ return known;
109
+ try {
110
+ const response = await opts.client.session.get({
111
+ path: { id: sessionId },
112
+ query: { directory: opts.directory },
113
+ });
114
+ const session = responseData(response);
115
+ return session ? upsert(session) : null;
116
+ }
117
+ catch {
118
+ return null;
119
+ }
120
+ }
121
+ return {
122
+ initialize() {
123
+ if (!readyPromise) {
124
+ readyPromise = new Promise((resolve) => {
125
+ markReady = resolve;
126
+ });
127
+ }
128
+ return whileRunning(undefined, async () => {
129
+ const [listedResponse, statusResponse] = await Promise.all([
130
+ opts.client.session.list({ query: { directory: opts.directory } }),
131
+ opts.client.session.status({ query: { directory: opts.directory } }),
132
+ ]);
133
+ const sessions = responseData(listedResponse) ?? [];
134
+ const statuses = responseData(statusResponse) ?? {};
135
+ const migrationTarget = (sessions.filter((candidate) => !candidate.parentID).length > 0
136
+ ? sessions.filter((candidate) => !candidate.parentID)
137
+ : sessions).slice().sort((a, b) => b.time.updated - a.time.updated || b.time.created - a.time.created || b.id.localeCompare(a.id))[0];
138
+ if (migrationTarget) {
139
+ await migrateWorkspaceSpool({
140
+ config: opts.config,
141
+ directory: opts.directory,
142
+ targetSessionId: migrationTarget.id,
143
+ logger: opts.logger,
144
+ });
145
+ }
146
+ // Adopt only sessions that are alive IN THIS PROCESS:
147
+ // - non-idle in the status snapshot (a real server keeps only
148
+ // busy/retry entries there, children included), or
149
+ // - holding undelivered peer state in their durable spool (restart
150
+ // recovery; done/ records alone do not count).
151
+ // Historical sessions from session.list() stay unpublished until real
152
+ // activity arrives via events, chat.message, or commands.
153
+ const listed = new Map(sessions.map((candidate) => [candidate.id, candidate]));
154
+ for (const [sessionId, raw] of Object.entries(statuses)) {
155
+ const status = normalizeStatus(raw);
156
+ if (status === "idle")
157
+ continue;
158
+ let session = listed.get(sessionId);
159
+ if (!session) {
160
+ // busy child of an idle/historical root: not in the root list
161
+ try {
162
+ const response = await opts.client.session.get({
163
+ path: { id: sessionId },
164
+ query: { directory: opts.directory },
165
+ });
166
+ session = responseData(response);
167
+ }
168
+ catch {
169
+ session = undefined;
170
+ }
171
+ }
172
+ if (session)
173
+ await upsert(session, status);
174
+ }
175
+ for (const session of sessions) {
176
+ if (endpoints.has(session.id))
177
+ continue;
178
+ if (hasSpoolRecords(opts.config, session.id)) {
179
+ const endpoint = await upsert(session, normalizeStatus(statuses[session.id]));
180
+ // Restart recovery: deliver what the previous run could not.
181
+ await endpoint.delivery.flush();
182
+ }
183
+ }
184
+ // No startup child traversal: busy children are already covered by the
185
+ // flat status snapshot, and anything else becomes visible through
186
+ // session.created/updated events. Traversing children of adopted
187
+ // roots would re-adopt idle historical subagent sessions.
188
+ }).finally(() => markReady());
189
+ },
190
+ whenReady() {
191
+ // Ready means "the first discovery pass has settled". Never rejects;
192
+ // callers should bound their wait if a hang would be a problem.
193
+ if (!readyPromise) {
194
+ readyPromise = new Promise((resolve) => {
195
+ markReady = resolve;
196
+ });
197
+ }
198
+ return readyPromise;
199
+ },
200
+ stop() {
201
+ if (stopPromise)
202
+ return stopPromise;
203
+ lifecycle = "stopping";
204
+ markReady(); // release whenReady waiters; no discovery will happen now
205
+ stopPromise = (async () => {
206
+ await Promise.allSettled([...pendingOperations]);
207
+ lifecycle = "stopped";
208
+ })();
209
+ return stopPromise;
210
+ },
211
+ registryEndpoints() {
212
+ return [...endpoints.values()].map((endpoint) => ({
213
+ endpointId: endpoint.endpointId,
214
+ sessionId: endpoint.session.id,
215
+ ...(endpoint.session.parentID ? { parentSessionId: endpoint.session.parentID } : {}),
216
+ title: endpoint.session.title,
217
+ name: opts.name(),
218
+ directory: endpoint.session.directory || opts.directory,
219
+ status: endpoint.status,
220
+ startedAt: endpoint.session.time.created,
221
+ updatedAt: endpoint.updatedAt,
222
+ queuedCount: endpoint.queue.size(),
223
+ }));
224
+ },
225
+ // Endpoints actually written to the shared registry. Only the process's
226
+ // representative session (most recently active) plus any busy/queued
227
+ // sessions are announced. Idle historical sessions that opencode replays
228
+ // at startup stay internal: they remain reachable by an exact endpoint ID
229
+ // a peer already knows, but they are not flooded into the registry under
230
+ // the process name (which made name-based routing ambiguous and could
231
+ // deliver a message into a background session the user cannot see).
232
+ publishableEndpoints() {
233
+ const all = [...endpoints.values()];
234
+ const representative = compatibilityEndpoint(all);
235
+ return all
236
+ .filter((endpoint) => endpoint === representative ||
237
+ endpoint.status !== "idle" ||
238
+ endpoint.queue.size() > 0)
239
+ .map((endpoint) => ({
240
+ endpointId: endpoint.endpointId,
241
+ sessionId: endpoint.session.id,
242
+ ...(endpoint.session.parentID ? { parentSessionId: endpoint.session.parentID } : {}),
243
+ title: endpoint.session.title,
244
+ name: opts.name(),
245
+ directory: endpoint.session.directory || opts.directory,
246
+ status: endpoint.status,
247
+ startedAt: endpoint.session.time.created,
248
+ updatedAt: endpoint.updatedAt,
249
+ queuedCount: endpoint.queue.size(),
250
+ }));
251
+ },
252
+ compatibilityEndpointId() {
253
+ return compatibilityEndpoint()?.endpointId ?? null;
254
+ },
255
+ hasEndpoint(endpointId) {
256
+ return [...endpoints.values()].some((endpoint) => endpoint.endpointId === endpointId);
257
+ },
258
+ endpointIdForSession(sessionId) {
259
+ return endpoints.get(sessionId)?.endpointId ?? null;
260
+ },
261
+ receive(message, endpointId, policy) {
262
+ return whileRunning("dropped", async () => {
263
+ const endpoint = [...endpoints.values()].find((candidate) => candidate.endpointId === endpointId);
264
+ if (!endpoint)
265
+ return "dropped";
266
+ const existing = endpoint.queue.existingStatus(message);
267
+ if (existing)
268
+ return existing;
269
+ if (endpoint.queue.isDebounced(message))
270
+ return "duplicate";
271
+ const decision = gateMessage(policy, message, endpoint.session.directory || opts.directory);
272
+ if (decision === "refuse")
273
+ return (await endpoint.queue.refuse(message)).status;
274
+ if (decision === "hold") {
275
+ if (!(await endpoint.queue.hold(message)))
276
+ return "full";
277
+ void endpoint.delivery.notice(`📥 Held message from "${message.from.name}" — /peers-inbox to review`);
278
+ return "held";
279
+ }
280
+ if (!endpoint.queue.enqueue(message))
281
+ return endpoint.queue.existingStatus(message) ?? "full";
282
+ await endpoint.delivery.flush();
283
+ return endpoint.queue.existingStatus(message) ?? "queued";
284
+ });
285
+ },
286
+ handleEvent(event) {
287
+ return whileRunning(false, async () => {
288
+ const properties = event.properties ?? {};
289
+ const info = properties.info;
290
+ if (event.type === "session.created" || event.type === "session.updated") {
291
+ if (!info?.id)
292
+ return false;
293
+ await upsert(info);
294
+ if (event.type === "session.created")
295
+ await loadChildren(info);
296
+ return true;
297
+ }
298
+ if (event.type === "session.deleted") {
299
+ if (!info?.id)
300
+ return false;
301
+ const deleted = new Set([info.id]);
302
+ let changed = true;
303
+ while (changed) {
304
+ changed = false;
305
+ for (const endpoint of endpoints.values()) {
306
+ if (endpoint.session.parentID && deleted.has(endpoint.session.parentID) && !deleted.has(endpoint.session.id)) {
307
+ deleted.add(endpoint.session.id);
308
+ changed = true;
309
+ }
310
+ }
311
+ }
312
+ for (const sessionId of deleted)
313
+ endpoints.delete(sessionId);
314
+ return true;
315
+ }
316
+ if (event.type === "session.status" || event.type === "session.idle") {
317
+ const sessionId = properties.sessionID;
318
+ if (!sessionId)
319
+ return false;
320
+ const endpoint = await findSession(sessionId);
321
+ if (!endpoint)
322
+ return false;
323
+ setStatus(endpoint, event.type === "session.idle" ? "idle" : normalizeStatus(properties.status));
324
+ return true;
325
+ }
326
+ return false;
327
+ });
328
+ },
329
+ noteActivity(sessionId) {
330
+ return whileRunning(undefined, async () => {
331
+ const endpoint = await findSession(sessionId);
332
+ if (endpoint)
333
+ setStatus(endpoint, "busy");
334
+ });
335
+ },
336
+ queueForSession(sessionId) {
337
+ return endpoints.get(sessionId)?.queue ?? null;
338
+ },
339
+ deliveryForSession(sessionId) {
340
+ return endpoints.get(sessionId)?.delivery ?? null;
341
+ },
342
+ sweep() {
343
+ return whileRunning(undefined, async () => {
344
+ for (const endpoint of endpoints.values()) {
345
+ await endpoint.queue.expireHeld();
346
+ await endpoint.delivery.flush();
347
+ }
348
+ });
349
+ },
350
+ pendingAcknowledgements() {
351
+ return [...endpoints.values()].flatMap((endpoint) => endpoint.queue.pendingAcknowledgements()
352
+ .map((acknowledgement) => ({ queue: endpoint.queue, acknowledgement })));
353
+ },
354
+ };
355
+ }
@@ -0,0 +1,16 @@
1
+ /**
2
+ * Tracks the "active" session of this opencode server instance and whether
3
+ * it is idle. opencode plugins are per-server, not per-session, so the
4
+ * active session is a heuristic: the session that most recently produced
5
+ * user activity.
6
+ */
7
+ export interface SessionTrackerInstance {
8
+ activeSessionId: () => string | null;
9
+ activeSessionTitle: () => string | null;
10
+ isIdle: () => boolean;
11
+ noteUserActivity: (sessionId: string, title?: string | null) => void;
12
+ noteIdle: (sessionId?: string) => void;
13
+ noteBusy: (sessionId?: string) => void;
14
+ noteDeleted: (sessionId: string) => void;
15
+ }
16
+ export declare function SessionTracker(): SessionTrackerInstance;
@@ -0,0 +1,39 @@
1
+ /**
2
+ * Tracks the "active" session of this opencode server instance and whether
3
+ * it is idle. opencode plugins are per-server, not per-session, so the
4
+ * active session is a heuristic: the session that most recently produced
5
+ * user activity.
6
+ */
7
+ export function SessionTracker() {
8
+ let activeId = null;
9
+ let activeTitle = null;
10
+ let idle = true;
11
+ return {
12
+ activeSessionId: () => activeId,
13
+ activeSessionTitle: () => activeTitle,
14
+ isIdle: () => idle,
15
+ noteUserActivity(sessionId, title) {
16
+ activeId = sessionId;
17
+ if (title)
18
+ activeTitle = title;
19
+ idle = false;
20
+ },
21
+ noteIdle(sessionId) {
22
+ if (!sessionId || sessionId === activeId)
23
+ idle = true;
24
+ if (!activeId && sessionId)
25
+ activeId = sessionId;
26
+ },
27
+ noteBusy(sessionId) {
28
+ if (!sessionId || sessionId === activeId)
29
+ idle = false;
30
+ },
31
+ noteDeleted(sessionId) {
32
+ if (activeId === sessionId) {
33
+ activeId = null;
34
+ activeTitle = null;
35
+ idle = true;
36
+ }
37
+ },
38
+ };
39
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * LLM-callable tools:
3
+ * - list_agents: discover same-machine opencode peers
4
+ * - send_message: send a plain-text message to a peer
5
+ */
6
+ import { type ToolDefinition } from "@opencode-ai/plugin/tool";
7
+ import type { ListedPeer, RegistryInstance } from "../registry.js";
8
+ import type { Sender } from "../sender.js";
9
+ import type { RateLimiter } from "../queue.js";
10
+ import type { OutboxInstance } from "../outbox.js";
11
+ export interface ToolsDeps {
12
+ registry: RegistryInstance;
13
+ sender: ReturnType<typeof Sender>;
14
+ sendLimit: ReturnType<typeof RateLimiter>;
15
+ maxMessageBytes: number;
16
+ selfName: () => string;
17
+ selfInstanceId: string;
18
+ endpointForSession?: (sessionId: string) => {
19
+ endpointId: string;
20
+ name: string;
21
+ directory: string;
22
+ } | null;
23
+ outbox?: Pick<OutboxInstance, "get" | "list">;
24
+ }
25
+ export declare function formatPeerList(peers: ListedPeer[], selfName: string, selfId: string): string;
26
+ export declare function buildPeerTools(deps: ToolsDeps): Record<string, ToolDefinition>;
@@ -0,0 +1,173 @@
1
+ /**
2
+ * LLM-callable tools:
3
+ * - list_agents: discover same-machine opencode peers
4
+ * - send_message: send a plain-text message to a peer
5
+ */
6
+ import { tool } from "@opencode-ai/plugin/tool";
7
+ import { z } from "zod";
8
+ import { collapseToProcesses, sortPeers } from "../format.js";
9
+ function entryId(entry) {
10
+ return entry.version === 2 ? entry.endpointId : entry.instanceId;
11
+ }
12
+ function isEndpointShaped(target) {
13
+ return /^(?:session|workspace)-[A-Za-z0-9][A-Za-z0-9_-]*$/.test(target);
14
+ }
15
+ export function formatPeerList(peers, selfName, selfId) {
16
+ const online = sortPeers(collapseToProcesses(peers.filter((p) => p.alive)));
17
+ const offline = sortPeers(collapseToProcesses(peers.filter((p) => !p.alive)));
18
+ const lines = [];
19
+ if (online.length === 0) {
20
+ lines.push("No peers online.");
21
+ }
22
+ else {
23
+ lines.push(`${online.length} peer(s) online:`);
24
+ for (const p of online) {
25
+ const e = p.entry;
26
+ const id = entryId(e);
27
+ const session = e.activeSessionId
28
+ ? `session ${e.activeSessionTitle ? `"${e.activeSessionTitle}" ` : ""}(${e.activeSessionId})`
29
+ : "(no active session)";
30
+ lines.push(`- "${e.name}" (id ${id}) — ${e.directory} — ${session} — inbound: ${e.inboundPolicy}`);
31
+ }
32
+ }
33
+ if (offline.length > 0) {
34
+ lines.push(`${offline.length} peer(s) stale/offline (hidden from targeting):`);
35
+ for (const p of offline) {
36
+ lines.push(`- "${p.entry.name}" (id ${entryId(p.entry)}) — ${p.staleReason}`);
37
+ }
38
+ }
39
+ lines.push(`You are "${selfName}" (id ${selfId}).`);
40
+ return lines.join("\n");
41
+ }
42
+ export function buildPeerTools(deps) {
43
+ return {
44
+ peer_message_status: tool({
45
+ description: "Query the durable receipt and final ACK status of a peer message sent by this session.",
46
+ args: {
47
+ message_id: z.string().describe("Message ID returned by send_message"),
48
+ },
49
+ async execute(args, context) {
50
+ const self = deps.endpointForSession?.(context.sessionID);
51
+ if (!self)
52
+ return `Error: sender session "${context.sessionID}" is not registered.`;
53
+ const record = deps.outbox?.get(self.endpointId, args.message_id);
54
+ if (!record)
55
+ return `Peer message "${args.message_id}" was not found in this session's outbox.`;
56
+ const receipt = record.receiptStatus ? `receipt: ${record.receiptStatus}` : "no transport receipt";
57
+ const final = record.finalStatus ? `final: ${record.finalStatus}` : "awaiting final ACK";
58
+ return `Message ${record.messageId} to "${record.toName}" — ${receipt}; ${final}${record.error ? `; error: ${record.error}` : ""}.`;
59
+ },
60
+ }),
61
+ list_agents: tool({
62
+ description: "List other opencode session endpoints on this machine that you can exchange plain-text messages with. Shows each endpoint's name, id, directory, session and inbound policy.",
63
+ args: {
64
+ include_offline: z
65
+ .boolean()
66
+ .optional()
67
+ .describe("Also list stale/offline registry entries (default false)"),
68
+ },
69
+ async execute(args, context) {
70
+ let peers;
71
+ try {
72
+ peers = await deps.registry.list();
73
+ }
74
+ catch (err) {
75
+ return `Failed to read peer registry: ${String(err)}`;
76
+ }
77
+ const self = deps.endpointForSession?.(context.sessionID);
78
+ const selfId = self?.endpointId ?? deps.selfInstanceId;
79
+ const shown = (args.include_offline ? peers : peers.filter((p) => p.alive))
80
+ .filter((peer) => entryId(peer.entry) !== selfId);
81
+ return formatPeerList(shown, self?.name ?? deps.selfName(), selfId);
82
+ },
83
+ }),
84
+ send_message: tool({
85
+ description: "Send a plain-text message immediately to an exact opencode session on this machine, including while it is busy. Text only — no files or conversation history. Resolve the target with list_agents first if unsure.",
86
+ args: {
87
+ to: z.string().describe("Peer name or instanceId (see list_agents)"),
88
+ message: z.string().describe("Plain-text message body"),
89
+ },
90
+ async execute(args, context) {
91
+ const text = args.message;
92
+ if (!text.trim())
93
+ return "Error: message must not be empty.";
94
+ if (Buffer.byteLength(text, "utf8") > deps.maxMessageBytes) {
95
+ return `Error: message exceeds ${deps.maxMessageBytes} bytes.`;
96
+ }
97
+ const self = deps.endpointForSession?.(context.sessionID);
98
+ if (deps.endpointForSession && !self) {
99
+ return `Error: sender session "${context.sessionID}" is not registered.`;
100
+ }
101
+ const selfId = self?.endpointId ?? deps.selfInstanceId;
102
+ const listed = await deps.registry.list();
103
+ const target = args.to.trim();
104
+ const knownExact = listed.find((peer) => entryId(peer.entry) === target);
105
+ if (knownExact) {
106
+ if (entryId(knownExact.entry) === selfId) {
107
+ return `Error: cannot send a peer message to your own endpoint "${target}" in the same session.`;
108
+ }
109
+ if (!knownExact.alive) {
110
+ return `Error: endpoint "${target}" appears offline (${knownExact.staleReason}).`;
111
+ }
112
+ }
113
+ else if (isEndpointShaped(target)) {
114
+ return `Error: unknown endpoint ID "${target}".`;
115
+ }
116
+ const peers = listed.filter((p) => p.alive && entryId(p.entry) !== selfId);
117
+ let matches = knownExact ? [knownExact] : peers.filter((p) => p.entry.name === target);
118
+ if (matches.length === 0) {
119
+ const stale = listed.find((p) => !p.alive && p.entry.name === target);
120
+ if (stale) {
121
+ return `Error: peer "${target}" appears offline (${stale.staleReason}).`;
122
+ }
123
+ const names = peers.map((p) => `"${p.entry.name}"`).join(", ") || "(none)";
124
+ return `Error: no peer named "${target}". Online peers: ${names}`;
125
+ }
126
+ if (matches.length > 1) {
127
+ // A single process publishes one endpoint per session (its current
128
+ // session plus any busy/queued ones), all sharing the process name.
129
+ // Collapse same-name endpoints to one representative per process
130
+ // (the most recently active session) so addressing by name lands on
131
+ // the session the user is actually working in. Only a genuine
132
+ // cross-process name clash is ambiguous.
133
+ const perProcess = collapseToProcesses(matches);
134
+ if (perProcess.length > 1) {
135
+ const candidates = perProcess
136
+ .map((p) => `"${p.entry.name}" (id ${entryId(p.entry)})`)
137
+ .join(", ");
138
+ return `Error: "${target}" is ambiguous across processes: ${candidates}. Use an endpoint ID.`;
139
+ }
140
+ matches = perProcess;
141
+ }
142
+ const peer = matches[0].entry;
143
+ if (!deps.sendLimit(entryId(peer))) {
144
+ return `Error: outbound rate limit reached for "${peer.name}"; try again in a minute.`;
145
+ }
146
+ const result = await deps.sender.send(peer, text, self ? {
147
+ instanceId: self.endpointId,
148
+ name: self.name,
149
+ directory: self.directory,
150
+ } : undefined);
151
+ if (!result.ok)
152
+ return `Error: ${result.error}`;
153
+ const tracking = result.messageId ? ` Tracking ID: ${result.messageId}.` : "";
154
+ switch (result.status) {
155
+ case "delivered":
156
+ return `Message delivered to "${peer.name}".${tracking}`;
157
+ case "duplicate":
158
+ return `Message was already received by "${peer.name}".`;
159
+ case "queued":
160
+ return `Message queued for "${peer.name}" (their session is busy); awaiting final delivery ACK.${tracking}`;
161
+ case "held":
162
+ return `"${peer.name}" reviews inbound messages manually; your message awaits their approval.${tracking}`;
163
+ case "refused":
164
+ return `Error: "${peer.name}" refuses inbound messages.`;
165
+ case "full":
166
+ return `Error: "${peer.name}" queue is full; try again later.`;
167
+ default:
168
+ return `Error: unexpected status from "${peer.name}".`;
169
+ }
170
+ },
171
+ }),
172
+ };
173
+ }
@@ -0,0 +1,20 @@
1
+ import type { InboundMessage, LocalTransportAddress, PeerAcknowledgementV2, PeerMessageV2, ReceiveStatus } from "./types.js";
2
+ export interface TransportTarget {
3
+ transport: LocalTransportAddress;
4
+ inboxToken: string;
5
+ }
6
+ export interface TransportResponse {
7
+ http: number;
8
+ status?: ReceiveStatus;
9
+ }
10
+ export interface Transport {
11
+ discover: () => Promise<TransportTarget[]>;
12
+ send: (target: TransportTarget, message: InboundMessage | PeerMessageV2) => Promise<TransportResponse>;
13
+ ack: (target: TransportTarget, acknowledgement: PeerAcknowledgementV2) => Promise<void>;
14
+ close: () => Promise<void>;
15
+ }
16
+ export interface LocalTransportOptions {
17
+ discover?: () => Promise<TransportTarget[]>;
18
+ timeoutMs?: number;
19
+ }
20
+ export declare function LocalTransport(opts?: LocalTransportOptions): Transport;
@@ -0,0 +1,46 @@
1
+ import { request } from "node:http";
2
+ export function LocalTransport(opts = {}) {
3
+ const timeoutMs = opts.timeoutMs ?? 3_000;
4
+ function post(target, path, body) {
5
+ return new Promise((resolve, reject) => {
6
+ const address = target.transport;
7
+ const req = request({
8
+ ...(address.type === "unix"
9
+ ? { socketPath: address.path, path }
10
+ : { hostname: address.host, port: address.port, path }),
11
+ method: "POST",
12
+ headers: {
13
+ "content-type": "application/json",
14
+ authorization: `Bearer ${target.inboxToken}`,
15
+ },
16
+ timeout: timeoutMs,
17
+ }, (res) => {
18
+ const chunks = [];
19
+ res.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
20
+ res.on("end", () => {
21
+ let status;
22
+ try {
23
+ status = JSON.parse(Buffer.concat(chunks).toString("utf8")).status;
24
+ }
25
+ catch {
26
+ // The HTTP status still carries the transport outcome.
27
+ }
28
+ resolve({ http: res.statusCode ?? 500, ...(status ? { status } : {}) });
29
+ });
30
+ });
31
+ req.on("timeout", () => req.destroy(new Error("local transport timed out")));
32
+ req.on("error", reject);
33
+ req.end(JSON.stringify(body));
34
+ });
35
+ }
36
+ return {
37
+ discover: opts.discover ?? (async () => []),
38
+ send: (target, message) => post(target, "/message", message),
39
+ async ack(target, acknowledgement) {
40
+ const response = await post(target, "/ack", acknowledgement);
41
+ if (response.http !== 202)
42
+ throw new Error(`acknowledgement failed with HTTP ${response.http}`);
43
+ },
44
+ async close() { },
45
+ };
46
+ }