@estebanforge/pi-antigravity-bridge 1.3.3 → 1.4.1
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 +57 -0
- package/README.md +22 -3
- package/docs/ACP-ADOPTION-PLAN.md +875 -0
- package/docs/ACP-PROTOCOL-REFERENCE.md +460 -0
- package/docs/ANTIGRAVITY-INTEGRATIONS.md +50 -551
- package/docs/ARCHITECTURE.md +50 -3
- package/docs/DEVELOPMENT.md +33 -0
- package/docs/PI-BRIDGE-GAPS.md +41 -140
- package/extensions/index.ts +235 -39
- package/package.json +12 -5
- package/src/acp/connection.ts +409 -0
- package/src/acp/driver.ts +723 -0
- package/src/acp/events.ts +250 -0
- package/src/acp/jsonrpc.ts +185 -0
- package/src/acp/setup.ts +355 -0
- package/src/config.ts +33 -0
- package/src/diff-render.ts +15 -0
- package/src/driver-types.ts +144 -0
- package/src/driver.ts +69 -78
- package/src/mcp-server.ts +8 -1
- package/src/models.ts +9 -7
- package/src/provider.ts +148 -23
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// ACP `session/update` payload → DriverActivity mapping + stopReason mapping.
|
|
2
|
+
//
|
|
3
|
+
// Pure functions, no I/O: unit-tested against the captured probe transcripts
|
|
4
|
+
// (probe-logs/acp-traffic-run5.jsonl, run6-restart-load-tools.jsonl).
|
|
5
|
+
//
|
|
6
|
+
// Shapes verified live against agy_acp_server 20260818_01_RC01:
|
|
7
|
+
// session/update params = { sessionId, update: { sessionUpdate: <discriminator>, ... } }
|
|
8
|
+
// - agent_message_chunk { content: { type: "text", text } } (pure deltas)
|
|
9
|
+
// - agent_thought_chunk { content: { type: "text", text } } (thought TEXT)
|
|
10
|
+
// - user_message_chunk { content: { type: "text", text } } (load replay only)
|
|
11
|
+
// - tool_call { toolCallId, title, kind, status, content?, locations?, rawInput? }
|
|
12
|
+
// - tool_call_update { toolCallId, status: completed|failed, rawOutput? }
|
|
13
|
+
// - plan { entries: [...] }
|
|
14
|
+
// - available_commands_update { availableCommands: [...] }
|
|
15
|
+
// Usage: ABSENT on RC01 (Gate B) — mapped defensively should a future build add it.
|
|
16
|
+
|
|
17
|
+
/** Map one `update` object to a driver-level event, or null when the update
|
|
18
|
+
* carries nothing the driver consumes (plan/commands are doctor/phase-2
|
|
19
|
+
* material; user_message_chunk is load replay and must never reach pi as
|
|
20
|
+
* live text). */
|
|
21
|
+
/** Native edit diff carried in tool_call content[] (run 6: edits arrive AS
|
|
22
|
+
* DIFFS: {type:"diff", path, newText, optional oldText}). The provider
|
|
23
|
+
* formats it in memory — no git subprocess needed on the ACP engine. */
|
|
24
|
+
export interface AcpEditDiff {
|
|
25
|
+
path: string;
|
|
26
|
+
oldText?: string;
|
|
27
|
+
newText: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export type MappedUpdate =
|
|
31
|
+
| { kind: "text"; delta: string }
|
|
32
|
+
| { kind: "thought"; delta: string }
|
|
33
|
+
| { kind: "tool_start"; toolCallId: string; name: string; args: Record<string, unknown>; diff?: AcpEditDiff }
|
|
34
|
+
| { kind: "tool_done"; toolCallId: string; output?: string; diff?: AcpEditDiff }
|
|
35
|
+
| { kind: "tool_error"; toolCallId: string; message: string }
|
|
36
|
+
| { kind: "replay_user" }
|
|
37
|
+
| null;
|
|
38
|
+
|
|
39
|
+
export function mapUpdate(update: unknown): MappedUpdate {
|
|
40
|
+
if (typeof update !== "object" || update === null) return null;
|
|
41
|
+
const u = update as Record<string, unknown>;
|
|
42
|
+
const kind = u.sessionUpdate;
|
|
43
|
+
if (typeof kind !== "string") return null;
|
|
44
|
+
switch (kind) {
|
|
45
|
+
case "agent_message_chunk":
|
|
46
|
+
return { kind: "text", delta: chunkText(u.content) };
|
|
47
|
+
case "agent_thought_chunk":
|
|
48
|
+
return { kind: "thought", delta: chunkText(u.content) };
|
|
49
|
+
case "user_message_chunk":
|
|
50
|
+
return { kind: "replay_user" };
|
|
51
|
+
case "tool_call": {
|
|
52
|
+
const id = stringField(u.toolCallId);
|
|
53
|
+
if (!id) return null;
|
|
54
|
+
return {
|
|
55
|
+
kind: "tool_start",
|
|
56
|
+
toolCallId: id,
|
|
57
|
+
name: metaToolName(u._meta) ?? toolName(u.title, u.kind),
|
|
58
|
+
args: rawArguments(u.rawInput),
|
|
59
|
+
// Edits carry their native diff HERE, on the pending tool_call
|
|
60
|
+
// frame (run 6:10) — the completed update carries only display
|
|
61
|
+
// text. contentDiff on the update stays as a future-build
|
|
62
|
+
// fallback.
|
|
63
|
+
diff: contentDiff(u.content),
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
case "tool_call_update": {
|
|
67
|
+
const id = stringField(u.toolCallId);
|
|
68
|
+
if (!id) return null;
|
|
69
|
+
if (u.status === "failed") {
|
|
70
|
+
// RC01 echo (run 6, finding 7): after an allow, the APPROVED call
|
|
71
|
+
// id reports failed with this sentinel while the real work runs
|
|
72
|
+
// under a different id that reports its own lifecycle. Track
|
|
73
|
+
// effects, not ids: drop the echo instead of rendering a bogus
|
|
74
|
+
// failure next to a successful edit.
|
|
75
|
+
const raw = stringField(u.rawOutput);
|
|
76
|
+
if (raw?.includes("approved but never executed")) return null;
|
|
77
|
+
return { kind: "tool_error", toolCallId: id, message: raw ?? "tool failed" };
|
|
78
|
+
}
|
|
79
|
+
if (u.status === "completed") {
|
|
80
|
+
// content[] first (edits carry their diff there); rawOutput alone is
|
|
81
|
+
// often just the server's display title, not the result (probe
|
|
82
|
+
// 2026-09-03: completed MCP call, rawOutput "Call bridge_echo").
|
|
83
|
+
const diff = contentDiff(u.content);
|
|
84
|
+
return {
|
|
85
|
+
kind: "tool_done",
|
|
86
|
+
toolCallId: id,
|
|
87
|
+
output: contentText(u.content) ?? stringField(u.rawOutput),
|
|
88
|
+
...(diff ? { diff } : {}),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
return null; // in_progress or unknown status: nothing to render yet
|
|
92
|
+
}
|
|
93
|
+
default:
|
|
94
|
+
// plan / available_commands_update / current_mode_update: consumed by
|
|
95
|
+
// the driver snapshot / future phases, not mapped to activities.
|
|
96
|
+
return null;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** MCP tools wrap their args in an `arguments` envelope
|
|
101
|
+
* ({arguments:{...}}); native tools carry them directly (probe 2026-09-03:
|
|
102
|
+
* bridge_echo rawInput {arguments:{text}}, edit_file rawInput {file_path}). */
|
|
103
|
+
function rawArguments(rawInput: unknown): Record<string, unknown> {
|
|
104
|
+
const rec = recordField(rawInput);
|
|
105
|
+
const args = rec.arguments;
|
|
106
|
+
return typeof args === "object" && args !== null && !Array.isArray(args)
|
|
107
|
+
? (args as Record<string, unknown>)
|
|
108
|
+
: rec;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Clean tool name for MCP tools: the title is "<server>_<tool>" (e.g.
|
|
112
|
+
* "pi-bridge_bridge_echo") and the real name hides in _meta.mcp.tool. */
|
|
113
|
+
function metaToolName(meta: unknown): string | undefined {
|
|
114
|
+
const mcp = recordField(recordField(meta).mcp);
|
|
115
|
+
const tool = mcp.tool;
|
|
116
|
+
return typeof tool === "string" && tool.length > 0 ? tool : undefined;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** First diff entry of a completed tool call: edits carry their native diff
|
|
120
|
+
* in content[] as {type:"diff", path, newText, optional oldText} (run 6). */
|
|
121
|
+
function contentDiff(content: unknown): AcpEditDiff | undefined {
|
|
122
|
+
if (!Array.isArray(content)) return undefined;
|
|
123
|
+
for (const entry of content) {
|
|
124
|
+
const e = recordField(entry);
|
|
125
|
+
if (e.type !== "diff") continue;
|
|
126
|
+
const path = stringField(e.path);
|
|
127
|
+
const newText = stringField(e.newText);
|
|
128
|
+
if (!path || newText === undefined) continue;
|
|
129
|
+
const oldText = stringField(e.oldText);
|
|
130
|
+
return oldText !== undefined ? { path, oldText, newText } : { path, newText };
|
|
131
|
+
}
|
|
132
|
+
return undefined;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Display text of a completed tool call. content[] entries wrap their
|
|
136
|
+
* payload ({type:"content", content:{type:"text", text}}); diff entries
|
|
137
|
+
* carry no text and are skipped. */
|
|
138
|
+
function contentText(content: unknown): string | undefined {
|
|
139
|
+
if (!Array.isArray(content)) return undefined;
|
|
140
|
+
const parts: string[] = [];
|
|
141
|
+
for (const entry of content) {
|
|
142
|
+
const inner = recordField(recordField(entry).content ?? entry);
|
|
143
|
+
if (typeof inner.text === "string" && inner.text.length > 0) parts.push(inner.text);
|
|
144
|
+
}
|
|
145
|
+
return parts.length > 0 ? parts.join("\n") : undefined;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Extract the text of a content block ({type:"text", text}). */
|
|
149
|
+
function chunkText(content: unknown): string {
|
|
150
|
+
if (typeof content === "object" && content !== null) {
|
|
151
|
+
const c = content as Record<string, unknown>;
|
|
152
|
+
if (typeof c.text === "string") return c.text;
|
|
153
|
+
}
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stringField(v: unknown): string | undefined {
|
|
158
|
+
return typeof v === "string" && v.length > 0 ? v : undefined;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function recordField(v: unknown): Record<string, unknown> {
|
|
162
|
+
return typeof v === "object" && v !== null && !Array.isArray(v)
|
|
163
|
+
? (v as Record<string, unknown>)
|
|
164
|
+
: {};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Derive a tool name from the title ("Run create_file?", "Running edit_file")
|
|
168
|
+
* with the kind as fallback. Titles observed live: "Run create_file?",
|
|
169
|
+
* "Running edit_file", "Running view_file". */
|
|
170
|
+
export function toolName(title: unknown, kind: unknown): string {
|
|
171
|
+
if (typeof title === "string") {
|
|
172
|
+
const m = /(?:Run|Running)\s+([A-Za-z_][\w.]*)/i.exec(title);
|
|
173
|
+
if (m) return m[1] as string;
|
|
174
|
+
}
|
|
175
|
+
return typeof kind === "string" && kind.length > 0 ? kind : "tool";
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Map a `session/prompt` result stopReason to a TurnOutcome shape.
|
|
179
|
+
* Verified live: `end_turn`. Schema enumerates cancelled / max_tokens /
|
|
180
|
+
* max_turn_requests / refusal. `cancelled` is unreachable on RC01 (no
|
|
181
|
+
* cancel method) but mapped for the day upstream ships it. */
|
|
182
|
+
export function mapStopReason(stopReason: unknown): {
|
|
183
|
+
status: "OK" | "ERROR";
|
|
184
|
+
aborted: boolean;
|
|
185
|
+
error?: string;
|
|
186
|
+
} {
|
|
187
|
+
switch (stopReason) {
|
|
188
|
+
case "end_turn":
|
|
189
|
+
return { status: "OK", aborted: false };
|
|
190
|
+
case "cancelled":
|
|
191
|
+
return { status: "OK", aborted: true };
|
|
192
|
+
case "max_tokens":
|
|
193
|
+
return { status: "OK", aborted: false, error: "ACP: response hit the token cap" };
|
|
194
|
+
case "refusal":
|
|
195
|
+
return { status: "ERROR", aborted: false, error: "ACP: the model refused the request" };
|
|
196
|
+
case "max_turn_requests":
|
|
197
|
+
return { status: "ERROR", aborted: false, error: "ACP: turn exceeded the request limit" };
|
|
198
|
+
default:
|
|
199
|
+
return { status: "OK", aborted: false };
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Defensive accumulation with the cumulative-resend port. RC01 streams pure
|
|
204
|
+
* deltas (verified: mid-token splits across the run-5 stress streams), so on
|
|
205
|
+
* a compliant server the resend branch stays inert; it exists because the
|
|
206
|
+
* same backend's private protocol exhibited exactly this failure mode.
|
|
207
|
+
*
|
|
208
|
+
* Two guards keep a misflip from corrupting the rest of the turn:
|
|
209
|
+
* - the flip requires the accumulated text to be at least FLIP_MIN_CHARS,
|
|
210
|
+
* because short prefixes ("**", "\n", "#") are trivially extended by
|
|
211
|
+
* ordinary markdown deltas;
|
|
212
|
+
* - in cumulative mode, a frame that no longer extends the accumulator is
|
|
213
|
+
* evidence of a misflip: fall back to append mode instead of slicing. */
|
|
214
|
+
const FLIP_MIN_CHARS = 32;
|
|
215
|
+
|
|
216
|
+
export class TextAccumulator {
|
|
217
|
+
#acc = "";
|
|
218
|
+
#cumulative: boolean | undefined;
|
|
219
|
+
|
|
220
|
+
/** Returns the delta to emit, or null when nothing new should be emitted. */
|
|
221
|
+
append(delta: string): string | null {
|
|
222
|
+
if (this.#cumulative === undefined) this.#cumulative = false;
|
|
223
|
+
else if (
|
|
224
|
+
!this.#cumulative &&
|
|
225
|
+
this.#acc.length >= FLIP_MIN_CHARS &&
|
|
226
|
+
delta.length > this.#acc.length &&
|
|
227
|
+
delta.startsWith(this.#acc)
|
|
228
|
+
) {
|
|
229
|
+
this.#cumulative = true;
|
|
230
|
+
}
|
|
231
|
+
if (this.#cumulative) {
|
|
232
|
+
if (!delta.startsWith(this.#acc)) {
|
|
233
|
+
// Misflip evidence: back to deltas.
|
|
234
|
+
this.#cumulative = false;
|
|
235
|
+
this.#acc += delta;
|
|
236
|
+
return delta;
|
|
237
|
+
}
|
|
238
|
+
if (delta.length <= this.#acc.length) return null; // duplicate frame
|
|
239
|
+
const emit = delta.slice(this.#acc.length);
|
|
240
|
+
this.#acc = delta;
|
|
241
|
+
return emit;
|
|
242
|
+
}
|
|
243
|
+
this.#acc += delta;
|
|
244
|
+
return delta;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
get text(): string {
|
|
248
|
+
return this.#acc;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
// Newline-delimited JSON-RPC 2.0 session over a stdio transport.
|
|
2
|
+
//
|
|
3
|
+
// Transport-agnostic by design: the caller supplies a `send` sink (writes one
|
|
4
|
+
// serialized frame + newline to the child's stdin) and feeds incoming bytes to
|
|
5
|
+
// `feed()`. Everything ACP-specific lives in connection.ts.
|
|
6
|
+
//
|
|
7
|
+
// Semantics verified live against agy_acp_server (20260818_01_RC01):
|
|
8
|
+
// - requests are correlated by numeric id; responses reject the pending
|
|
9
|
+
// promise on JSON-RPC `error` results;
|
|
10
|
+
// - server-to-client REQUESTS (session/request_permission, fs/*, terminal/*)
|
|
11
|
+
// arrive as messages with both `method` and `id` and are answered through
|
|
12
|
+
// the handler registered via setRequestHandler;
|
|
13
|
+
// - notifications (session/update, auth_required) carry no id;
|
|
14
|
+
// - malformed lines are counted and dropped, never fatal (the parseAgyLine
|
|
15
|
+
// lesson: a chatty banner must not kill the reader loop);
|
|
16
|
+
// - `abortAll()` rejects every pending request (Gate D teardown: no promise
|
|
17
|
+
// survives a killed connection).
|
|
18
|
+
|
|
19
|
+
export interface JsonRpcErrorShape {
|
|
20
|
+
code: number;
|
|
21
|
+
message: string;
|
|
22
|
+
data?: unknown;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface JsonRpcIncoming {
|
|
26
|
+
id?: number | string | null;
|
|
27
|
+
method?: string;
|
|
28
|
+
params?: unknown;
|
|
29
|
+
result?: unknown;
|
|
30
|
+
error?: JsonRpcErrorShape;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type SendFn = (frame: string) => void;
|
|
34
|
+
|
|
35
|
+
export interface JsonRpcSessionOptions {
|
|
36
|
+
send: SendFn;
|
|
37
|
+
/** Server-to-client request (has method + id). Return the result value, or
|
|
38
|
+
* throw to answer with a JSON-RPC error. */
|
|
39
|
+
onRequest?: (method: string, params: unknown) => Promise<unknown>;
|
|
40
|
+
/** Server-to-client notification (method, no id). */
|
|
41
|
+
onNotification?: (method: string, params: unknown) => void;
|
|
42
|
+
/** Unparseable line (logged, never fatal). */
|
|
43
|
+
onParseError?: (line: string) => void;
|
|
44
|
+
/** Default timeout for requests without an explicit one (ms). 0 = none. */
|
|
45
|
+
defaultTimeoutMs?: number;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
interface Pending {
|
|
49
|
+
resolve: (value: unknown) => void;
|
|
50
|
+
reject: (err: Error) => void;
|
|
51
|
+
timer?: ReturnType<typeof setTimeout>;
|
|
52
|
+
method: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export class JsonRpcSession {
|
|
56
|
+
#send: SendFn;
|
|
57
|
+
#opts: JsonRpcSessionOptions;
|
|
58
|
+
#pending = new Map<number, Pending>();
|
|
59
|
+
#nextId = 1;
|
|
60
|
+
#aborted = false;
|
|
61
|
+
#lineBuf = "";
|
|
62
|
+
parseErrors = 0;
|
|
63
|
+
|
|
64
|
+
constructor(opts: JsonRpcSessionOptions) {
|
|
65
|
+
this.#opts = opts;
|
|
66
|
+
this.#send = opts.send;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Feed raw transport bytes. Stdio chunks are NOT newline-aligned: bytes
|
|
70
|
+
* are buffered and only complete lines are parsed (a frame split across a
|
|
71
|
+
* 64KB pipe boundary must never be dropped). */
|
|
72
|
+
feed(chunk: string): void {
|
|
73
|
+
this.#lineBuf += chunk;
|
|
74
|
+
const lines = this.#lineBuf.split("\n");
|
|
75
|
+
this.#lineBuf = lines.pop() ?? "";
|
|
76
|
+
for (const raw of lines) {
|
|
77
|
+
const line = raw.trim();
|
|
78
|
+
if (!line) continue;
|
|
79
|
+
let msg: JsonRpcIncoming;
|
|
80
|
+
try {
|
|
81
|
+
msg = JSON.parse(line) as JsonRpcIncoming;
|
|
82
|
+
} catch {
|
|
83
|
+
this.parseErrors += 1;
|
|
84
|
+
this.#opts.onParseError?.(line.slice(0, 200));
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
this.#handleMessage(msg);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
#handleMessage(msg: JsonRpcIncoming): void {
|
|
92
|
+
// Response to one of our requests.
|
|
93
|
+
if (msg.method === undefined && msg.id !== undefined && msg.id !== null) {
|
|
94
|
+
const id = Number(msg.id);
|
|
95
|
+
const pending = this.#pending.get(id);
|
|
96
|
+
if (!pending) return; // late response to an aborted request: drop
|
|
97
|
+
this.#pending.delete(id);
|
|
98
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
99
|
+
if (msg.error) {
|
|
100
|
+
pending.reject(new JsonRpcResponseError(msg.error));
|
|
101
|
+
} else {
|
|
102
|
+
pending.resolve(msg.result);
|
|
103
|
+
}
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
if (msg.method === undefined) return;
|
|
107
|
+
// Server-to-client request: must be answered with the same id. The
|
|
108
|
+
// handler is always invoked through Promise.resolve() so a synchronous
|
|
109
|
+
// throw can never escape into the reader loop.
|
|
110
|
+
if (msg.id !== undefined && msg.id !== null) {
|
|
111
|
+
const id = msg.id;
|
|
112
|
+
const handler = this.#opts.onRequest;
|
|
113
|
+
if (!handler) {
|
|
114
|
+
this.#sendError(id, -32601, `client does not support method: ${msg.method}`);
|
|
115
|
+
return;
|
|
116
|
+
}
|
|
117
|
+
void Promise.resolve()
|
|
118
|
+
.then(() => handler(msg.method as string, msg.params))
|
|
119
|
+
.then((result) => {
|
|
120
|
+
if (this.#aborted) return;
|
|
121
|
+
this.#send(JSON.stringify({ jsonrpc: "2.0", id, result: result ?? {} }));
|
|
122
|
+
})
|
|
123
|
+
.catch((err: unknown) => {
|
|
124
|
+
if (this.#aborted) return;
|
|
125
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
126
|
+
this.#sendError(id, -32000, message);
|
|
127
|
+
});
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
// Notification.
|
|
131
|
+
this.#opts.onNotification?.(msg.method, msg.params);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
#sendError(id: number | string, code: number, message: string): void {
|
|
135
|
+
if (this.#aborted) return;
|
|
136
|
+
this.#send(JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } }));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Send a request. Resolves with the result value; rejects on error
|
|
140
|
+
* responses (JsonRpcResponseError), timeouts, or abortAll(). */
|
|
141
|
+
request(method: string, params?: unknown, timeoutMs?: number): Promise<unknown> {
|
|
142
|
+
if (this.#aborted) return Promise.reject(new Error("connection aborted"));
|
|
143
|
+
const id = this.#nextId++;
|
|
144
|
+
const effective = timeoutMs ?? this.#opts.defaultTimeoutMs ?? 0;
|
|
145
|
+
return new Promise<unknown>((resolve, reject) => {
|
|
146
|
+
const pending: Pending = { resolve, reject, method };
|
|
147
|
+
if (effective > 0) {
|
|
148
|
+
pending.timer = setTimeout(() => {
|
|
149
|
+
this.#pending.delete(id);
|
|
150
|
+
reject(new Error(`ACP request timed out after ${effective}ms: ${method}`));
|
|
151
|
+
}, effective);
|
|
152
|
+
}
|
|
153
|
+
this.#pending.set(id, pending);
|
|
154
|
+
this.#send(JSON.stringify({ jsonrpc: "2.0", id, method, params: params ?? {} }));
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** Reject every pending request (Gate D teardown). The transport is going
|
|
159
|
+
* away; nothing will ever be written again. */
|
|
160
|
+
abortAll(reason: string): void {
|
|
161
|
+
this.#aborted = true;
|
|
162
|
+
for (const [id, pending] of this.#pending) {
|
|
163
|
+
if (pending.timer) clearTimeout(pending.timer);
|
|
164
|
+
pending.reject(new Error(`${reason} (request: ${pending.method})`));
|
|
165
|
+
this.#pending.delete(id);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
get pendingCount(): number {
|
|
170
|
+
return this.#pending.size;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** A JSON-RPC `error` result, preserving code + data (the -32602 `loc` paths
|
|
175
|
+
* and the -32601 method name are correction oracles; see the reference doc). */
|
|
176
|
+
export class JsonRpcResponseError extends Error {
|
|
177
|
+
readonly code: number;
|
|
178
|
+
readonly data: unknown;
|
|
179
|
+
constructor(error: JsonRpcErrorShape) {
|
|
180
|
+
super(error.message);
|
|
181
|
+
this.name = "JsonRpcResponseError";
|
|
182
|
+
this.code = error.code;
|
|
183
|
+
this.data = error.data;
|
|
184
|
+
}
|
|
185
|
+
}
|