@estebanforge/pi-antigravity-bridge 1.6.4 → 1.7.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 +26 -1
- package/README.md +18 -16
- package/extensions/index.ts +163 -30
- package/package.json +1 -1
- package/src/acp/connection.ts +54 -9
- package/src/acp/driver.ts +42 -19
- package/src/acp/events.ts +8 -4
- package/src/acp/usage-estimate.ts +3 -1
- package/src/ask-tool.ts +5 -2
- package/src/bridge-catalog.ts +29 -0
- package/src/config.ts +11 -0
- package/src/daily-log.ts +17 -4
- package/src/driver-types.ts +3 -2
- package/src/driver.ts +4 -1
- package/src/provider.ts +50 -15
- package/src/redact.ts +27 -0
- package/src/web-tools.ts +312 -0
package/src/acp/connection.ts
CHANGED
|
@@ -25,6 +25,8 @@ import os from "node:os";
|
|
|
25
25
|
import path from "node:path";
|
|
26
26
|
import { parseAuthPort, readLastUrl } from "./browser-capture.js";
|
|
27
27
|
import { frameCarriesUsage } from "./events.js";
|
|
28
|
+
import type { AgyUsage } from "../driver-types.js";
|
|
29
|
+
import { redactText } from "../redact.js";
|
|
28
30
|
import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
|
|
29
31
|
|
|
30
32
|
export interface AcpMcpServer {
|
|
@@ -82,6 +84,10 @@ export class AcpConnection {
|
|
|
82
84
|
#rpc: JsonRpcSession | undefined;
|
|
83
85
|
#stderrTail = "";
|
|
84
86
|
#usageSeen = false;
|
|
87
|
+
/** Set ONLY by a parsed PromptResponse.usage; this gates estimate
|
|
88
|
+
* suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
|
|
89
|
+
* it, and the connection outlives turns. */
|
|
90
|
+
#exactUsageSeen = false;
|
|
85
91
|
#exited = false;
|
|
86
92
|
#killed = false;
|
|
87
93
|
#suppressUpdates = false;
|
|
@@ -105,7 +111,9 @@ export class AcpConnection {
|
|
|
105
111
|
}
|
|
106
112
|
|
|
107
113
|
get stderrTail(): string {
|
|
108
|
-
|
|
114
|
+
// Redacted at the boundary: the raw tail stays internal, every consumer
|
|
115
|
+
// (doctor display, driver exit logs, failure messages) sees redacted text.
|
|
116
|
+
return redactText(this.#stderrTail);
|
|
109
117
|
}
|
|
110
118
|
|
|
111
119
|
/** Gate B watch: latched true once any session/update frame carried
|
|
@@ -115,6 +123,11 @@ export class AcpConnection {
|
|
|
115
123
|
return this.#usageSeen;
|
|
116
124
|
}
|
|
117
125
|
|
|
126
|
+
/** True once the server sent real per-turn usage (PromptResponse.usage). */
|
|
127
|
+
get exactUsageSeen(): boolean {
|
|
128
|
+
return this.#exactUsageSeen;
|
|
129
|
+
}
|
|
130
|
+
|
|
118
131
|
/** While true, session/update notifications are dropped: they are the
|
|
119
132
|
* full-text history replay that precedes a session/load response, never
|
|
120
133
|
* live generation (run 6). */
|
|
@@ -142,8 +155,8 @@ export class AcpConnection {
|
|
|
142
155
|
// try/catch in #write cannot see them. Without this listener an EPIPE
|
|
143
156
|
// (server died mid-handshake) is uncaught and kills pi.
|
|
144
157
|
child.stdin?.on("error", (err) => {
|
|
145
|
-
this.#opts.log("stdin-error", { message: err.message });
|
|
146
|
-
if (!this.#exited && !this.#killed) this.#finish(err.message);
|
|
158
|
+
this.#opts.log("stdin-error", { message: redactText(err.message) });
|
|
159
|
+
if (!this.#exited && !this.#killed) this.#finish(redactText(err.message));
|
|
147
160
|
});
|
|
148
161
|
|
|
149
162
|
const rpc = new JsonRpcSession({
|
|
@@ -159,12 +172,14 @@ export class AcpConnection {
|
|
|
159
172
|
this.#stderrTail = (this.#stderrTail + chunk).slice(-8192);
|
|
160
173
|
});
|
|
161
174
|
child.on("error", (err) => {
|
|
162
|
-
this.#opts.log("spawn-error", { message: err.message });
|
|
163
|
-
this.#finish(err.message);
|
|
175
|
+
this.#opts.log("spawn-error", { message: redactText(err.message) });
|
|
176
|
+
this.#finish(redactText(err.message));
|
|
164
177
|
});
|
|
165
178
|
child.on("exit", (code, signal) => {
|
|
166
179
|
this.#opts.log("exit", { code: code ?? signal ?? "?" });
|
|
167
|
-
|
|
180
|
+
// The reason rides into pending-request rejections, so it must be
|
|
181
|
+
// redacted here (server stderr can carry auth material).
|
|
182
|
+
this.#finish(redactText(this.#stderrTail.trim()));
|
|
168
183
|
});
|
|
169
184
|
|
|
170
185
|
const result = (await this.request(
|
|
@@ -237,7 +252,7 @@ export class AcpConnection {
|
|
|
237
252
|
text: string,
|
|
238
253
|
images: Array<{ data: string; mimeType: string }> = [],
|
|
239
254
|
contextBlock?: { uri: string; text: string },
|
|
240
|
-
): Promise<{ stopReason: string }> {
|
|
255
|
+
): Promise<{ stopReason: string; usage?: AgyUsage }> {
|
|
241
256
|
// Block order: images, then the embedded-context resource (pi-side
|
|
242
257
|
// digest), then the text question last (it refers to everything before
|
|
243
258
|
// it). Shapes per the ACP v1 content-block schema; embeddedContext is
|
|
@@ -264,7 +279,15 @@ export class AcpConnection {
|
|
|
264
279
|
if (typeof stopReason !== "string") {
|
|
265
280
|
throw new Error("ACP session/prompt returned no stopReason");
|
|
266
281
|
}
|
|
267
|
-
|
|
282
|
+
// Optional, unstable ACP PromptResponse.usage: absent on 1.1.1 but
|
|
283
|
+
// parsed for the day the server forwards its SDK usage_metadata. Never
|
|
284
|
+
// infer exact counts from a context-window usage_update ({used,size}).
|
|
285
|
+
const usage = parseAcpTurnUsage(result?.usage);
|
|
286
|
+
if (usage) {
|
|
287
|
+
this.#usageSeen = true;
|
|
288
|
+
this.#exactUsageSeen = true;
|
|
289
|
+
}
|
|
290
|
+
return { stopReason, ...(usage ? { usage } : {}) };
|
|
268
291
|
}
|
|
269
292
|
|
|
270
293
|
/** Probe-and-cancel. Returns supported:false when the server does not
|
|
@@ -426,10 +449,32 @@ export class AcpConnection {
|
|
|
426
449
|
this.#authUrlWatcher?.close();
|
|
427
450
|
this.#authUrlWatcher = undefined;
|
|
428
451
|
this.abortAll(`connection exited: ${reason || "process gone"}`);
|
|
429
|
-
this.#opts.onExit({ code: null, signal: null, stderrTail: this.#stderrTail });
|
|
452
|
+
this.#opts.onExit({ code: null, signal: null, stderrTail: redactText(this.#stderrTail) });
|
|
430
453
|
}
|
|
431
454
|
}
|
|
432
455
|
|
|
456
|
+
/** ACP's optional (unstable) per-turn PromptResponse.usage, not the
|
|
457
|
+
* session/usage_update context-window occupancy notification. */
|
|
458
|
+
export function parseAcpTurnUsage(value: unknown): AgyUsage | undefined {
|
|
459
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
|
|
460
|
+
const u = value as Record<string, unknown>;
|
|
461
|
+
const count = (v: unknown): number | undefined =>
|
|
462
|
+
typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
|
|
463
|
+
const input = count(u.inputTokens);
|
|
464
|
+
const output = count(u.outputTokens);
|
|
465
|
+
const total = count(u.totalTokens);
|
|
466
|
+
const thought = count(u.thoughtTokens);
|
|
467
|
+
const cacheRead = count(u.cachedReadTokens);
|
|
468
|
+
if (input === undefined || output === undefined || total === undefined) return undefined;
|
|
469
|
+
return {
|
|
470
|
+
input_tokens: input,
|
|
471
|
+
output_tokens: output,
|
|
472
|
+
total_tokens: total,
|
|
473
|
+
...(thought === undefined ? {} : { thinking_tokens: thought }),
|
|
474
|
+
...(cacheRead === undefined ? {} : { cache_read_tokens: cacheRead }),
|
|
475
|
+
};
|
|
476
|
+
}
|
|
477
|
+
|
|
433
478
|
export function translateError(method: string, err: unknown): Error {
|
|
434
479
|
if (err instanceof AcpAuthError) return err;
|
|
435
480
|
if (err instanceof JsonRpcResponseError) {
|
package/src/acp/driver.ts
CHANGED
|
@@ -73,6 +73,8 @@ interface ActiveTurn {
|
|
|
73
73
|
thoughtTokens: number;
|
|
74
74
|
textDeltas: number;
|
|
75
75
|
thoughtDeltas: number;
|
|
76
|
+
/** Estimated once, not re-tokenized for every streaming delta. */
|
|
77
|
+
estimatedInputTokens: number;
|
|
76
78
|
sawResult: boolean;
|
|
77
79
|
/** True once the prompt RPC was issued. Abort before this point has
|
|
78
80
|
* nothing to cancel: probing would risk a success-as-noop answer from a
|
|
@@ -89,12 +91,7 @@ interface ActiveTurn {
|
|
|
89
91
|
idleTimer?: ReturnType<typeof setTimeout>;
|
|
90
92
|
/** toolCallId → tool name + args + optional native diff (diff rides on
|
|
91
93
|
* the pending tool_call frame; updates don't repeat it). */
|
|
92
|
-
toolCalls: Map<string, { name: string; args: Record<string, unknown>; diff?: AcpEditDiff }>;
|
|
93
|
-
/** Last pending native tool seen. The supersede quirk (run 6, finding 7)
|
|
94
|
-
* means the executing call can arrive under a DIFFERENT id than the
|
|
95
|
-
* approved one; unknown-id updates adopt this so the diff and name are
|
|
96
|
-
* not lost. */
|
|
97
|
-
lastNativeTool?: { name: string; args: Record<string, unknown>; diff?: AcpEditDiff };
|
|
94
|
+
toolCalls: Map<string, { name: string; args: Record<string, unknown>; diff?: AcpEditDiff; mcpServer?: string }>;
|
|
98
95
|
}
|
|
99
96
|
|
|
100
97
|
/** Recombine the provider's (base slug, effort) into the FULL ACP model slug.
|
|
@@ -309,6 +306,10 @@ export class AcpDriver implements TurnDriver {
|
|
|
309
306
|
// Timers: overall (turn deadline, pause-aware) + idle (inactivity).
|
|
310
307
|
this.#armOverall(turn);
|
|
311
308
|
this.#armIdle(turn);
|
|
309
|
+
// Live usage is explicitly an estimate: ACP 1.1.1 does not forward the
|
|
310
|
+
// SDK's exact usage_metadata onto the ACP wire. Emit input first; output
|
|
311
|
+
// estimates advance with each text/thought delta below.
|
|
312
|
+
this.#emitLiveEstimate(turn);
|
|
312
313
|
|
|
313
314
|
// Prompt. Updates stream through the connection's onUpdate callback.
|
|
314
315
|
try {
|
|
@@ -317,11 +318,15 @@ export class AcpDriver implements TurnDriver {
|
|
|
317
318
|
const result = await conn.prompt(turn.sessionId, request.prompt, request.images, request.contextBlock);
|
|
318
319
|
if (turn.closed) return;
|
|
319
320
|
turn.sawResult = true;
|
|
321
|
+
// When a future server fills ACP PromptResponse.usage, exact counts
|
|
322
|
+
// supersede every partial estimate at the end of the turn.
|
|
323
|
+
if (result.usage) this.#emit(turn, { type: "usage", usage: result.usage });
|
|
320
324
|
const mapped = mapStopReason(result.stopReason);
|
|
321
325
|
this.#settle(turn, {
|
|
322
326
|
conversationId: turn.sessionId,
|
|
323
327
|
status: mapped.status,
|
|
324
328
|
response: turn.response.text,
|
|
329
|
+
usage: result.usage,
|
|
325
330
|
error: mapped.error,
|
|
326
331
|
finished: true,
|
|
327
332
|
aborted: mapped.aborted,
|
|
@@ -376,42 +381,49 @@ export class AcpDriver implements TurnDriver {
|
|
|
376
381
|
if (emit) {
|
|
377
382
|
turn.textDeltas += 1;
|
|
378
383
|
turn.textTokens += estimateTokens(emit);
|
|
384
|
+
this.#emitLiveEstimate(turn);
|
|
379
385
|
this.#emit(turn, { type: "text", delta: emit });
|
|
380
386
|
}
|
|
381
387
|
return;
|
|
382
388
|
}
|
|
383
389
|
case "thought": {
|
|
390
|
+
if (!mapped.delta) return;
|
|
384
391
|
turn.thoughtDeltas += 1;
|
|
385
392
|
turn.thoughtTokens += estimateTokens(mapped.delta);
|
|
393
|
+
this.#emitLiveEstimate(turn);
|
|
386
394
|
this.#emit(turn, { type: "thought", delta: mapped.delta });
|
|
387
395
|
return;
|
|
388
396
|
}
|
|
389
397
|
case "tool_start": {
|
|
390
|
-
const entry = { name: mapped.name, args: mapped.args, diff: mapped.diff };
|
|
398
|
+
const entry = { name: mapped.name, args: mapped.args, diff: mapped.diff, mcpServer: mapped.mcpServer };
|
|
391
399
|
turn.toolCalls.set(mapped.toolCallId, entry);
|
|
392
|
-
turn.
|
|
393
|
-
this.#emit(turn, { type: "tool_start", name: mapped.name, args: mapped.args });
|
|
400
|
+
this.#emit(turn, { type: "tool_start", name: mapped.name, args: mapped.args, mcpServer: mapped.mcpServer });
|
|
394
401
|
return;
|
|
395
402
|
}
|
|
396
403
|
case "tool_done": {
|
|
397
404
|
let entry = turn.toolCalls.get(mapped.toolCallId);
|
|
398
|
-
if (!entry && turn.
|
|
399
|
-
//
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
405
|
+
if (!entry && turn.toolCalls.size === 1) {
|
|
406
|
+
// The server can finish an approved tool under a different id.
|
|
407
|
+
// Adopt only when exactly one call is pending: with concurrent
|
|
408
|
+
// tools, guessing would attach an edit diff to the wrong file.
|
|
409
|
+
// Residual: an unrelated done with an unknown id still pairs
|
|
410
|
+
// with the pending entry; strictly better than guessing ANY id.
|
|
411
|
+
entry = turn.toolCalls.values().next().value;
|
|
412
|
+
turn.toolCalls.clear();
|
|
403
413
|
}
|
|
414
|
+
turn.toolCalls.delete(mapped.toolCallId);
|
|
404
415
|
const name = entry?.name ?? "tool";
|
|
405
416
|
const args = entry?.args ?? {};
|
|
406
417
|
// Native diff from the stored tool_call frame; the update's own
|
|
407
418
|
// diff (future builds) wins when present.
|
|
408
|
-
this.#emit(turn, { type: "tool_done", name, args, output: mapped.output, diff: mapped.diff ?? entry?.diff });
|
|
419
|
+
this.#emit(turn, { type: "tool_done", name, args, output: mapped.output, diff: mapped.diff ?? entry?.diff, mcpServer: mapped.mcpServer ?? entry?.mcpServer });
|
|
409
420
|
return;
|
|
410
421
|
}
|
|
411
422
|
case "tool_error": {
|
|
412
423
|
const entry = turn.toolCalls.get(mapped.toolCallId);
|
|
424
|
+
turn.toolCalls.delete(mapped.toolCallId);
|
|
413
425
|
const name = entry?.name ?? "tool";
|
|
414
|
-
this.#emit(turn, { type: "tool_error", name, message: mapped.message });
|
|
426
|
+
this.#emit(turn, { type: "tool_error", name, message: mapped.message, mcpServer: mapped.mcpServer ?? entry?.mcpServer });
|
|
415
427
|
return;
|
|
416
428
|
}
|
|
417
429
|
case "replay_user":
|
|
@@ -567,6 +579,7 @@ export class AcpDriver implements TurnDriver {
|
|
|
567
579
|
thoughtTokens: 0,
|
|
568
580
|
textDeltas: 0,
|
|
569
581
|
thoughtDeltas: 0,
|
|
582
|
+
estimatedInputTokens: estimateTokens(request.contextBlock?.text ? `${request.prompt}\n${request.contextBlock.text}` : request.prompt),
|
|
570
583
|
sawResult: false,
|
|
571
584
|
promptStarted: false,
|
|
572
585
|
aborted: false,
|
|
@@ -717,16 +730,26 @@ export class AcpDriver implements TurnDriver {
|
|
|
717
730
|
return (typeof opt === "function" ? opt() : opt) ?? "estimate";
|
|
718
731
|
}
|
|
719
732
|
|
|
733
|
+
/** Update Pi's partial usage before the next visible text/thought delta.
|
|
734
|
+
* This is not provider-reported billing or an exact context-window count. */
|
|
735
|
+
#emitLiveEstimate(turn: ActiveTurn): void {
|
|
736
|
+
const usage = this.#syntheticUsage(turn);
|
|
737
|
+
if (usage) this.#emit(turn, { type: "usage", usage });
|
|
738
|
+
}
|
|
739
|
+
|
|
720
740
|
#syntheticUsage(turn: ActiveTurn): AgyUsage | undefined {
|
|
721
741
|
const mode = this.#usageMode();
|
|
722
742
|
if (mode === "off") return undefined;
|
|
723
|
-
//
|
|
724
|
-
//
|
|
725
|
-
|
|
743
|
+
// Superseded only by EXACT PromptResponse.usage. Diagnostic usage-shaped
|
|
744
|
+
// frames (context windows, occupancy) must not silence the estimates:
|
|
745
|
+
// the connection outlives turns and no exact data ever replaces them
|
|
746
|
+
// on ACP 1.1.1 (peer review 2026-09-23).
|
|
747
|
+
if (this.#conn?.exactUsageSeen) return undefined;
|
|
726
748
|
return synthesizeUsage({
|
|
727
749
|
mode,
|
|
728
750
|
prompt: turn.request.prompt,
|
|
729
751
|
contextText: turn.request.contextBlock?.text,
|
|
752
|
+
inputTokens: turn.estimatedInputTokens,
|
|
730
753
|
textTokens: turn.textTokens,
|
|
731
754
|
thoughtTokens: turn.thoughtTokens,
|
|
732
755
|
textDeltas: turn.textDeltas,
|
package/src/acp/events.ts
CHANGED
|
@@ -30,9 +30,9 @@ export interface AcpEditDiff {
|
|
|
30
30
|
export type MappedUpdate =
|
|
31
31
|
| { kind: "text"; delta: string }
|
|
32
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 }
|
|
33
|
+
| { kind: "tool_start"; toolCallId: string; name: string; args: Record<string, unknown>; diff?: AcpEditDiff; mcpServer?: string }
|
|
34
|
+
| { kind: "tool_done"; toolCallId: string; output?: string; diff?: AcpEditDiff; mcpServer?: string }
|
|
35
|
+
| { kind: "tool_error"; toolCallId: string; message: string; mcpServer?: string }
|
|
36
36
|
| { kind: "replay_user" }
|
|
37
37
|
| null;
|
|
38
38
|
|
|
@@ -56,6 +56,7 @@ export function mapUpdate(update: unknown): MappedUpdate {
|
|
|
56
56
|
toolCallId: id,
|
|
57
57
|
name: metaToolName(u._meta) ?? toolName(u.title, u.kind),
|
|
58
58
|
args: rawArguments(u.rawInput),
|
|
59
|
+
mcpServer: stringField(recordField(recordField(u._meta).mcp).server),
|
|
59
60
|
// Edits carry their native diff HERE, on the pending tool_call
|
|
60
61
|
// frame (run 6:10) — the completed update carries only display
|
|
61
62
|
// text. contentDiff on the update stays as a future-build
|
|
@@ -74,17 +75,20 @@ export function mapUpdate(update: unknown): MappedUpdate {
|
|
|
74
75
|
// failure next to a successful edit.
|
|
75
76
|
const raw = stringField(u.rawOutput);
|
|
76
77
|
if (raw?.includes("approved but never executed")) return null;
|
|
77
|
-
|
|
78
|
+
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server);
|
|
79
|
+
return { kind: "tool_error", toolCallId: id, message: raw ?? "tool failed", ...(mcpServer ? { mcpServer } : {}) };
|
|
78
80
|
}
|
|
79
81
|
if (u.status === "completed") {
|
|
80
82
|
// content[] first (edits carry their diff there); rawOutput alone is
|
|
81
83
|
// often just the server's display title, not the result (probe
|
|
82
84
|
// 2026-09-03: completed MCP call, rawOutput "Call bridge_echo").
|
|
83
85
|
const diff = contentDiff(u.content);
|
|
86
|
+
const mcpServer = stringField(recordField(recordField(u._meta).mcp).server);
|
|
84
87
|
return {
|
|
85
88
|
kind: "tool_done",
|
|
86
89
|
toolCallId: id,
|
|
87
90
|
output: contentText(u.content) ?? stringField(u.rawOutput),
|
|
91
|
+
...(mcpServer ? { mcpServer } : {}),
|
|
88
92
|
...(diff ? { diff } : {}),
|
|
89
93
|
};
|
|
90
94
|
}
|
|
@@ -22,6 +22,8 @@ export interface UsageEstimateInput {
|
|
|
22
22
|
mode: UsageEstimate;
|
|
23
23
|
/** Full outgoing prompt text (input side; always regex-estimated). */
|
|
24
24
|
prompt: string;
|
|
25
|
+
/** Cached estimate for repeated live updates during the same turn. */
|
|
26
|
+
inputTokens?: number;
|
|
25
27
|
/** ACP embeddedContext resource text (G1 digest): also reaches the model. */
|
|
26
28
|
contextText?: string;
|
|
27
29
|
/** Per-delta token sums (mode "estimate"). Summing per delta, like
|
|
@@ -38,7 +40,7 @@ export interface UsageEstimateInput {
|
|
|
38
40
|
* reporting (mode off, or a turn with no prompt and no output). */
|
|
39
41
|
export function synthesizeUsage(input: UsageEstimateInput): AgyUsage | undefined {
|
|
40
42
|
if (input.mode === "off") return undefined;
|
|
41
|
-
const inputTokens = estimateTokens(
|
|
43
|
+
const inputTokens = input.inputTokens ?? estimateTokens(
|
|
42
44
|
input.contextText ? `${input.prompt}\n${input.contextText}` : input.prompt,
|
|
43
45
|
);
|
|
44
46
|
const thoughtTokens =
|
package/src/ask-tool.ts
CHANGED
|
@@ -25,6 +25,7 @@ import {
|
|
|
25
25
|
snapshotConversations,
|
|
26
26
|
} from "./discovery.js";
|
|
27
27
|
import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
|
|
28
|
+
import { redactText } from "./redact.js";
|
|
28
29
|
import { acquireBridgeSuppression } from "./mcp-registration.js";
|
|
29
30
|
import { AGY_EFFORT_ORDER, spawnAgyModelsRaw, toAgyEffort } from "./models.js";
|
|
30
31
|
|
|
@@ -742,8 +743,10 @@ export async function registerAskAntigravityTool(
|
|
|
742
743
|
}
|
|
743
744
|
|
|
744
745
|
if (outcome.exitCode !== 0) {
|
|
745
|
-
|
|
746
|
-
|
|
746
|
+
// stderr can carry auth material; the note is chat-visible.
|
|
747
|
+
const stderr = redactText(details.stderr.trim());
|
|
748
|
+
const note = stderr
|
|
749
|
+
? `agy exited with status ${outcome.exitCode}: ${stderr}`
|
|
747
750
|
: `agy exited with status ${outcome.exitCode}`;
|
|
748
751
|
return {
|
|
749
752
|
content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { BridgeTools } from "./config.js";
|
|
2
|
+
|
|
3
|
+
export interface BridgeToolInfo {
|
|
4
|
+
name: string;
|
|
5
|
+
description?: string;
|
|
6
|
+
parameters?: object;
|
|
7
|
+
sourceInfo?: { source?: string };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** The bridge has one live HTTP endpoint. Catalog and dispatch must both use
|
|
11
|
+
* the CURRENT Pi active set: /mcp and pi.setActiveTools() can change it mid-session.
|
|
12
|
+
* A stale tools/call must not bypass a disabled Pi tool. */
|
|
13
|
+
export function bridgedPiTools(
|
|
14
|
+
all: BridgeToolInfo[], active: readonly string[], mode: BridgeTools, hidden: ReadonlySet<string>,
|
|
15
|
+
): BridgeToolInfo[] {
|
|
16
|
+
if (mode === "none") return [];
|
|
17
|
+
const enabled = new Set(active);
|
|
18
|
+
return all.filter((tool) =>
|
|
19
|
+
enabled.has(tool.name) && !hidden.has(tool.name) &&
|
|
20
|
+
// Never bridge the bridge's own tools. agy has native web tools, so
|
|
21
|
+
// our quota-burning one-shot wrappers must not tempt it through MCP.
|
|
22
|
+
tool.name !== "AskAntigravity" && tool.name !== "antigravity" &&
|
|
23
|
+
tool.name !== "agy_web_search" && tool.name !== "agy_read_url" &&
|
|
24
|
+
tool.name !== "bridge_poll_result" && tool.name !== "activate_skill" &&
|
|
25
|
+
(mode === "mcp"
|
|
26
|
+
? /pi-mcp-adapter/.test(tool.sourceInfo?.source ?? "")
|
|
27
|
+
: tool.sourceInfo?.source !== "builtin"),
|
|
28
|
+
);
|
|
29
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -107,6 +107,11 @@ export interface AgyConfig {
|
|
|
107
107
|
* and models (no delegation tool in the model's window context).
|
|
108
108
|
* Takes effect at pi start (or /reload). */
|
|
109
109
|
askTool: boolean;
|
|
110
|
+
/** Register the agy_web_search + agy_read_url Pi tools. Default OFF:
|
|
111
|
+
* inside Antigravity sessions the model already has native web tools;
|
|
112
|
+
* these exist for NON-Antigravity provider sessions and every call
|
|
113
|
+
* spends Antigravity quota. Takes effect at pi start (or /reload). */
|
|
114
|
+
webTools: boolean;
|
|
110
115
|
/** Set after the one-time notice about a leftover legacy invokeTool patch
|
|
111
116
|
* on the installed pi. The notice never repeats; /agy patch-cleanup is
|
|
112
117
|
* always available. */
|
|
@@ -175,6 +180,7 @@ const DEFAULTS: AgyConfig = {
|
|
|
175
180
|
defaultModel: "flash",
|
|
176
181
|
defaultThinking: "medium",
|
|
177
182
|
askTool: true,
|
|
183
|
+
webTools: false,
|
|
178
184
|
bridgeTools: "all",
|
|
179
185
|
digest: false,
|
|
180
186
|
systemPrompt: true,
|
|
@@ -257,6 +263,10 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
257
263
|
? ["1", "true", "on"].includes(process.env.AGY_ASK_TOOL.toLowerCase())
|
|
258
264
|
: file.askTool ?? DEFAULTS.askTool;
|
|
259
265
|
|
|
266
|
+
const webTools = process.env.AGY_WEB_TOOLS !== undefined
|
|
267
|
+
? ["1", "true", "on"].includes(process.env.AGY_WEB_TOOLS.toLowerCase())
|
|
268
|
+
: file.webTools ?? DEFAULTS.webTools;
|
|
269
|
+
|
|
260
270
|
const bridgeRaw = (process.env.AGY_BRIDGE_TOOLS ?? file.bridgeTools ?? DEFAULTS.bridgeTools).toLowerCase();
|
|
261
271
|
const bridgeTools: BridgeTools =
|
|
262
272
|
bridgeRaw === "none" || bridgeRaw === "all" || bridgeRaw === "mcp"
|
|
@@ -319,6 +329,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
319
329
|
defaultModel,
|
|
320
330
|
defaultThinking,
|
|
321
331
|
askTool,
|
|
332
|
+
webTools,
|
|
322
333
|
bridgeTools,
|
|
323
334
|
digest,
|
|
324
335
|
systemPrompt,
|
package/src/daily-log.ts
CHANGED
|
@@ -12,7 +12,9 @@
|
|
|
12
12
|
// - One appendFile per record: O_APPEND keeps single-line writes atomic,
|
|
13
13
|
// so two pi tabs sharing the dir stay line-consistent.
|
|
14
14
|
// - No secrets, no prompt content: values of secret-shaped keys are
|
|
15
|
-
// redacted
|
|
15
|
+
// redacted wholesale, secret-shaped material inside string values
|
|
16
|
+
// (stderr tails, error messages) is scrubbed, and long strings are
|
|
17
|
+
// truncated before they reach disk.
|
|
16
18
|
// - Retention: files older than `retentionDays` are pruned once per
|
|
17
19
|
// process, so the dir cannot grow unbounded.
|
|
18
20
|
// - Volume tiers: default installs write ONLY errors — routine logging
|
|
@@ -22,6 +24,7 @@
|
|
|
22
24
|
|
|
23
25
|
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
24
26
|
import path from "node:path";
|
|
27
|
+
import { redactText } from "./redact.js";
|
|
25
28
|
|
|
26
29
|
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
27
30
|
|
|
@@ -63,14 +66,24 @@ const MAX_RECORD = 4000;
|
|
|
63
66
|
// lives in `value`, which a name-only regex would miss.
|
|
64
67
|
const SECRET_KEY = /token|secret|password|passphrase|authorization|api[-_]?key|cookie|headers/i;
|
|
65
68
|
|
|
66
|
-
/** Redact secret-shaped
|
|
69
|
+
/** Redact secret-shaped keys wholesale, scrub secrets embedded in string
|
|
70
|
+
* values, and cap runaway strings before disk. */
|
|
67
71
|
function scrub(value: unknown, depth: number): unknown {
|
|
68
72
|
if (value === null || value === undefined) return value;
|
|
69
73
|
if (value instanceof Error) {
|
|
70
|
-
|
|
74
|
+
// message goes through the string branch: errors are the main carrier of
|
|
75
|
+
// subprocess stderr text (the exact leak class this scrubber exists for).
|
|
76
|
+
return {
|
|
77
|
+
name: value.name,
|
|
78
|
+
message: scrub(value.message, depth),
|
|
79
|
+
stack: scrub(value.stack, depth),
|
|
80
|
+
};
|
|
71
81
|
}
|
|
72
82
|
if (typeof value === "string") {
|
|
73
|
-
|
|
83
|
+
// Value scan first, truncate second: a cut token could lose the tail
|
|
84
|
+
// characters the pattern needs to match.
|
|
85
|
+
const clean = redactText(value);
|
|
86
|
+
return clean.length > MAX_STRING ? clean.slice(0, MAX_STRING) + "…(truncated)" : clean;
|
|
74
87
|
}
|
|
75
88
|
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
76
89
|
if (depth >= MAX_DEPTH) return "(depth limit)";
|
package/src/driver-types.ts
CHANGED
|
@@ -55,12 +55,13 @@ export type DriverActivity =
|
|
|
55
55
|
/** Stream-json emits a token count only; ACP carries the actual thought text in
|
|
56
56
|
* `delta`. The provider renders whichever is present. */
|
|
57
57
|
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
|
-
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown
|
|
58
|
+
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown>; mcpServer?: string }
|
|
59
59
|
| {
|
|
60
60
|
type: "tool_done";
|
|
61
61
|
stepId?: number;
|
|
62
62
|
name: string;
|
|
63
63
|
args: Record<string, unknown>;
|
|
64
|
+
mcpServer?: string;
|
|
64
65
|
output?: string;
|
|
65
66
|
durationSeconds?: number;
|
|
66
67
|
/** ACP only: the server's native edit diff from `tool_call`
|
|
@@ -68,7 +69,7 @@ export type DriverActivity =
|
|
|
68
69
|
* sets it; the provider renders it without any git subprocess. */
|
|
69
70
|
diff?: { path: string; oldText?: string; newText: string };
|
|
70
71
|
}
|
|
71
|
-
| { type: "tool_error"; stepId?: number; name: string; message: string }
|
|
72
|
+
| { type: "tool_error"; stepId?: number; name: string; message: string; mcpServer?: string }
|
|
72
73
|
| { type: "usage"; usage: AgyUsage }
|
|
73
74
|
/** Synthetic: injected by the provider when the MCP bridge receives a call
|
|
74
75
|
* (G9). Parks the turn: output is expected to stall while pi executes the
|
package/src/driver.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { randomUUID } from "node:crypto";
|
|
|
19
19
|
import { parseAgyLine } from "./stream-events.js";
|
|
20
20
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
21
21
|
import { gateHooksStaged } from "./approval-hook.js";
|
|
22
|
+
import { redactText } from "./redact.js";
|
|
22
23
|
import type {
|
|
23
24
|
AgyUsage,
|
|
24
25
|
DriverActivity,
|
|
@@ -403,7 +404,9 @@ export class StreamDriver implements TurnDriver {
|
|
|
403
404
|
} else {
|
|
404
405
|
this.#failTurn(
|
|
405
406
|
turn,
|
|
406
|
-
|
|
407
|
+
// agy stderr can carry auth material; the tail becomes the
|
|
408
|
+
// user-facing error and a daily-log value.
|
|
409
|
+
redactText(this.#stderrTail.trim()) || `agy exited with status ${code ?? "signal"}`,
|
|
407
410
|
);
|
|
408
411
|
}
|
|
409
412
|
}
|
package/src/provider.ts
CHANGED
|
@@ -314,6 +314,16 @@ export interface BlockState {
|
|
|
314
314
|
started: boolean;
|
|
315
315
|
}
|
|
316
316
|
|
|
317
|
+
export interface NativeDisplayEvent {
|
|
318
|
+
name: string;
|
|
319
|
+
mcpServer?: string;
|
|
320
|
+
status: "started" | "completed" | "failed";
|
|
321
|
+
path?: string;
|
|
322
|
+
command?: string;
|
|
323
|
+
output?: string;
|
|
324
|
+
diff?: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
317
327
|
export interface StreamSimpleDeps {
|
|
318
328
|
entries: AgyModelEntry[];
|
|
319
329
|
store: SessionStore;
|
|
@@ -336,6 +346,9 @@ export interface StreamSimpleDeps {
|
|
|
336
346
|
* read decides (tests); production wiring always passes it so a
|
|
337
347
|
* mid-session config flip cannot move one side of a parked turn. */
|
|
338
348
|
engine?: "stream-json" | "acp";
|
|
349
|
+
/** Output-only ACP tool events; persisted as TUI entries, never sent to
|
|
350
|
+
* Antigravity or dispatched through Pi's executable tools. */
|
|
351
|
+
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
339
352
|
/** Daily file log sink (src/daily-log.ts). Records pre-dispatch turn
|
|
340
353
|
* errors that never create a driver turn (and so never reach onTurnEnd). */
|
|
341
354
|
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
@@ -902,6 +915,7 @@ export interface DriverDeps {
|
|
|
902
915
|
roundTrips: ToolRoundTrips;
|
|
903
916
|
replay?: WrapperReplay;
|
|
904
917
|
nativeActive?: (name: string) => boolean;
|
|
918
|
+
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
905
919
|
/** Active engine (config), for engine-scoped session keys. */
|
|
906
920
|
engine: "stream-json" | "acp";
|
|
907
921
|
/** Daily file log sink for pre-dispatch errors (see StreamSimpleDeps). */
|
|
@@ -915,6 +929,7 @@ export interface ActivityFeatures {
|
|
|
915
929
|
nativeActive?: (name: string) => boolean;
|
|
916
930
|
roundTrips?: ToolRoundTrips;
|
|
917
931
|
engine?: "stream-json" | "acp";
|
|
932
|
+
onNativeEvent?: (event: NativeDisplayEvent) => void;
|
|
918
933
|
}
|
|
919
934
|
|
|
920
935
|
/** Process-wide counter: round-trip ids must never repeat across turns in
|
|
@@ -976,7 +991,13 @@ export function consumeActivity(
|
|
|
976
991
|
toPiUsage(activity.usage, partial.usage);
|
|
977
992
|
return "continue";
|
|
978
993
|
case "tool_start":
|
|
979
|
-
//
|
|
994
|
+
// Transient status while Antigravity executes; no Pi tool call or
|
|
995
|
+
// model-facing result is produced. Persist a card only on completion.
|
|
996
|
+
if (feats.engine === "acp" && feats.onNativeEvent) {
|
|
997
|
+
try {
|
|
998
|
+
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "started" });
|
|
999
|
+
} catch { /* UI failure must not fail the generation. */ }
|
|
1000
|
+
}
|
|
980
1001
|
return "continue";
|
|
981
1002
|
/** File-path argument of an ACP edit tool (observed: `file_path`); other
|
|
982
1003
|
* tool kinds (execute, read) don't carry one. Returns undefined for
|
|
@@ -998,27 +1019,33 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
998
1019
|
// output the stream-json engine produces, one readFileSync.
|
|
999
1020
|
if (feats.engine === "acp") {
|
|
1000
1021
|
const d = activity.diff;
|
|
1001
|
-
if (d) {
|
|
1002
|
-
appendThinking(stream, blocks, `[agy edit: ${path.basename(d.path)}]\n`);
|
|
1003
|
-
const diffText = formatInlineDiff(d.oldText ?? "", d.newText);
|
|
1004
|
-
if (diffText) appendThinking(stream, blocks, `${diffText}\n`);
|
|
1005
|
-
return "continue";
|
|
1006
|
-
}
|
|
1007
1022
|
const editFile = ACP_EDIT_TOOLS.has(activity.name) ? acpEditFileArg(activity.args) : undefined;
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1023
|
+
const file = d?.path ?? (editFile ? (path.isAbsolute(editFile) ? editFile : path.resolve(cwd, editFile)) : undefined);
|
|
1024
|
+
let diffText = d ? formatInlineDiff(d.oldText ?? "", d.newText) : undefined;
|
|
1025
|
+
if (!d && file) {
|
|
1011
1026
|
let disk = "";
|
|
1012
1027
|
try {
|
|
1013
|
-
disk = fs.readFileSync(
|
|
1028
|
+
disk = fs.readFileSync(file, "utf8");
|
|
1014
1029
|
} catch {
|
|
1015
1030
|
/* deleted or unreadable: diffEdit degrades to a summary */
|
|
1016
1031
|
}
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1032
|
+
diffText = diffCtx.diffEdit(file, disk).text;
|
|
1033
|
+
}
|
|
1034
|
+
const command = ["CommandLine", "command_line", "command"].map((k) => activity.args[k]).find((v): v is string => typeof v === "string" && v.length > 0);
|
|
1035
|
+
let displayed = false;
|
|
1036
|
+
try {
|
|
1037
|
+
if (feats.onNativeEvent) {
|
|
1038
|
+
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "completed", path: file, command, output: activity.output, diff: diffText });
|
|
1039
|
+
displayed = true;
|
|
1040
|
+
}
|
|
1041
|
+
} catch { /* A stale renderer must never fail the model turn. */ }
|
|
1042
|
+
if (displayed) return "continue";
|
|
1043
|
+
if (file) {
|
|
1044
|
+
appendThinking(stream, blocks, `[agy edit: ${path.basename(file)}]\n`);
|
|
1045
|
+
if (diffText) appendThinking(stream, blocks, `${diffText}\n`);
|
|
1046
|
+
} else {
|
|
1047
|
+
appendThinking(stream, blocks, `[agy tool: ${activity.name}]\n`);
|
|
1020
1048
|
}
|
|
1021
|
-
appendThinking(stream, blocks, `[agy tool: ${activity.name}]\n`);
|
|
1022
1049
|
return "continue";
|
|
1023
1050
|
}
|
|
1024
1051
|
// G8 (stream-json): agy file edits surface a git-sourced diff in a
|
|
@@ -1066,6 +1093,12 @@ function acpEditFileArg(args: Record<string, unknown>): string | undefined {
|
|
|
1066
1093
|
}
|
|
1067
1094
|
}
|
|
1068
1095
|
case "tool_error":
|
|
1096
|
+
if (feats.engine === "acp" && feats.onNativeEvent) {
|
|
1097
|
+
try {
|
|
1098
|
+
feats.onNativeEvent({ name: activity.name, mcpServer: activity.mcpServer, status: "failed", output: activity.message });
|
|
1099
|
+
return "continue";
|
|
1100
|
+
} catch { /* Fall back to a thinking label. */ }
|
|
1101
|
+
}
|
|
1069
1102
|
appendThinking(stream, blocks, `[agy tool: ${activity.name} failed: ${activity.message}]\n`);
|
|
1070
1103
|
return "continue";
|
|
1071
1104
|
case "bridge_call": {
|
|
@@ -1227,6 +1260,7 @@ async function runTurnDriver(
|
|
|
1227
1260
|
nativeActive: deps.nativeActive,
|
|
1228
1261
|
roundTrips: deps.roundTrips,
|
|
1229
1262
|
engine: deps.engine,
|
|
1263
|
+
onNativeEvent: deps.onNativeEvent,
|
|
1230
1264
|
};
|
|
1231
1265
|
|
|
1232
1266
|
for (;;) {
|
|
@@ -1293,6 +1327,7 @@ export function createStreamSimple(
|
|
|
1293
1327
|
roundTrips,
|
|
1294
1328
|
replay: deps.replay,
|
|
1295
1329
|
nativeActive: deps.nativeActive,
|
|
1330
|
+
onNativeEvent: deps.onNativeEvent,
|
|
1296
1331
|
// Record the engine of the driver that will ACTUALLY run: if the
|
|
1297
1332
|
// ACP driver is absent, the config switch falls back to stream,
|
|
1298
1333
|
// and keying the session as @acp would store a stream
|