@estebanforge/pi-antigravity-bridge 1.6.3 → 1.7.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.
@@ -25,6 +25,7 @@ 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";
28
29
  import { JsonRpcResponseError, JsonRpcSession } from "./jsonrpc.js";
29
30
 
30
31
  export interface AcpMcpServer {
@@ -82,6 +83,10 @@ export class AcpConnection {
82
83
  #rpc: JsonRpcSession | undefined;
83
84
  #stderrTail = "";
84
85
  #usageSeen = false;
86
+ /** Set ONLY by a parsed PromptResponse.usage; this gates estimate
87
+ * suppression. #usageSeen stays diagnostic: any usage-shaped frame trips
88
+ * it, and the connection outlives turns. */
89
+ #exactUsageSeen = false;
85
90
  #exited = false;
86
91
  #killed = false;
87
92
  #suppressUpdates = false;
@@ -115,6 +120,11 @@ export class AcpConnection {
115
120
  return this.#usageSeen;
116
121
  }
117
122
 
123
+ /** True once the server sent real per-turn usage (PromptResponse.usage). */
124
+ get exactUsageSeen(): boolean {
125
+ return this.#exactUsageSeen;
126
+ }
127
+
118
128
  /** While true, session/update notifications are dropped: they are the
119
129
  * full-text history replay that precedes a session/load response, never
120
130
  * live generation (run 6). */
@@ -237,7 +247,7 @@ export class AcpConnection {
237
247
  text: string,
238
248
  images: Array<{ data: string; mimeType: string }> = [],
239
249
  contextBlock?: { uri: string; text: string },
240
- ): Promise<{ stopReason: string }> {
250
+ ): Promise<{ stopReason: string; usage?: AgyUsage }> {
241
251
  // Block order: images, then the embedded-context resource (pi-side
242
252
  // digest), then the text question last (it refers to everything before
243
253
  // it). Shapes per the ACP v1 content-block schema; embeddedContext is
@@ -264,7 +274,15 @@ export class AcpConnection {
264
274
  if (typeof stopReason !== "string") {
265
275
  throw new Error("ACP session/prompt returned no stopReason");
266
276
  }
267
- return { stopReason };
277
+ // Optional, unstable ACP PromptResponse.usage: absent on 1.1.1 but
278
+ // parsed for the day the server forwards its SDK usage_metadata. Never
279
+ // infer exact counts from a context-window usage_update ({used,size}).
280
+ const usage = parseAcpTurnUsage(result?.usage);
281
+ if (usage) {
282
+ this.#usageSeen = true;
283
+ this.#exactUsageSeen = true;
284
+ }
285
+ return { stopReason, ...(usage ? { usage } : {}) };
268
286
  }
269
287
 
270
288
  /** Probe-and-cancel. Returns supported:false when the server does not
@@ -430,6 +448,28 @@ export class AcpConnection {
430
448
  }
431
449
  }
432
450
 
451
+ /** ACP's optional (unstable) per-turn PromptResponse.usage, not the
452
+ * session/usage_update context-window occupancy notification. */
453
+ export function parseAcpTurnUsage(value: unknown): AgyUsage | undefined {
454
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
455
+ const u = value as Record<string, unknown>;
456
+ const count = (v: unknown): number | undefined =>
457
+ typeof v === "number" && Number.isSafeInteger(v) && v >= 0 ? v : undefined;
458
+ const input = count(u.inputTokens);
459
+ const output = count(u.outputTokens);
460
+ const total = count(u.totalTokens);
461
+ const thought = count(u.thoughtTokens);
462
+ const cacheRead = count(u.cachedReadTokens);
463
+ if (input === undefined || output === undefined || total === undefined) return undefined;
464
+ return {
465
+ input_tokens: input,
466
+ output_tokens: output,
467
+ total_tokens: total,
468
+ ...(thought === undefined ? {} : { thinking_tokens: thought }),
469
+ ...(cacheRead === undefined ? {} : { cache_read_tokens: cacheRead }),
470
+ };
471
+ }
472
+
433
473
  export function translateError(method: string, err: unknown): Error {
434
474
  if (err instanceof AcpAuthError) return err;
435
475
  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 =
@@ -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,
@@ -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/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