@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.
@@ -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
- return this.#stderrTail;
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
- this.#finish(this.#stderrTail.trim());
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
- return { stopReason };
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.lastNativeTool = { ...entry };
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.lastNativeTool) {
399
- // Unknown id with a recent native tool: adopt it (supersede).
400
- entry = { ...turn.lastNativeTool };
401
- turn.toolCalls.set(mapped.toolCallId, entry);
402
- turn.lastNativeTool = undefined;
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
- // Gate B latch: any server frame with usage/token keys means real
724
- // usage exists upstream; estimates must never shadow it.
725
- if (this.#conn?.usageSeen) return undefined;
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
- return { kind: "tool_error", toolCallId: id, message: raw ?? "tool failed" };
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
- const note = details.stderr.trim()
746
- ? `agy exited with status ${outcome.exitCode}: ${details.stderr.trim()}`
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 and long strings are truncated before they reach disk.
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 values and cap runaway strings before disk. */
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
- return { name: value.name, message: value.message, stack: scrub(value.stack, depth) };
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
- return value.length > MAX_STRING ? value.slice(0, MAX_STRING) + "…(truncated)" : value;
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)";
@@ -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
- this.#stderrTail.trim() || `agy exited with status ${code ?? "signal"}`,
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
- // Rendering happens on completion (output/diff available).
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
- if (editFile) {
1009
- const absFile = path.isAbsolute(editFile) ? editFile : path.resolve(cwd, editFile);
1010
- appendThinking(stream, blocks, `[agy edit: ${path.basename(absFile)}]\n`);
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(absFile, "utf8");
1028
+ disk = fs.readFileSync(file, "utf8");
1014
1029
  } catch {
1015
1030
  /* deleted or unreadable: diffEdit degrades to a summary */
1016
1031
  }
1017
- const outcome = diffCtx.diffEdit(absFile, disk);
1018
- if (outcome.text) appendThinking(stream, blocks, `${outcome.text}\n`);
1019
- return "continue";
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