@estebanforge/pi-antigravity-bridge 1.3.2 → 1.4.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 +49 -0
- package/README.md +25 -4
- 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 -134
- package/extensions/index.ts +141 -19
- package/package.json +8 -2
- package/src/acp/connection.ts +395 -0
- package/src/acp/driver.ts +719 -0
- package/src/acp/events.ts +250 -0
- package/src/acp/jsonrpc.ts +185 -0
- package/src/config.ts +50 -0
- package/src/diff-render.ts +15 -0
- package/src/driver-types.ts +144 -0
- package/src/driver.ts +49 -77
- package/src/mcp-server.ts +8 -1
- package/src/models.ts +9 -7
- package/src/provider.ts +181 -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
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -22,11 +22,27 @@ const CONFIG_PATH = path.join(
|
|
|
22
22
|
"config.json",
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
/** Which turn engine drives turns. "stream-json" is the tested default;
|
|
26
|
+
* "acp" is the official-server engine, opt-in (plan §9.5). */
|
|
27
|
+
export type Engine = "stream-json" | "acp";
|
|
25
28
|
export type AgyMode = "accept-edits" | "plan";
|
|
26
29
|
export type ThinkingTier = "low" | "medium" | "high";
|
|
27
30
|
export type BridgeTools = "none" | "mcp" | "all";
|
|
28
31
|
|
|
32
|
+
export interface AcpConfig {
|
|
33
|
+
/** Path to agy_acp_server.par. Empty = env AGY_ACP_BIN > PATH. */
|
|
34
|
+
bin: string;
|
|
35
|
+
/** Single policy today: auto-approve request_permission in-connection
|
|
36
|
+
* (parity with skipPermissions). Kept as a key so future policies do not
|
|
37
|
+
* change the config shape. */
|
|
38
|
+
permissions: "auto";
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
export interface AgyConfig {
|
|
42
|
+
/** Turn engine. Switching requires a pi restart (drivers wire at load). */
|
|
43
|
+
engine: Engine;
|
|
44
|
+
/** Official-server ACP engine options (used when engine = "acp"). */
|
|
45
|
+
acp: AcpConfig;
|
|
30
46
|
mode: AgyMode;
|
|
31
47
|
/** Auto-approve all agy tool permission requests (--dangerously-skip-permissions).
|
|
32
48
|
* Required for non-interactive use: without it, any `run_command` triggers an
|
|
@@ -60,15 +76,28 @@ export interface AgyConfig {
|
|
|
60
76
|
* sessions gain nothing: agy already keeps its own history, and bridge
|
|
61
77
|
* round-trips deliver tool results through the bridge, not the digest. */
|
|
62
78
|
digest: boolean;
|
|
79
|
+
/** Prepend pi's composed system prompt (pi tool guidance + the global
|
|
80
|
+
* agent-dir AGENTS.md and ancestor AGENTS.md/CLAUDE.md) to the FIRST
|
|
81
|
+
* prompt of each fresh agy conversation.
|
|
82
|
+
*
|
|
83
|
+
* Default ON: agy keeps its own history, so the prefix is sent once per
|
|
84
|
+
* conversation and stays byte-identical afterwards - agy's server-side
|
|
85
|
+
* prompt cache keeps hitting. This is why it is safe here while the G1
|
|
86
|
+
* digest (per-turn) is not. Turn off for agy-native behavior (agy's own
|
|
87
|
+
* system prompt only). */
|
|
88
|
+
systemPrompt: boolean;
|
|
63
89
|
}
|
|
64
90
|
|
|
65
91
|
const DEFAULTS: AgyConfig = {
|
|
92
|
+
engine: "stream-json",
|
|
66
93
|
mode: "accept-edits",
|
|
67
94
|
skipPermissions: true,
|
|
68
95
|
defaultModel: "flash",
|
|
69
96
|
defaultThinking: "medium",
|
|
70
97
|
bridgeTools: "mcp",
|
|
71
98
|
digest: false,
|
|
99
|
+
systemPrompt: true,
|
|
100
|
+
acp: { bin: "", permissions: "auto" },
|
|
72
101
|
};
|
|
73
102
|
|
|
74
103
|
/** Load config merged over defaults. Env vars override the file when set. */
|
|
@@ -88,6 +117,11 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
88
117
|
// The naive OR `env === "plan" || file.mode === "plan"` would ignore an
|
|
89
118
|
// explicit AGY_MODE=accept-edits when the file says plan, violating the
|
|
90
119
|
// documented precedence. Check env first.
|
|
120
|
+
// Engine: narrow to the known set; anything else (incl. the pre-1.3.2
|
|
121
|
+
// "sqlite" value) falls back to the tested default.
|
|
122
|
+
const engineRaw = String(process.env.AGY_ENGINE ?? file.engine ?? DEFAULTS.engine).toLowerCase();
|
|
123
|
+
const engine: Engine = engineRaw === "acp" ? "acp" : "stream-json";
|
|
124
|
+
|
|
91
125
|
const mode: AgyMode =
|
|
92
126
|
process.env.AGY_MODE !== undefined
|
|
93
127
|
? process.env.AGY_MODE === "plan"
|
|
@@ -121,13 +155,29 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
121
155
|
? ["1", "true", "on"].includes(process.env.AGY_DIGEST.toLowerCase())
|
|
122
156
|
: file.digest ?? false;
|
|
123
157
|
|
|
158
|
+
const envSys = process.env.AGY_SYSTEM_PROMPT;
|
|
159
|
+
const systemPrompt = envSys !== undefined
|
|
160
|
+
? ["1", "true", "on"].includes(envSys.toLowerCase())
|
|
161
|
+
: file.systemPrompt ?? DEFAULTS.systemPrompt;
|
|
162
|
+
|
|
163
|
+
const fileAcp = (typeof file.acp === "object" && file.acp !== null ? file.acp : {}) as Partial<AcpConfig>;
|
|
164
|
+
const acp: AcpConfig = {
|
|
165
|
+
bin:
|
|
166
|
+
process.env.AGY_ACP_BIN ??
|
|
167
|
+
(typeof fileAcp.bin === "string" ? fileAcp.bin : DEFAULTS.acp.bin),
|
|
168
|
+
permissions: "auto",
|
|
169
|
+
};
|
|
170
|
+
|
|
124
171
|
return {
|
|
172
|
+
engine,
|
|
173
|
+
acp,
|
|
125
174
|
mode,
|
|
126
175
|
skipPermissions,
|
|
127
176
|
defaultModel,
|
|
128
177
|
defaultThinking,
|
|
129
178
|
bridgeTools,
|
|
130
179
|
digest,
|
|
180
|
+
systemPrompt,
|
|
131
181
|
patchCleanupNotified: file.patchCleanupNotified === true,
|
|
132
182
|
};
|
|
133
183
|
}
|
package/src/diff-render.ts
CHANGED
|
@@ -188,3 +188,18 @@ function capLines(diff: string, max: number): string {
|
|
|
188
188
|
const dropped = lines.length - max;
|
|
189
189
|
return `${lines.slice(0, max).join("\n")}\n[... ${dropped} more diff lines]`;
|
|
190
190
|
}
|
|
191
|
+
|
|
192
|
+
/** Format an in-memory before/after pair as a line-numbered diff with pi's
|
|
193
|
+
* own generateDiffString. No git subprocess: the ACP engine supplies
|
|
194
|
+
* oldText/newText directly in tool_call content[] (Gate C), so the provider
|
|
195
|
+
* renders the native diff without touching the repo. Exported for the
|
|
196
|
+
* provider; TurnDiffContext keeps the git-sourced path for stream-json. */
|
|
197
|
+
export function formatInlineDiff(
|
|
198
|
+
oldContent: string,
|
|
199
|
+
newContent: string,
|
|
200
|
+
maxDiffLines: number = DEFAULT_MAX_DIFF_LINES,
|
|
201
|
+
): string {
|
|
202
|
+
if (oldContent === newContent) return "";
|
|
203
|
+
const { diff } = generateDiffString(oldContent, newContent);
|
|
204
|
+
return capLines(diff, maxDiffLines);
|
|
205
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Engine-agnostic turn-driver contract.
|
|
2
|
+
//
|
|
3
|
+
// Both turn engines (legacy stream-json driver in `driver.ts`, ACP driver in
|
|
4
|
+
// `acp/driver.ts`) implement `TurnDriver`, and everything above them — the
|
|
5
|
+
// provider's stream loop, the G9 round-trip store, the extension wiring —
|
|
6
|
+
// depends on this interface only. Types live here so the legacy module can be
|
|
7
|
+
// deleted (phase 4) without breaking imports.
|
|
8
|
+
//
|
|
9
|
+
// The ACP driver implements the same surface with protocol-native mechanics:
|
|
10
|
+
// no process recycle on profile drift, teardown-based abort (Gate D), and
|
|
11
|
+
// `session/load` resume. See docs/ACP-ADOPTION-PLAN.md section 9.
|
|
12
|
+
|
|
13
|
+
export type DriverState = "idle" | "starting" | "ready" | "running" | "dead";
|
|
14
|
+
|
|
15
|
+
export interface DriverProfile {
|
|
16
|
+
cwd: string;
|
|
17
|
+
model: string;
|
|
18
|
+
effort?: string;
|
|
19
|
+
mode: string;
|
|
20
|
+
skipPermissions: boolean;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface DriverTurnRequest extends DriverProfile {
|
|
24
|
+
/** Existing conversation/session to resume. Legacy: agy conversation id via
|
|
25
|
+
* `--conversation`. ACP: sessionId via `session/load` (falls back to
|
|
26
|
+
* `session/new` when the server no longer knows it). */
|
|
27
|
+
conversationId?: string | null;
|
|
28
|
+
prompt: string;
|
|
29
|
+
/** Image blocks riding with the prompt. ACP forwards them as typed
|
|
30
|
+
* content blocks (probe 2026-09-03: 64x64 two-tone PNG answered
|
|
31
|
+
* correctly); the legacy CLI prompt is text-only and ignores them. */
|
|
32
|
+
images?: Array<{ data: string; mimeType: string }>;
|
|
33
|
+
/** ACP only: pi-side context delivered as a native `embeddedContext`
|
|
34
|
+
* resource block instead of inline prompt text (G1 on ACP). Legacy
|
|
35
|
+
* embeds the digest in the prompt string and ignores this. */
|
|
36
|
+
contextBlock?: { uri: string; text: string };
|
|
37
|
+
signal?: AbortSignal;
|
|
38
|
+
/** Overall turn cap in minutes (default 10). Fractional values are valid
|
|
39
|
+
* (tests use sub-minute caps). */
|
|
40
|
+
timeoutMin?: number;
|
|
41
|
+
/** Stdout-inactivity cap in minutes (default 5). */
|
|
42
|
+
inactivityMin?: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export type AgyUsage = {
|
|
46
|
+
input_tokens?: number;
|
|
47
|
+
output_tokens?: number;
|
|
48
|
+
thinking_tokens?: number;
|
|
49
|
+
cache_read_tokens?: number;
|
|
50
|
+
total_tokens?: number;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export type DriverActivity =
|
|
54
|
+
| { type: "text"; delta: string }
|
|
55
|
+
/** Legacy emits a token count only; ACP carries the actual thought text in
|
|
56
|
+
* `delta`. The provider renders whichever is present. */
|
|
57
|
+
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
|
+
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown> }
|
|
59
|
+
| {
|
|
60
|
+
type: "tool_done";
|
|
61
|
+
stepId?: number;
|
|
62
|
+
name: string;
|
|
63
|
+
args: Record<string, unknown>;
|
|
64
|
+
output?: string;
|
|
65
|
+
durationSeconds?: number;
|
|
66
|
+
/** ACP only: the server's native edit diff from `tool_call`
|
|
67
|
+
* content[] ({type:"diff", path, oldText?, newText}). Legacy never
|
|
68
|
+
* sets it; the provider renders it without any git subprocess. */
|
|
69
|
+
diff?: { path: string; oldText?: string; newText: string };
|
|
70
|
+
}
|
|
71
|
+
| { type: "tool_error"; stepId?: number; name: string; message: string }
|
|
72
|
+
| { type: "usage"; usage: AgyUsage }
|
|
73
|
+
/** Synthetic: injected by the provider when the MCP bridge receives a call
|
|
74
|
+
* (G9). Parks the turn: output is expected to stall while pi executes the
|
|
75
|
+
* tool, so the driver suspends its idle/overall timers. */
|
|
76
|
+
| { type: "bridge_call"; callId: string; name: string; args: Record<string, unknown> };
|
|
77
|
+
|
|
78
|
+
export interface TurnOutcome {
|
|
79
|
+
conversationId?: string;
|
|
80
|
+
status: "OK" | "ERROR" | "UNKNOWN";
|
|
81
|
+
response: string;
|
|
82
|
+
error?: string;
|
|
83
|
+
usage?: AgyUsage;
|
|
84
|
+
finished: boolean;
|
|
85
|
+
aborted: boolean;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export interface TurnHandle {
|
|
89
|
+
id: string;
|
|
90
|
+
/** Resolves when the turn settles (result event, exit, abort, recycle). */
|
|
91
|
+
outcome: Promise<TurnOutcome>;
|
|
92
|
+
/** Pull the next activity. Resolves null once the activity stream closes. */
|
|
93
|
+
next(): Promise<DriverActivity | null>;
|
|
94
|
+
/** Inject a synthetic activity (bridge inbox). No-op after settle. */
|
|
95
|
+
pushExternal(activity: DriverActivity): void;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface DriverSnapshot {
|
|
99
|
+
state: DriverState;
|
|
100
|
+
pid?: number;
|
|
101
|
+
conversationId?: string;
|
|
102
|
+
stats: {
|
|
103
|
+
spawns: number;
|
|
104
|
+
turns: number;
|
|
105
|
+
reused: number;
|
|
106
|
+
recycles: number;
|
|
107
|
+
lastRecycleReason?: string;
|
|
108
|
+
recycleReasons: Record<string, number>;
|
|
109
|
+
};
|
|
110
|
+
lifecycle: string[];
|
|
111
|
+
/** Present on ACP snapshots; absent on legacy. */
|
|
112
|
+
engine?: "acp";
|
|
113
|
+
acp?: {
|
|
114
|
+
sessionId?: string;
|
|
115
|
+
prompts: number;
|
|
116
|
+
sessionsCreated: number;
|
|
117
|
+
sessionsLoaded: number;
|
|
118
|
+
kills: number;
|
|
119
|
+
/** null = never probed on this server process. */
|
|
120
|
+
cancelSupported: boolean | null;
|
|
121
|
+
serverVersion?: string;
|
|
122
|
+
/** Connections beyond the first this driver process made = server
|
|
123
|
+
* restarts (Gate D kills + stale-exit replacements). */
|
|
124
|
+
reconnects: number;
|
|
125
|
+
/** From the initialize handshake agentInfo block. */
|
|
126
|
+
agentName?: string;
|
|
127
|
+
agentTitle?: string;
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The engine contract. Everything above the driver depends on this interface
|
|
132
|
+
* only; `AgyDriver` and `AcpDriver` both implement it. */
|
|
133
|
+
export interface TurnDriver {
|
|
134
|
+
readonly state: DriverState;
|
|
135
|
+
readonly activeHandle: TurnHandle | null;
|
|
136
|
+
run(request: DriverTurnRequest): Promise<TurnHandle>;
|
|
137
|
+
/** Re-attach to the active turn (pi toolUse continuation). */
|
|
138
|
+
reentry(): TurnHandle | null;
|
|
139
|
+
/** Resume turn timers after a parked G9 round-trip settles. */
|
|
140
|
+
kickIdle(): void;
|
|
141
|
+
set onTurnEnd(fn: ((outcome: TurnOutcome) => void) | undefined);
|
|
142
|
+
snapshot(): DriverSnapshot;
|
|
143
|
+
close(reason: "recycle" | "shutdown", cause?: string): Promise<void>;
|
|
144
|
+
}
|