@arcanemachine/inter-agent-opencode 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE.md +21 -0
- package/README.md +146 -0
- package/dist/client.d.ts +73 -0
- package/dist/client.js +409 -0
- package/dist/config.d.ts +47 -0
- package/dist/config.js +349 -0
- package/dist/errors.d.ts +35 -0
- package/dist/errors.js +95 -0
- package/dist/inbox.d.ts +31 -0
- package/dist/inbox.js +94 -0
- package/dist/protocol.d.ts +132 -0
- package/dist/protocol.js +255 -0
- package/dist/server.d.ts +49 -0
- package/dist/server.js +231 -0
- package/dist/state.d.ts +91 -0
- package/dist/state.js +1306 -0
- package/dist/tui.d.ts +115 -0
- package/dist/tui.js +996 -0
- package/package.json +40 -0
package/CHANGELOG.md
ADDED
package/LICENSE.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 arcanemachine
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
# Inter-agent for OpenCode
|
|
2
|
+
|
|
3
|
+
Connect OpenCode sessions to a local inter-agent server.
|
|
4
|
+
|
|
5
|
+
The extension adds:
|
|
6
|
+
|
|
7
|
+
- TUI commands to connect a session, send direct or broadcast messages, inspect peers, check status, and read the inbox.
|
|
8
|
+
- Server tools for direct messages, broadcasts, peer listing, session status, and inbox reads.
|
|
9
|
+
- Automatic delivery of inbound messages after the OpenCode session becomes idle.
|
|
10
|
+
|
|
11
|
+
The Core server must already be running. This extension never starts, stops, or owns that server.
|
|
12
|
+
|
|
13
|
+
## Transport and plugin targets
|
|
14
|
+
|
|
15
|
+
- Connections stay on loopback addresses. Non-loopback endpoints fail closed.
|
|
16
|
+
- Plaintext WebSockets are the default. Enable WSS when the Core server uses TLS. The client verifies the configured Core certificate and never falls back to plaintext.
|
|
17
|
+
- The package has separate `./tui` and `./server` targets. Put the package in both `tui.json` and `opencode.json`; OpenCode loads the right target for each host.
|
|
18
|
+
|
|
19
|
+
## Installation
|
|
20
|
+
|
|
21
|
+
Build and pack from this checkout:
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
npm ci
|
|
25
|
+
npm run build
|
|
26
|
+
npm pack
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Install and configure both plugin targets with OpenCode's installer:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
opencode plugin /path/to/inter-agent-opencode.tgz
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
For local development, point the same command at the checkout:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
opencode plugin /workspace/projects/inter-agent-opencode
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The command installs the package and adds the server target to `opencode.json` and the TUI target to `tui.json`. Restart OpenCode after installation. If the TUI config lives at a custom path, set `OPENCODE_TUI_CONFIG` before starting OpenCode. Keep configuration files and plugin caches private.
|
|
42
|
+
|
|
43
|
+
## Configuration
|
|
44
|
+
|
|
45
|
+
The extension reads the same Core-compatible settings from environment variables or a JSON config file. Environment variables take precedence. The supported variables are:
|
|
46
|
+
|
|
47
|
+
| Variable | Meaning |
|
|
48
|
+
| ---------------------- | ------------------------------------------------------------------------ |
|
|
49
|
+
| `INTER_AGENT_HOST` | Server host; defaults to `127.0.0.1`. Must resolve to loopback. |
|
|
50
|
+
| `INTER_AGENT_PORT` | Server port; defaults to `16837`. |
|
|
51
|
+
| `INTER_AGENT_SECRET` | Shared authentication secret. |
|
|
52
|
+
| `INTER_AGENT_CONFIG` | JSON config path; default is the platform config location. |
|
|
53
|
+
| `INTER_AGENT_DATA_DIR` | Private state directory; default is the platform state location. |
|
|
54
|
+
| `INTER_AGENT_TLS` | Enable authenticated WSS on a loopback endpoint. |
|
|
55
|
+
| `INTER_AGENT_TLS_CERT` | PEM certificate to trust for WSS; defaults to `<data-dir>/tls-cert.pem`. |
|
|
56
|
+
|
|
57
|
+
The JSON config accepts `host`, `port`, `secret`, `dataDir`, `tls`, and `tlsCert` with the same meanings. When TLS is enabled, certificate trust resolves from `INTER_AGENT_TLS_CERT`, then `tlsCert`, then `<data-dir>/tls-cert.pem`; the extension never disables certificate verification or falls back to plaintext. Secret resolution is environment, then config, then a generated private `token` file under the data directory. Do not put secrets, authentication proofs, or private state contents in prompts, logs, or source control.
|
|
58
|
+
|
|
59
|
+
### Configuration and private state paths
|
|
60
|
+
|
|
61
|
+
`INTER_AGENT_CONFIG` overrides the JSON config path. Without that override, the default is platform-specific: macOS uses `~/Library/Application Support/inter-agent/config.json`; Windows uses `%APPDATA%/inter-agent/config.json` when `APPDATA` is set; otherwise `$XDG_CONFIG_HOME/inter-agent/config.json` is used when set, falling back to `~/.config/inter-agent/config.json`. These are location policies, not machine-specific paths.
|
|
62
|
+
|
|
63
|
+
`INTER_AGENT_DATA_DIR` overrides the private state root, followed by `dataDir` in the JSON config. With neither override, macOS uses `~/Library/Application Support/inter-agent`; Windows uses `%LOCALAPPDATA%/inter-agent` (or `%APPDATA%/inter-agent`); otherwise `$XDG_STATE_HOME/inter-agent` is used when set, falling back to `~/.local/state/inter-agent`. The path resolver expands `~`, `$NAME`, and `${NAME}` forms.
|
|
64
|
+
|
|
65
|
+
Workspace and session records are kept below `<data-dir>/opencode/workspaces/<workspace-hash>/sessions/<session-hash>/`. That private, session-scoped directory contains the connection lease, preferences, durable inbox, and lock/recovery state. When generated as the authentication fallback, a token is stored as `<data-dir>/token`; directories and token files use private permissions where the platform supports them. Keep the config file, data directory, and any configured certificate path private.
|
|
66
|
+
|
|
67
|
+
For an isolated loopback setup, set a fresh port and state directory and provide the same secret to the separately started Core server and OpenCode host:
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
export INTER_AGENT_HOST=127.0.0.1
|
|
71
|
+
export INTER_AGENT_PORT=19001
|
|
72
|
+
export INTER_AGENT_DATA_DIR=/path/to/private/inter-agent-state
|
|
73
|
+
export INTER_AGENT_SECRET='set-this-out-of-band'
|
|
74
|
+
# Optional authenticated WSS on loopback:
|
|
75
|
+
export INTER_AGENT_TLS=true
|
|
76
|
+
export INTER_AGENT_TLS_CERT="$INTER_AGENT_DATA_DIR/tls-cert.pem"
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
The extension does not provide a Core-server start command. Start and stop that server using the Core installation's own documented procedure.
|
|
80
|
+
|
|
81
|
+
## TUI commands
|
|
82
|
+
|
|
83
|
+
Commands apply to the current OpenCode session. Each session has its own connection lease, routing name, pending delivery batch, and durable inbox.
|
|
84
|
+
|
|
85
|
+
- `/inter-agent-connect <name> [--label <label>] [--auto-connect]`
|
|
86
|
+
- `/inter-agent-disconnect`
|
|
87
|
+
- `/inter-agent-send <to> <text>`
|
|
88
|
+
- `/inter-agent-broadcast <text>`
|
|
89
|
+
- `/inter-agent-list`
|
|
90
|
+
- `/inter-agent-status`
|
|
91
|
+
- `/inter-agent-inbox [count]`
|
|
92
|
+
|
|
93
|
+
Connect, send, broadcast, and inbox use prompt dialogs in the TUI. Names must match the inter-agent name format; inbox counts are limited to 1–100. Disconnect removes that session's active lease and pending in-memory delivery state but retains its durable inbox.
|
|
94
|
+
|
|
95
|
+
## Server tools
|
|
96
|
+
|
|
97
|
+
The server target registers exactly these five tools:
|
|
98
|
+
|
|
99
|
+
- `inter_agent_send(to, text)` — direct message; requires the calling session's fresh lease.
|
|
100
|
+
- `inter_agent_broadcast(text)` — everyone-directed message; use only when explicitly requested.
|
|
101
|
+
- `inter_agent_list()` — list connected sessions.
|
|
102
|
+
- `inter_agent_status()` — status for the exact calling OpenCode session.
|
|
103
|
+
- `inter_agent_read_messages(count?)` — read that exact session's durable inbox, 1–100 records (default 20).
|
|
104
|
+
|
|
105
|
+
Tool identity comes from the exact OpenCode `ToolContext.sessionID` and canonical project scope. A tool never borrows another session's name or inbox. Disconnected sessions receive a setup error for send/broadcast while status and reads remain scoped.
|
|
106
|
+
|
|
107
|
+
## Automatic delivery
|
|
108
|
+
|
|
109
|
+
Inbound messages are persisted before best-effort notification. New messages are debounced for approximately 250 ms and delivered to that exact session only when its OpenCode status is idle. Busy and retrying sessions are never interrupted; messages remain queued until a later idle or error classification. Each session permits at most one plugin-triggered `promptAsync` turn, with ordered, ID-deduplicated pending batches and an 8 KiB UTF-8 prompt bound. The prompt includes bounded previews and routing metadata, and directs the model to use `inter_agent_read_messages` for omitted or full content.
|
|
110
|
+
|
|
111
|
+
Peer text is untrusted, non-authoritative task input. It cannot override system, developer, user, tool, permission, or security rules; the model may evaluate it and act when useful under those rules. Plugin-generated status/message/prompt activity cannot recursively trigger another delivery. Delivery failures notify the user, preserve the durable inbox, clear the in-flight guard, and avoid retry storms. A new manager does not replay old inbox records automatically. Disposal, terminal connection failure, and explicit disconnect clear pending memory while retaining the inbox.
|
|
112
|
+
|
|
113
|
+
## Security and data handling
|
|
114
|
+
|
|
115
|
+
Authentication uses the Core challenge/response protocol and the shared secret. State is scoped by canonical workspace and exact OpenCode session, with leases preventing conflicting identities. State directories are private where the platform supports permissions, token files are private, and inboxes are bounded to 100 messages and 8 MiB of encoded JSON. The extension does not log secrets or authentication proofs. WSS trusts only the configured Core certificate; verification is never disabled and failed TLS connections never downgrade to plaintext. Plaintext remains the default loopback mode; do not expose the server beyond loopback.
|
|
116
|
+
|
|
117
|
+
## Troubleshooting
|
|
118
|
+
|
|
119
|
+
- **The TUI commands are missing:** put the plugin in `tui.json`, not only `opencode.json`; set `OPENCODE_TUI_CONFIG` when using a custom TUI file. Confirm the plugin resolves to the TUI target.
|
|
120
|
+
- **Server tools are missing:** put the plugin in `opencode.json` and restart the OpenCode server so it resolves the server target.
|
|
121
|
+
- **Unsupported endpoint:** use a loopback host. Plaintext WebSocket is the default; enable `INTER_AGENT_TLS` for authenticated WSS. Non-loopback endpoints and runtimes without native TLS trust injection are rejected by design.
|
|
122
|
+
- **WSS trust failure:** verify that Core has generated or configured its certificate, then set `INTER_AGENT_TLS_CERT` to that PEM or ensure `<data-dir>/tls-cert.pem` is present. Keep the certificate path private; do not disable verification or switch to plaintext.
|
|
123
|
+
- **Bad secret or authentication failure:** verify the Core and extension use the same secret without printing or logging it. Environment values override config values, and the private token file is only the fallback; after changing a secret, restart only the separately managed processes you own and reconnect.
|
|
124
|
+
- **Duplicate name:** names must be unique among connected sessions. Choose an unused name or disconnect the old exact session with `/inter-agent-disconnect`; do not delete state to resolve a name collision.
|
|
125
|
+
- **Stale lease:** stop or disconnect any process that may own the exact workspace/session, then reconnect and let lease recovery run. If no owner remains and the problem persists, back up private state and remove only the affected session-scoped records while all related clients are stopped; never delete the whole data directory or another session's records.
|
|
126
|
+
- **Disconnected or unavailable:** start the separate Core server, verify the same endpoint/secret, then run `/inter-agent-status` and reconnect the exact OpenCode session.
|
|
127
|
+
- **Messages are not visible:** run `/inter-agent-inbox`; durable records remain available after notification or delivery failure. Check that the recipient name and current session are exact.
|
|
128
|
+
- **A model turn is still busy:** delivery waits rather than interrupting a busy/retrying session. Provider latency can delay the next idle turn.
|
|
129
|
+
|
|
130
|
+
## Uninstall
|
|
131
|
+
|
|
132
|
+
Remove the plugin entry from `opencode.json` and `tui.json`, remove the locally installed archive/package if applicable, and restart OpenCode. Stop the separately managed Core server yourself. If no longer needed, remove the private data directory only after preserving anything required from its inbox; the extension does not remove it automatically.
|
|
133
|
+
|
|
134
|
+
## Development and verification
|
|
135
|
+
|
|
136
|
+
```sh
|
|
137
|
+
npm ci
|
|
138
|
+
npm run typecheck
|
|
139
|
+
npm run build
|
|
140
|
+
npm run format:check
|
|
141
|
+
npm test
|
|
142
|
+
npm run test:compiled
|
|
143
|
+
npm run test:state:compiled
|
|
144
|
+
```
|
|
145
|
+
|
|
146
|
+
Use fresh loopback ports and private temporary state for live checks. Do not use the shared default port when testing.
|
package/dist/client.d.ts
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { type EndpointResolution } from "./config.js";
|
|
2
|
+
import { RemoteError } from "./errors.js";
|
|
3
|
+
import { type ListOkFrame, type Welcome } from "./protocol.js";
|
|
4
|
+
export declare const HANDSHAKE_TIMEOUT_MS = 5000;
|
|
5
|
+
export declare const LIST_TIMEOUT_MS = 2000;
|
|
6
|
+
export declare const SEND_ERROR_WINDOW_MS = 250;
|
|
7
|
+
export type WebSocketMessageEvent = {
|
|
8
|
+
data: unknown;
|
|
9
|
+
};
|
|
10
|
+
export type WebSocketCloseEvent = {
|
|
11
|
+
code?: number;
|
|
12
|
+
reason?: string;
|
|
13
|
+
};
|
|
14
|
+
export interface WebSocketLike {
|
|
15
|
+
readonly readyState?: number;
|
|
16
|
+
onopen: (() => void) | null;
|
|
17
|
+
onmessage: ((event: WebSocketMessageEvent) => void) | null;
|
|
18
|
+
onerror: ((event: unknown) => void) | null;
|
|
19
|
+
onclose: ((event: WebSocketCloseEvent) => void) | null;
|
|
20
|
+
send(data: string): void;
|
|
21
|
+
close(code?: number, reason?: string): void;
|
|
22
|
+
}
|
|
23
|
+
export type WebSocketFactory = (url: string, tlsCertPath?: string) => WebSocketLike;
|
|
24
|
+
export type ClientOptions = {
|
|
25
|
+
endpoint?: EndpointResolution;
|
|
26
|
+
secret?: string;
|
|
27
|
+
websocketFactory?: WebSocketFactory;
|
|
28
|
+
connectTimeoutMs?: number;
|
|
29
|
+
operationTimeoutMs?: number;
|
|
30
|
+
sendErrorWindowMs?: number;
|
|
31
|
+
signal?: AbortSignal;
|
|
32
|
+
};
|
|
33
|
+
export type ControlClientOptions = ClientOptions & {
|
|
34
|
+
fromName?: string;
|
|
35
|
+
};
|
|
36
|
+
export type SendResult = {
|
|
37
|
+
welcome: Welcome;
|
|
38
|
+
error?: RemoteError;
|
|
39
|
+
};
|
|
40
|
+
export type AgentConnectionOptions = ClientOptions & {
|
|
41
|
+
name: string;
|
|
42
|
+
label?: string | null;
|
|
43
|
+
};
|
|
44
|
+
export declare function defaultWebSocketFactory(url: string, tlsCertPath?: string): WebSocketLike;
|
|
45
|
+
export declare class AgentConnection {
|
|
46
|
+
private readonly session;
|
|
47
|
+
readonly welcome: Welcome;
|
|
48
|
+
private readonly secret;
|
|
49
|
+
private readonly signal?;
|
|
50
|
+
private constructor();
|
|
51
|
+
static open(options: AgentConnectionOptions): Promise<AgentConnection>;
|
|
52
|
+
receive(timeoutMs?: number): Promise<import("./protocol.js").Message>;
|
|
53
|
+
close(): Promise<void>;
|
|
54
|
+
}
|
|
55
|
+
export declare class ControlConnection {
|
|
56
|
+
private readonly session;
|
|
57
|
+
readonly welcome: Welcome;
|
|
58
|
+
private readonly secret;
|
|
59
|
+
private readonly operationTimeoutMs;
|
|
60
|
+
private readonly sendErrorWindowMs;
|
|
61
|
+
private readonly signal?;
|
|
62
|
+
private constructor();
|
|
63
|
+
static open(options?: ClientOptions): Promise<ControlConnection>;
|
|
64
|
+
sendDirect(to: string, text: string, fromName?: string): Promise<SendResult>;
|
|
65
|
+
broadcast(text: string, fromName?: string): Promise<SendResult>;
|
|
66
|
+
list(): Promise<ListOkFrame>;
|
|
67
|
+
close(): Promise<void>;
|
|
68
|
+
private sendAndObserve;
|
|
69
|
+
}
|
|
70
|
+
export declare function sendDirect(to: string, text: string, fromName?: string, options?: ClientOptions): Promise<SendResult>;
|
|
71
|
+
export declare function broadcast(text: string, fromName?: string, options?: ClientOptions): Promise<SendResult>;
|
|
72
|
+
export declare function listSessions(options?: ClientOptions): Promise<ListOkFrame>;
|
|
73
|
+
export declare function websocketUrl(endpoint: EndpointResolution): string;
|
package/dist/client.js
ADDED
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { performance } from "node:perf_hooks";
|
|
4
|
+
import { endpointUri, assertSupportedEndpoint, resolveEndpoint, resolveSecret, } from "./config.js";
|
|
5
|
+
import { AuthenticationError, ConnectionError, ProtocolError, RemoteError, TimeoutError, InterAgentError, } from "./errors.js";
|
|
6
|
+
import { buildAuthResponse, buildHello, decodeFrame, parseAuthChallenge, parseError, parseFrame, parseListOk, parseMessage, parseWelcome, validateName, validateText, verifyServerProof, DEFAULT_BROADCAST_TEXT_MAX, DEFAULT_DIRECT_TEXT_MAX, } from "./protocol.js";
|
|
7
|
+
export const HANDSHAKE_TIMEOUT_MS = 5_000;
|
|
8
|
+
export const LIST_TIMEOUT_MS = 2_000;
|
|
9
|
+
export const SEND_ERROR_WINDOW_MS = 250;
|
|
10
|
+
export function defaultWebSocketFactory(url, tlsCertPath) {
|
|
11
|
+
const Constructor = globalThis.WebSocket;
|
|
12
|
+
if (typeof Constructor !== "function")
|
|
13
|
+
throw new ConnectionError("native WebSocket is unavailable");
|
|
14
|
+
if (!tlsCertPath)
|
|
15
|
+
return new Constructor(url);
|
|
16
|
+
const bun = globalThis.Bun;
|
|
17
|
+
if (!bun)
|
|
18
|
+
throw new ConnectionError("WSS requires native Bun WebSocket TLS trust support");
|
|
19
|
+
let certificate;
|
|
20
|
+
try {
|
|
21
|
+
certificate = readFileSync(tlsCertPath);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
throw new ConnectionError("unable to read inter-agent TLS certificate");
|
|
25
|
+
}
|
|
26
|
+
try {
|
|
27
|
+
return new Constructor(url, { tls: { ca: certificate } });
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
throw new ConnectionError("unable to create inter-agent WSS WebSocket");
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
class SocketSession {
|
|
34
|
+
socket;
|
|
35
|
+
queue = [];
|
|
36
|
+
waiter;
|
|
37
|
+
failure;
|
|
38
|
+
closed = false;
|
|
39
|
+
constructor(socket) {
|
|
40
|
+
this.socket = socket;
|
|
41
|
+
socket.onmessage = (event) => {
|
|
42
|
+
try {
|
|
43
|
+
this.deliver(decodeFrame(event.data));
|
|
44
|
+
}
|
|
45
|
+
catch (error) {
|
|
46
|
+
this.fail(error instanceof Error
|
|
47
|
+
? error
|
|
48
|
+
: new ProtocolError("invalid websocket frame"));
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
socket.onerror = () => this.fail(new ConnectionError("inter-agent WebSocket connection failed"));
|
|
52
|
+
socket.onclose = () => this.fail(new ConnectionError("inter-agent WebSocket connection closed"));
|
|
53
|
+
}
|
|
54
|
+
deliver(data) {
|
|
55
|
+
if (this.waiter) {
|
|
56
|
+
const waiter = this.waiter;
|
|
57
|
+
this.waiter = undefined;
|
|
58
|
+
waiter.resolve(data);
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
this.queue.push(data);
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
fail(error) {
|
|
65
|
+
if (this.closed || this.failure)
|
|
66
|
+
return;
|
|
67
|
+
this.failure = error;
|
|
68
|
+
const waiter = this.waiter;
|
|
69
|
+
this.waiter = undefined;
|
|
70
|
+
waiter?.reject(error);
|
|
71
|
+
}
|
|
72
|
+
async waitOpen(timeoutMs, signal) {
|
|
73
|
+
if (this.socket.readyState === 1)
|
|
74
|
+
return;
|
|
75
|
+
await waitForEvent((resolve, reject) => {
|
|
76
|
+
const previousOpen = this.socket.onopen;
|
|
77
|
+
const previousClose = this.socket.onclose;
|
|
78
|
+
const previousError = this.socket.onerror;
|
|
79
|
+
this.socket.onopen = () => {
|
|
80
|
+
previousOpen?.();
|
|
81
|
+
resolve();
|
|
82
|
+
};
|
|
83
|
+
this.socket.onclose = (event) => {
|
|
84
|
+
previousClose?.(event);
|
|
85
|
+
reject(new ConnectionError("inter-agent WebSocket closed before authentication"));
|
|
86
|
+
};
|
|
87
|
+
this.socket.onerror = (event) => {
|
|
88
|
+
previousError?.(event);
|
|
89
|
+
reject(new ConnectionError("inter-agent WebSocket failed before authentication"));
|
|
90
|
+
};
|
|
91
|
+
}, timeoutMs, signal, "inter-agent WebSocket connection timed out");
|
|
92
|
+
}
|
|
93
|
+
send(data) {
|
|
94
|
+
try {
|
|
95
|
+
this.socket.send(data);
|
|
96
|
+
}
|
|
97
|
+
catch {
|
|
98
|
+
throw new ConnectionError("inter-agent WebSocket send failed");
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
async receive(timeoutMs, signal) {
|
|
102
|
+
if (this.failure)
|
|
103
|
+
throw this.failure;
|
|
104
|
+
const queued = this.queue.shift();
|
|
105
|
+
if (queued !== undefined)
|
|
106
|
+
return queued;
|
|
107
|
+
return waitForEvent((resolve, reject) => {
|
|
108
|
+
this.waiter = { resolve, reject };
|
|
109
|
+
}, timeoutMs, signal, "inter-agent operation timed out").catch((error) => {
|
|
110
|
+
this.waiter = undefined;
|
|
111
|
+
throw error;
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
close() {
|
|
115
|
+
if (this.closed)
|
|
116
|
+
return;
|
|
117
|
+
this.closed = true;
|
|
118
|
+
const waiter = this.waiter;
|
|
119
|
+
this.waiter = undefined;
|
|
120
|
+
waiter?.reject(new ConnectionError("inter-agent WebSocket closed"));
|
|
121
|
+
this.socket.onmessage = null;
|
|
122
|
+
this.socket.onopen = null;
|
|
123
|
+
this.socket.onerror = null;
|
|
124
|
+
this.socket.onclose = null;
|
|
125
|
+
try {
|
|
126
|
+
this.socket.close();
|
|
127
|
+
}
|
|
128
|
+
catch {
|
|
129
|
+
// Closing an already closed socket is harmless.
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function waitForEvent(install, timeoutMs, signal, timeoutMessage) {
|
|
134
|
+
if (signal?.aborted)
|
|
135
|
+
return Promise.reject(new InterAgentError("operation aborted", "AbortError", true));
|
|
136
|
+
return new Promise((resolve, reject) => {
|
|
137
|
+
let settled = false;
|
|
138
|
+
const timer = setTimeout(() => {
|
|
139
|
+
if (settled)
|
|
140
|
+
return;
|
|
141
|
+
settled = true;
|
|
142
|
+
cleanup();
|
|
143
|
+
reject(new TimeoutError(timeoutMessage));
|
|
144
|
+
}, timeoutMs);
|
|
145
|
+
const abort = () => {
|
|
146
|
+
if (settled)
|
|
147
|
+
return;
|
|
148
|
+
settled = true;
|
|
149
|
+
cleanup();
|
|
150
|
+
reject(new InterAgentError("operation aborted", "AbortError", true));
|
|
151
|
+
};
|
|
152
|
+
const cleanup = () => {
|
|
153
|
+
clearTimeout(timer);
|
|
154
|
+
signal?.removeEventListener("abort", abort);
|
|
155
|
+
};
|
|
156
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
157
|
+
install((value) => {
|
|
158
|
+
if (settled)
|
|
159
|
+
return;
|
|
160
|
+
settled = true;
|
|
161
|
+
cleanup();
|
|
162
|
+
resolve(value);
|
|
163
|
+
}, (error) => {
|
|
164
|
+
if (settled)
|
|
165
|
+
return;
|
|
166
|
+
settled = true;
|
|
167
|
+
cleanup();
|
|
168
|
+
reject(error);
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
function safeRemoteError(frame, secret) {
|
|
173
|
+
const parsed = parseError(frame);
|
|
174
|
+
return new RemoteError(parsed.code, parsed.message, secret);
|
|
175
|
+
}
|
|
176
|
+
function parseOperationError(raw, secret) {
|
|
177
|
+
const frame = parseFrame(raw);
|
|
178
|
+
if (frame.op !== "error")
|
|
179
|
+
return undefined;
|
|
180
|
+
return safeRemoteError(frame, secret);
|
|
181
|
+
}
|
|
182
|
+
export class AgentConnection {
|
|
183
|
+
session;
|
|
184
|
+
welcome;
|
|
185
|
+
secret;
|
|
186
|
+
signal;
|
|
187
|
+
constructor(session, welcome, secret, signal) {
|
|
188
|
+
this.session = session;
|
|
189
|
+
this.welcome = welcome;
|
|
190
|
+
this.secret = secret;
|
|
191
|
+
this.signal = signal;
|
|
192
|
+
}
|
|
193
|
+
static async open(options) {
|
|
194
|
+
const endpoint = options.endpoint ?? (await resolveEndpoint());
|
|
195
|
+
assertSupportedEndpoint(endpoint);
|
|
196
|
+
const secret = options.secret ?? resolveSecret().secret;
|
|
197
|
+
const factory = options.websocketFactory ?? defaultWebSocketFactory;
|
|
198
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
199
|
+
const deadline = performance.now() + connectTimeoutMs;
|
|
200
|
+
const socket = createSocket(factory, endpoint);
|
|
201
|
+
const session = new SocketSession(socket);
|
|
202
|
+
try {
|
|
203
|
+
await session.waitOpen(remainingDeadline(deadline), options.signal);
|
|
204
|
+
const hello = buildHello({
|
|
205
|
+
role: "agent",
|
|
206
|
+
sessionId: randomUUID(),
|
|
207
|
+
name: options.name,
|
|
208
|
+
label: options.label ?? null,
|
|
209
|
+
capabilities: {},
|
|
210
|
+
});
|
|
211
|
+
const clientNonce = hello.auth.client_nonce;
|
|
212
|
+
session.send(JSON.stringify(hello));
|
|
213
|
+
const challengeFrame = parseFrame(await session.receive(remainingDeadline(deadline), options.signal));
|
|
214
|
+
if (challengeFrame.op === "error")
|
|
215
|
+
throw safeRemoteError(challengeFrame, secret);
|
|
216
|
+
const challenge = parseAuthChallenge(challengeFrame);
|
|
217
|
+
if (!verifyServerProof(challenge.server_proof, secret, {
|
|
218
|
+
clientNonce,
|
|
219
|
+
serverNonce: challenge.server_nonce,
|
|
220
|
+
hello,
|
|
221
|
+
}))
|
|
222
|
+
throw new AuthenticationError();
|
|
223
|
+
session.send(JSON.stringify(buildAuthResponse(secret, {
|
|
224
|
+
clientNonce,
|
|
225
|
+
serverNonce: challenge.server_nonce,
|
|
226
|
+
hello,
|
|
227
|
+
})));
|
|
228
|
+
const welcomeFrame = parseFrame(await session.receive(remainingDeadline(deadline), options.signal));
|
|
229
|
+
if (welcomeFrame.op === "error")
|
|
230
|
+
throw safeRemoteError(welcomeFrame, secret);
|
|
231
|
+
const welcome = parseWelcome(welcomeFrame);
|
|
232
|
+
return new AgentConnection(session, welcome, secret, options.signal);
|
|
233
|
+
}
|
|
234
|
+
catch (error) {
|
|
235
|
+
session.close();
|
|
236
|
+
if (error instanceof InterAgentError)
|
|
237
|
+
throw error;
|
|
238
|
+
throw new ConnectionError("inter-agent authentication failed");
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
async receive(timeoutMs = HANDSHAKE_TIMEOUT_MS * 12) {
|
|
242
|
+
const frame = parseFrame(await this.session.receive(timeoutMs, this.signal));
|
|
243
|
+
if (frame.op === "error")
|
|
244
|
+
throw safeRemoteError(frame, this.secret);
|
|
245
|
+
return parseMessage(frame);
|
|
246
|
+
}
|
|
247
|
+
async close() {
|
|
248
|
+
this.session.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
export class ControlConnection {
|
|
252
|
+
session;
|
|
253
|
+
welcome;
|
|
254
|
+
secret;
|
|
255
|
+
operationTimeoutMs;
|
|
256
|
+
sendErrorWindowMs;
|
|
257
|
+
signal;
|
|
258
|
+
constructor(session, welcome, secret, operationTimeoutMs, sendErrorWindowMs, signal) {
|
|
259
|
+
this.session = session;
|
|
260
|
+
this.welcome = welcome;
|
|
261
|
+
this.secret = secret;
|
|
262
|
+
this.operationTimeoutMs = operationTimeoutMs;
|
|
263
|
+
this.sendErrorWindowMs = sendErrorWindowMs;
|
|
264
|
+
this.signal = signal;
|
|
265
|
+
}
|
|
266
|
+
static async open(options = {}) {
|
|
267
|
+
const endpoint = options.endpoint ?? (await resolveEndpoint());
|
|
268
|
+
assertSupportedEndpoint(endpoint);
|
|
269
|
+
const secret = options.secret ?? resolveSecret().secret;
|
|
270
|
+
const factory = options.websocketFactory ?? defaultWebSocketFactory;
|
|
271
|
+
const connectTimeoutMs = options.connectTimeoutMs ?? HANDSHAKE_TIMEOUT_MS;
|
|
272
|
+
const deadline = performance.now() + connectTimeoutMs;
|
|
273
|
+
const socket = createSocket(factory, endpoint);
|
|
274
|
+
const session = new SocketSession(socket);
|
|
275
|
+
try {
|
|
276
|
+
await session.waitOpen(remainingDeadline(deadline), options.signal);
|
|
277
|
+
const hello = buildHello({
|
|
278
|
+
role: "control",
|
|
279
|
+
sessionId: randomUUID(),
|
|
280
|
+
name: "control",
|
|
281
|
+
label: null,
|
|
282
|
+
capabilities: {},
|
|
283
|
+
});
|
|
284
|
+
const clientNonce = hello.auth.client_nonce;
|
|
285
|
+
session.send(JSON.stringify(hello));
|
|
286
|
+
const challengeFrame = parseFrame(await session.receive(remainingDeadline(deadline), options.signal));
|
|
287
|
+
if (challengeFrame.op === "error")
|
|
288
|
+
throw safeRemoteError(challengeFrame, secret);
|
|
289
|
+
const challenge = parseAuthChallenge(challengeFrame);
|
|
290
|
+
if (!verifyServerProof(challenge.server_proof, secret, {
|
|
291
|
+
clientNonce,
|
|
292
|
+
serverNonce: challenge.server_nonce,
|
|
293
|
+
hello,
|
|
294
|
+
})) {
|
|
295
|
+
throw new AuthenticationError();
|
|
296
|
+
}
|
|
297
|
+
session.send(JSON.stringify(buildAuthResponse(secret, {
|
|
298
|
+
clientNonce,
|
|
299
|
+
serverNonce: challenge.server_nonce,
|
|
300
|
+
hello,
|
|
301
|
+
})));
|
|
302
|
+
const welcomeFrame = parseFrame(await session.receive(remainingDeadline(deadline), options.signal));
|
|
303
|
+
if (welcomeFrame.op === "error")
|
|
304
|
+
throw safeRemoteError(welcomeFrame, secret);
|
|
305
|
+
const welcome = parseWelcome(welcomeFrame);
|
|
306
|
+
return new ControlConnection(session, welcome, secret, options.operationTimeoutMs ?? LIST_TIMEOUT_MS, options.sendErrorWindowMs ?? SEND_ERROR_WINDOW_MS, options.signal);
|
|
307
|
+
}
|
|
308
|
+
catch (error) {
|
|
309
|
+
session.close();
|
|
310
|
+
if (error instanceof InterAgentError)
|
|
311
|
+
throw error;
|
|
312
|
+
throw new ConnectionError("inter-agent authentication failed");
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
async sendDirect(to, text, fromName) {
|
|
316
|
+
if (!validateName(to))
|
|
317
|
+
throw new ProtocolError("invalid direct message target", "BAD_NAME");
|
|
318
|
+
if (!validateText(text, DEFAULT_DIRECT_TEXT_MAX))
|
|
319
|
+
throw new ProtocolError("direct message text is too large", "TEXT_TOO_LARGE");
|
|
320
|
+
if (fromName !== undefined && !validateName(fromName))
|
|
321
|
+
throw new ProtocolError("invalid from_name", "BAD_FROM_NAME");
|
|
322
|
+
const payload = { op: "send", to, text };
|
|
323
|
+
if (fromName !== undefined)
|
|
324
|
+
payload.from_name = fromName;
|
|
325
|
+
return this.sendAndObserve(payload);
|
|
326
|
+
}
|
|
327
|
+
async broadcast(text, fromName) {
|
|
328
|
+
if (!validateText(text, DEFAULT_BROADCAST_TEXT_MAX))
|
|
329
|
+
throw new ProtocolError("broadcast text is too large", "TEXT_TOO_LARGE");
|
|
330
|
+
if (fromName !== undefined && !validateName(fromName))
|
|
331
|
+
throw new ProtocolError("invalid from_name", "BAD_FROM_NAME");
|
|
332
|
+
const payload = { op: "broadcast", text };
|
|
333
|
+
if (fromName !== undefined)
|
|
334
|
+
payload.from_name = fromName;
|
|
335
|
+
return this.sendAndObserve(payload);
|
|
336
|
+
}
|
|
337
|
+
async list() {
|
|
338
|
+
this.session.send(JSON.stringify({ op: "list" }));
|
|
339
|
+
const frame = parseFrame(await this.session.receive(this.operationTimeoutMs, this.signal));
|
|
340
|
+
if (frame.op === "error")
|
|
341
|
+
throw safeRemoteError(frame, this.secret);
|
|
342
|
+
return parseListOk(frame);
|
|
343
|
+
}
|
|
344
|
+
async close() {
|
|
345
|
+
this.session.close();
|
|
346
|
+
}
|
|
347
|
+
async sendAndObserve(payload) {
|
|
348
|
+
this.session.send(JSON.stringify(payload));
|
|
349
|
+
try {
|
|
350
|
+
const raw = await this.session.receive(this.sendErrorWindowMs, this.signal);
|
|
351
|
+
const error = parseOperationError(raw, this.secret);
|
|
352
|
+
if (error)
|
|
353
|
+
throw error;
|
|
354
|
+
throw new ProtocolError("unexpected response after message send");
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
if (error instanceof TimeoutError)
|
|
358
|
+
return { welcome: this.welcome };
|
|
359
|
+
throw error;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
function remainingDeadline(deadline) {
|
|
364
|
+
const remaining = Math.ceil(deadline - performance.now());
|
|
365
|
+
if (remaining <= 0)
|
|
366
|
+
throw new TimeoutError("inter-agent WebSocket connection timed out");
|
|
367
|
+
return remaining;
|
|
368
|
+
}
|
|
369
|
+
function createSocket(factory, endpoint) {
|
|
370
|
+
try {
|
|
371
|
+
return factory(endpointUri(endpoint), endpoint.tls ? endpoint.tlsCertPath : undefined);
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (error instanceof InterAgentError)
|
|
375
|
+
throw error;
|
|
376
|
+
throw new ConnectionError("unable to create inter-agent WebSocket");
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
export async function sendDirect(to, text, fromName, options = {}) {
|
|
380
|
+
const connection = await ControlConnection.open(options);
|
|
381
|
+
try {
|
|
382
|
+
return await connection.sendDirect(to, text, fromName);
|
|
383
|
+
}
|
|
384
|
+
finally {
|
|
385
|
+
await connection.close();
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
export async function broadcast(text, fromName, options = {}) {
|
|
389
|
+
const connection = await ControlConnection.open(options);
|
|
390
|
+
try {
|
|
391
|
+
return await connection.broadcast(text, fromName);
|
|
392
|
+
}
|
|
393
|
+
finally {
|
|
394
|
+
await connection.close();
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
export async function listSessions(options = {}) {
|
|
398
|
+
const connection = await ControlConnection.open(options);
|
|
399
|
+
try {
|
|
400
|
+
return await connection.list();
|
|
401
|
+
}
|
|
402
|
+
finally {
|
|
403
|
+
await connection.close();
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
export function websocketUrl(endpoint) {
|
|
407
|
+
return endpointUri(endpoint);
|
|
408
|
+
}
|
|
409
|
+
//# sourceMappingURL=client.js.map
|