@estebanforge/pi-antigravity-bridge 1.7.2 → 1.7.4

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 CHANGED
@@ -2,11 +2,38 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file.
4
4
 
5
+ ## [1.7.4] - 2026-09-25
6
+
7
+ ### Added
8
+
9
+ - **`/agy agent <name|off>` — select a custom agy agent for stream-json turns.** The agent list comes from a one-shot `agy agent` spawn through the same bounded raw-spawn helper as the model catalog (8 s watchdog, 1 MiB cap, fail-closed to an empty list), parsed line-oriented: banner and help noise skipped, first whitespace token per line taken, filesystem-safe names validated, duplicates dropped. The choice persists in config with an `AGY_AGENT` env override, rides `--agent` on stream-json spawns, and recycles the process when it changes. ACP has no protocol slot for agent selection, so the command refuses it there with a stream-json-only message instead of silently ignoring the knob.
10
+ - **`/agy subagents` — live subagent roster.** agy's subagent steps were invisible from pi: spawn, message, and manage activity now folds from the engine-agnostic activity stream into an in-memory roster (spawn tools open entries, `send_message` counts messages, `manage_subagents` kills mark entries killed, done/error closes them). Entries key by tool-call id on ACP and step id on stream-json, with name, status, duration, message count, detail, and last error rendered on demand. Works on both engines; deliberately never persisted — agy's conversation database is the durable record, and telemetry must never be able to fail a turn.
11
+ - **`/agy quota` — subscription quota view.** One-shot `agy --print /usage` fetch rendered as bars per window, 5h ranked before weekly, with reset times. The fetch carries its own 30 s budget (the live call answers in ~10 s) and the wrapper watchdog is padded 5 s past agy's own `--print-timeout` so agy's clean self-timeout always wins the race over a SIGKILL. Quota reflects the CLI's Google account identity, so the reading is equally true under both engines; a CLI-less ACP-only install gets a fail-closed pointer to `/agy doctor` rather than a raw spawn error.
12
+ - **`/agy artifacts [open <n|name>]` — browse the conversation brain dir.** Non-recursive scan of the conversation root, `.tempmediaStorage`, and `.user_uploaded`, deduped by canonical path, newest first, with kind/size/last-write shown. `open` resolves by index, exact name, or unique substring and launches the OS opener detached. Path containment canonicalizes the scan root once and re-realpaths every entry before opening, so symlinked ancestors (macOS `/var` → `/private/var`, symlinked homes) cannot silently empty the listing — the first containment gate compared raw paths and did exactly that on such machines.
13
+ - **Parked-answer recovery on stream-json deadline guards.** When the stall or total-timeout guard fires, the driver now kills the child first (bumping the generation so a racing exit handler cannot settle the turn ERROR mid-probe), then probes the conversation's brain transcript: if the NEWEST step written during the turn is a clean, tool-free DONE MODEL response, the turn settles OK with the withheld answer instead of discarding finished work. The newest-step rule is the load-bearing part — a corpus sweep found 96.6% of PLANNER_RESPONSE transcript steps carry tool_calls; they are intermediate plan-and-act steps whose content is status text, not answers, and an older scan that accepted any matching step could settle a turn with "Running test..." as the answer. ACP reports turn completion itself, so the probe is stream-json-only by construction.
14
+ - **`/agy tasks [tail <id>]` — read-only background-task dashboard.** Scans the conversation's task logs (newest first, 128 cap, missing dirs degrade to an empty list) and marks a task ACTIVE while any process holds its log open, via one batched `lsof -nP -Fpn` call — a reader counts too, so the state is a watch, not a fact. Watch-only by design: modern agy pipes task output through itself, making log ownership unprovable, so nothing is ever stopped from here and a missing lsof shows `liveness?` instead of a guess. `tail <id>` resolves against the scanned list and prints the last 40 lines.
15
+ - **Agy CLI version awareness.** A warn-once session toast fires when the CLI reports older than the 1.1.22 stream-json floor; development builds pass, unparseable output classifies as invalid, and a failed spawn maps to a distinct unavailable status rather than a fake verdict. `/agy doctor` gained a CLI version line, labeled "(optional here; AskAntigravity delegations use the CLI)" on ACP-only installs because the CLI matters on both engines. Warn-only by design: a hard spawn gate would block turns on a slow `--version` in odd environments, and an old agy already fails turns with parse noise the warning explains. ACP server versions stay display-only — `agy_acp_server_1.1.1` and CLI `1.2.10` are unrelated streams, and no floor is enforced against the server until its versioning is understood.
16
+
17
+ ### Removed
18
+
19
+ - **`/agy status`.** It was never a real subcommand — the old argument guard let the literal name fall through to the settings picker while the printed summary read as a broken second status surface. `/agy doctor` is now the single status surface: bridge state, driver counters, CLI and ACP versions, lifecycle, plus the settings rows folded in (nothing duplicated — rows doctor already rendered stay out of the shared block). Typing the retired name gets a one-line pointer to doctor; bare `/agy` keeps its settings picker.
20
+
21
+ ### Fixed
22
+
23
+ - **The agy models discovery spawn can no longer balloon memory.** It accumulated child stdout with no cap, so a runaway agy could exhaust the whole pi session. Output is now capped at 1 MiB (decoded-length proxy, documented); past the cap the child is SIGKILLed and the result fails closed to empty, which engages the baked-in fallback catalog — and a capped empty result never overwrites a good on-disk cache.
24
+ - **Plan mode never receives `--dangerously-skip-permissions`.** agy's plan-mode no-write gate IS a permission request ("File creation requires plan approval"), and the skip flag auto-approves every permission request — so passing the flag alongside `--mode plan` silently turned review-only into full write access inside the `--add-dir` grant. Probed both directions on 2026-09-25: with the flag a plan run wrote files; without it, file-write and run_command attempts end exit 0 in ~20-30 s with a "confirm plan" message — fail-visible, never the upstream #318 prompt-hang. Both spawn surfaces (the AskAntigravity one-shot and the stream-json driver) now withhold the flag in plan mode through one shared helper; the recycle profile stores the effective value and compares effective-vs-effective so plan sessions keep process reuse. README and ENGINES.md now state the enforcement instead of the aspirational claim.
25
+
26
+ ## [1.7.3] - 2026-09-24
27
+
28
+ ### Fixed
29
+
30
+ - **Collapsed ACP tool cards no longer repeat the tool name.** The `agy-native-event` renderer filled the muted detail slot with the tool name itself whenever an event carried neither a path nor a recognized command argument, so `ls` and `exec_command` collapsed to "✓ ls ls" / "✓ exec_command exec_command". The detail now prefers the path basename, then the command's first line, then agy's own display text (the first line of the completed call's output, e.g. "Run make test in wicket-cli-atlas"), and stays empty when none exists; the name always stands alone. ACP tool_call frames carry no per-tool reasoning, so agy's display text is the closest honest signal.
31
+
5
32
  ## [1.7.2] - 2026-09-24
6
33
 
7
34
  ### Added
8
35
 
9
- - **ACP permission requests park on a human decision.** `session/request_permission` was answered silently in-process (skip turns auto-allowed, everything else auto-denied), making ACP turns all-or-nothing with no per-tool human say. Interactive turns now hold the JSON-RPC request open and surface the server's options as a pi select dialog under the dialog lock, with the turn budget paused while the human decides: 480 s park, deny on timeout/esc/throw/connection death, and a cancel instead of an answer when the server offers no reject option (a deny must never degrade into picking an allow). `skipPermissions` keeps the synchronous auto-allow; `approvals.mode allow` maps to it, deny and headless stay fail-closed. `allow_always`/`reject_always` answers are remembered per connection for identical later requests, and first settle wins: a late dialog answer after a timeout is forgotten. Nine new tests pin the behavior, including a scenario proving a late answer writes nothing. 1217 tests, tsc clean.
36
+ - **ACP permission requests park on a human decision.** `session/request_permission` was answered silently in-process (skip turns auto-allowed, everything else auto-denied), making ACP turns all-or-nothing with no per-tool human say. Interactive turns now hold the JSON-RPC request open and surface the server's options as a pi select dialog under the dialog lock, with the turn budget paused while the human decides: 480 s park, deny on timeout/esc/throw/connection death, and a cancel instead of an answer when the server offers no reject option (a deny must never degrade into picking an allow). `skipPermissions` keeps the synchronous auto-allow; `approvals.mode allow` maps to it, deny and headless stay fail-closed. `allow_always`/`reject_always` answers are remembered per connection for identical later requests, and first settle wins: a late dialog answer after a timeout is forgotten. Nine new tests pin the behavior, including a scenario proving a late answer writes nothing. tsc clean.
10
37
 
11
38
  ### Changed
12
39
 
package/README.md CHANGED
@@ -40,7 +40,7 @@ While agy is the active model it normally cannot see pi's universe of extensions
40
40
 
41
41
  The bridge starts a localhost MCP server inside pi's process. The exposed catalog is computed live from pi's active tools on every `tools/list` and re-checked immediately before every `tools/call`: the `bridgeTools` mode, the session's `/agy tools` hidden set, and the bridge's own internal tools (`AskAntigravity`, the display-only `antigravity` wrapper, `activate_skill`, `bridge_poll_result`, the `agy_web_search`/`agy_read_url` wrappers) are filtered each time, and a call for anything outside the fresh set is rejected - a cached MCP catalog is not authorization. A call that passes routes into pi's own tool loop via the round-trip described below. Discovery has two layers. The provider's agy gets a per-invocation config: the bridge writes `.agents/mcp_config.json` into a bridge-controlled dir (`~/.pi/agent/antigravity-bridge/agy-mcp-<pid>/`) and the driver passes that dir as an extra `--add-dir` when it spawns agy. A per-pid entry (`pi-bridge-<pid>`) is also registered in the user's global agy config (`~/.gemini/config/mcp_config.json`; foreign servers preserved, stale entries swept at start), so agy builds that read only the global config still find the bridge.
42
42
 
43
- **No patch required.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call. This is the same mechanism tianzuo/pi-antigravity uses; upstream pi APIs only.
43
+ **Upstream pi APIs only.** Bridge calls park in the provider's round-trip store; the provider ends the pi assistant message with a `toolUse` stop reason for the real pi tool, pi executes it in its own loop (native cards, permissions, hooks), and the toolResult completes the parked MCP response on the next stream call.
44
44
 
45
45
  **Long calls don't die.** agy's MCP client abandons a `tools/call` request at a flat ~180s, which used to kill any pi tool that ran longer (a long peer review, a build, a commit preview waiting for you). A call still running after ~20 seconds now settles its HTTP request with a `STILL RUNNING` answer carrying a `callId` while pi keeps executing; agy fetches the result through the bridge-local `bridge_poll_result` tool and polls until it lands. Escalated calls get their own 30-minute budget, so human-gated tools can take as long as the human takes. Fast calls stay fully synchronous and never see any of this. If a park does fail (abort, timeout, recycle), the late result is re-routed to agy as a follow-up prompt in the same conversation instead of being lost.
46
46
 
@@ -73,13 +73,13 @@ cards for read-only steps.
73
73
 
74
74
  ## Approval gate (agy native tools)
75
75
 
76
- agy runs its own agent loop with native tools (`run_command`, `create_file`, `edit_file`, ...), which pi's permission extensions never saw. The optional approval gate routes those calls through a pi-side approval: a staged PreToolUse hook parks the call, the provider surfaces it as a shadow `bash`/`write`/`edit` toolUse, and your permission extension (or the built-in ask/allow/deny fallback) decides before agy executes it. Off by default (`approvals.gateMode: auto` enables it only when a pi permission extension is detected); denials fail closed; read-only agy tools stay ungated.
76
+ agy runs its own agent loop with native tools (`run_command`, `create_file`, `edit_file`, ...), which pi's permission extensions never saw. The optional approval gate routes those calls through a pi-side approval: a staged PreToolUse hook parks the call, the provider surfaces it as a shadow `bash`/`write`/`edit` toolUse, and your permission extension (or the built-in ask/allow/deny fallback) decides before agy executes it. Off by default (`approvals.gateMode: auto` enables it only when a pi permission extension is detected); denials fail closed; read-only agy tools stay ungated. On ACP turns the server has its own per-tool permission ask: when one fires (`skipPermissions` off), the request parks on a real pi dialog instead of a silent deny, and allow-always choices are remembered per connection.
77
77
 
78
78
  Full mechanics, configuration, and a sample gate extension: [docs/APPROVAL-GATE.md](docs/APPROVAL-GATE.md).
79
79
 
80
80
  ## Install
81
81
 
82
- > **No patch required.** The bridge runs on pi's public APIs only; the extension never edits your pi install. If an older version of this extension patched your pi (adding `pi.invokeTool()`), the leftover is inert and a pi update removes it. The extension detects it once and offers `/agy patch-cleanup` to restore the original files from the backup immediately.
82
+ > The bridge runs on pi's public APIs; the extension never edits your pi install.
83
83
 
84
84
  Install with pi's package manager:
85
85
 
@@ -132,9 +132,8 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
132
132
  `/agy` configures the provider at runtime. Settings persist to `~/.pi/agent/antigravity-bridge/config.json` and take effect on the next turn.
133
133
 
134
134
  ```
135
- /agy status, or open the full settings picker (TUI)
136
- /agy status print current settings + session counts
137
- /agy doctor bridge state, driver counters, bridge port, last lifecycle events, log dir
135
+ /agy status summary, or open the full settings picker (TUI)
136
+ /agy doctor single status surface: bridge state, driver counters, versions, acp state, lifecycle, settings, log dir
138
137
  /agy auth run the antigravity-acp sign-in now (engine acp): opens the Google login in your browser, shows the URL when no browser opens
139
138
  /agy mode plan review-only: agy plans but writes nothing
140
139
  /agy mode accept-edits agy applies edits directly (default)
@@ -150,7 +149,6 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
150
149
  /agy acp-bin <path|auto> point the ACP engine at a specific server binary (auto = setup installs, or AGY_ACP_BIN; applies on the next ACP turn)
151
150
  /agy engine acp|stream-json switch the turn engine (restart to apply; default stream-json; acp is beta and runs self-service setup: binary install + auth bootstrap). No arguments opens the engine picker modal (TUI)
152
151
  /agy auth-manual manual ACP credential setup (fallback; auto-setup normally covers this; default login = your Antigravity subscription, same account as the agy CLI)
153
- /agy patch-cleanup restore the original pi files if an older version patched them
154
152
  /agy clear drop all session bindings (force fresh conversations)
155
153
  ```
156
154
 
@@ -158,9 +156,9 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
158
156
 
159
157
  pi itself has no built-in approval gate. Unlike codex, claude, or agy running interactively, pi does not prompt you to confirm each tool action before it runs. That is the host environment this extension lives in.
160
158
 
161
- Because agy runs non-interactively under this provider (nothing can answer a `y/n` prompt), this extension passes `--dangerously-skip-permissions` by default. It is technically necessary: `accept-edits` auto-approves file edits but not shell commands, so a `run_command` would otherwise hang forever waiting for a prompt nothing can answer (upstream [google-antigravity/antigravity-cli#318](https://github.com/google-antigravity/antigravity-cli/issues/318)). The net effect is that agy executes commands the same way pi already executes your other tools: without per-action review.
159
+ Because agy runs non-interactively under this provider (nothing can answer a `y/n` prompt), this extension passes `--dangerously-skip-permissions` by default on `accept-edits` runs. It is technically necessary there: `accept-edits` auto-approves file edits but not shell commands, so a `run_command` would otherwise hang forever waiting for a prompt nothing can answer (upstream [google-antigravity/antigravity-cli#318](https://github.com/google-antigravity/antigravity-cli/issues/318)). The net effect is that agy executes commands the same way pi already executes your other tools: without per-action review.
162
160
 
163
- If you want agy to execute nothing, use `/agy mode plan`. Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
161
+ Plan mode never receives the flag: the flag auto-approves every permission request, including plan mode's own approval gate, which would silently turn review-only into full write access inside the `--add-dir` grant (probed 2026-09-25: with the flag a plan run wrote files; without it, file and command attempts end the run quickly with a "confirm plan" message). So `/agy mode plan` (and `AskAntigravity` with `mode: "plan"`) genuinely executes nothing. Do not combine `--sandbox` with skip-permissions ([#36](https://github.com/google-antigravity/antigravity-cli/issues/36)).
164
162
 
165
163
  For per-action review of agy's mutating native tools (`run_command`, `create_file`, `edit_file`, ...), see the [Approval gate](docs/APPROVAL-GATE.md): with it on, the call must pass a pi-side approval (your permission extension, or the built-in ask/allow/deny fallback) before agy executes it.
166
164
 
@@ -68,6 +68,16 @@ import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
68
68
  import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
69
69
  import { CONFIG_PATH, loadConfig, logsDir, MAX_TURN_CAP_MIN, parseCapMinutes, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
70
70
  import { agyMissingMessage, isAgyInstalled, savedEngineMessage, showEnginePicker, shouldOfferEnginePicker } from "../src/engine-picker.js";
71
+ import { checkAgyCliVersion, describeAgyVersionCheck, MIN_AGY_VERSION } from "../src/agy-version.js";
72
+ import { isValidAgyAgentName, listAgyAgents } from "../src/agents.js";
73
+ import { formatSubagentRoster, SubagentRoster } from "../src/subagent-roster.js";
74
+ import { spawn } from "node:child_process";
75
+ import { fetchAgyQuota, formatAgyQuotaReport } from "../src/usage.js";
76
+ import { artifactOpenCommand, listAgyArtifacts } from "../src/artifacts.js";
77
+ import { showArtifactsBrowser } from "../src/artifacts-ui.js";
78
+ import { listAgyTasks, tailAgyTaskLog } from "../src/tasks.js";
79
+ import { showTasksBrowser } from "../src/tasks-ui.js";
80
+ import { agyConversationDir } from "../src/agy-paths.js";
71
81
  import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
72
82
  import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
73
83
  import { registerWebTools } from "../src/web-tools.js";
@@ -197,8 +207,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
197
207
  // ACP self-heal runs once per process (session_start re-fires on /reload;
198
208
  // a ready setup is two file stats, so re-running is harmless anyway).
199
209
  let acpSelfHealRan = false;
200
- // Warn-once-per-process flag for the missing-agy-CLI toast (stream-json).
210
+ // Warn-once-per-process flags for the missing-agy-CLI toast and the
211
+ // too-old-agy version gate (both stream-json).
201
212
  let agyMissingWarned = false;
213
+ let agyVersionWarned = false;
202
214
  // OAuth URL capture: the server hands the login URL only to the
203
215
  // browser-open call (nothing on stdio), so a BROWSER wrapper records it
204
216
  // and the driver logs it as "auth-url". Local users keep the automatic
@@ -351,15 +363,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
351
363
  ),
352
364
  );
353
365
  const replay = new WrapperReplay();
366
+ // Subagent roster: folded from every driver activity (both engines) via
367
+ // the provider; /agy subagents renders it. In-memory, process-lifetime.
368
+ const roster = new SubagentRoster();
354
369
  // ACP tool activity is display-only: durable Pi entries with native-looking
355
370
  // titles and optional diffs. Unlike a synthetic Pi toolCall this does not
356
371
  // re-execute edits, park the turn, or add tool results to model context.
357
372
  if (engine === "acp") {
358
373
  pi.registerEntryRenderer<NativeDisplayEvent>("agy-native-event", (entry, { expanded }, theme) => {
359
374
  const event = entry.data;
360
- const title = (event?.path ? path.basename(event.path) : event?.command?.split(/\r?\n/, 1)[0] ?? event?.name ?? "tool").slice(0, 160);
375
+ // Collapsed detail: most-specific signal wins (path, command, agy's own
376
+ // display text). The name is NOT a fallback here - it rendered "ls ls"
377
+ // duplication whenever the event carried neither path nor command.
378
+ const firstLine = (value: string | undefined): string | undefined => value?.split(/\r?\n/, 1)[0].trim() || undefined;
379
+ const detail = event?.path ? path.basename(event.path) : firstLine(event?.command) ?? firstLine(event?.output);
361
380
  const status = event?.status === "failed" ? theme.fg("error", "✗") : theme.fg("success", "✓");
362
- const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")} ${theme.fg("muted", title)}`];
381
+ const lines = [`${status} ${theme.fg("toolTitle", event?.name ?? "Antigravity")}${detail ? ` ${theme.fg("muted", detail.slice(0, 160))}` : ""}`];
363
382
  if (expanded) {
364
383
  if (event?.path) lines.push(theme.fg("dim", event.path));
365
384
  if (event?.diff) {
@@ -456,6 +475,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
456
475
  replay,
457
476
  nativeActive,
458
477
  onNativeEvent,
478
+ roster,
459
479
  engine,
460
480
  log: fileLog.log.bind(fileLog),
461
481
  });
@@ -491,6 +511,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
491
511
  hiddenBridgeTools,
492
512
  acpLog,
493
513
  fileLog,
514
+ roster,
494
515
  authCapture: authCapture ?? null,
495
516
  runAcpPickSetup,
496
517
  });
@@ -637,6 +658,20 @@ export default async function (pi: ExtensionAPI): Promise<void> {
637
658
  if (ctx.hasUI) ctx.ui.notify(msg, "warning");
638
659
  else console.error(`[antigravity-bridge] ${msg}`);
639
660
  }
661
+ // Version gate (stream-json): an agy older than MIN_AGY_VERSION fails
662
+ // turns with raw stream errors that look like bridge bugs. Warn once
663
+ // with the fix in the message; doctor carries the full verdict.
664
+ // Development builds and unreadable output stay silent here (an agy
665
+ // from source works; doctor shows what we could not parse).
666
+ if (engine === "stream-json" && !agyVersionWarned && isAgyInstalled(binary)) {
667
+ void checkAgyCliVersion(binary).then((check) => {
668
+ if (agyVersionWarned || check.status !== "unsupported") return;
669
+ agyVersionWarned = true;
670
+ const msg = `agy ${check.version} is too old; install ${MIN_AGY_VERSION} or newer. Stream-json turns may fail in confusing ways until then (/agy doctor inspects).`;
671
+ if (ctx.hasUI) ctx.ui.notify(msg, "warning");
672
+ else console.error(`[antigravity-bridge] ${msg}`);
673
+ });
674
+ }
640
675
  // Legacy cleanup: users who ran the old consent-gated patcher still
641
676
  // carry pi.invokeTool in their installed pi. Inert, but tell them once
642
677
  // and offer /agy patch-cleanup. Never auto-edits the install.
@@ -1006,6 +1041,8 @@ interface AgyCommandCtx {
1006
1041
  acpLog: (msg: string, data?: unknown) => void;
1007
1042
  /** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
1008
1043
  fileLog: DailyLogger;
1044
+ /** Live subagent roster (folded in the provider); /agy subagents. */
1045
+ roster: SubagentRoster;
1009
1046
  /** Wizard-pick follow-through (download now + chained sign-in); reused
1010
1047
  * by /agy engine's no-args modal so both entry points behave alike. */
1011
1048
  runAcpPickSetup: (cmdCtx: { ui: ExtensionUIContext }) => Promise<void>;
@@ -1027,30 +1064,39 @@ interface PendingConfig {
1027
1064
  systemPrompt?: boolean;
1028
1065
  }
1029
1066
 
1030
- function statusText(ctx: AgyCommandCtx): string {
1067
+ function settingsRows(ctx: AgyCommandCtx): string[] {
1031
1068
  const config = loadConfig();
1032
- const source = ctx.usingFallback ? "fallback (agy models failed)" : "discovered";
1033
1069
  const perm = config.skipPermissions ? "auto-approved (DANGEROUS)" : "prompt (hangs in -p)";
1034
1070
  // padEnd keyed to the longest label ("AskAntigravity thinking:") so the
1035
- // value column stays aligned as labels grow.
1071
+ // value column stays aligned as labels grow. Doctor-only state (engine,
1072
+ // models, sessions, config path) lives in /agy doctor, not here: one
1073
+ // surface per concern — this is the config surface, doctor is health.
1036
1074
  const row = (label: string, value: string) => ` ${label.padEnd(24)} ${value}`;
1037
1075
  return [
1038
- "Antigravity bridge",
1039
- row("engine:", `${config.engine}${config.engine === "acp" ? " (official server, opt-in)" : ""}`),
1040
- row("models:", `${ctx.entries.length} ${source}`),
1041
1076
  row("mode:", config.mode),
1042
1077
  row("permissions:", perm),
1043
1078
  row("AskAntigravity tool:", config.askTool ? "on" : "off"),
1044
1079
  row("Web tools:", config.webTools ? "on" : "off"),
1045
1080
  row("AskAntigravity model:", config.defaultModel),
1046
1081
  row("AskAntigravity thinking:", config.defaultThinking),
1047
- row("sessions:", `${ctx.store.size} bound`),
1048
- row("config:", CONFIG_PATH),
1049
1082
  row("bridge tools:", config.bridgeTools),
1050
1083
  row("digest:", config.digest ? "on" : "off"),
1051
1084
  row("system prompt:", config.systemPrompt ? "on" : "off"),
1085
+ ];
1086
+ }
1087
+
1088
+ function statusText(ctx: AgyCommandCtx): string {
1089
+ const config = loadConfig();
1090
+ const source = ctx.usingFallback ? "fallback (agy models failed)" : "discovered";
1091
+ return [
1092
+ "Antigravity bridge",
1093
+ ` engine: ${config.engine}${config.engine === "acp" ? " (official server, opt-in)" : ""}`,
1094
+ ` models: ${ctx.entries.length} ${source}`,
1095
+ ...settingsRows(ctx),
1096
+ ` sessions: ${ctx.store.size} bound`,
1097
+ ` config: ${CONFIG_PATH}`,
1052
1098
  "",
1053
- "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear",
1099
+ "Subcommands: /agy auth, /agy auth-manual, /agy engine stream-json|acp, /agy mode plan|accept-edits, /agy permissions on|off, /agy ask on|off, /agy model <alias>, /agy thinking low|medium|high, /agy agent <name|off>, /agy subagents, /agy quota, /agy artifacts [open <n|name>], /agy tasks [tail <id>], /agy bridge all|mcp|none, /agy tools [hide|show <name>|reset], /agy web on|off, /agy digest on|off, /agy system-prompt on|off, /agy timeout <1-1440|off>, /agy acp-bin <path|auto>, /agy patch-cleanup, /agy clear, /agy doctor",
1054
1100
  ].join("\n");
1055
1101
  }
1056
1102
 
@@ -1058,7 +1104,7 @@ function statusText(ctx: AgyCommandCtx): string {
1058
1104
  function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1059
1105
  pi.registerCommand("agy", {
1060
1106
  description:
1061
- "Antigravity provider: status, doctor, settings picker, clear sessions. Usage: /agy [status|doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1107
+ "Antigravity provider: doctor (health + settings), settings picker, clear sessions. Usage: /agy [doctor|auth|auth-manual|engine stream-json|acp|mode plan|accept-edits|permissions on|off|ask on|off|model <alias>|thinking low|medium|high|agent <name|off>|subagents|quota|artifacts [open <n|name>]|tasks [tail <id>]|bridge all|mcp|none|tools [hide|show <name>|reset]|web on|off|digest on|off|system-prompt on|off|timeout <1-1440|off>|acp-bin <path|auto>|patch-cleanup|clear]",
1062
1108
  handler: async (args, cmdCtx: ExtensionCommandContext) => {
1063
1109
  const ui = cmdCtx.ui;
1064
1110
  if (ui) activeUi = ui;
@@ -1286,6 +1332,15 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1286
1332
  const engine = ctx.engine;
1287
1333
  const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1288
1334
  const port = ctx.getMcpPort();
1335
+ // The CLI matters on both engines (AskAntigravity shells out to
1336
+ // `agy -p` even when turns ride ACP), but on an ACP session a
1337
+ // missing CLI is a supported shape, so label it optional there
1338
+ // instead of showing a bare failure line.
1339
+ const cliCheck = await checkAgyCliVersion(resolveAgyBinary());
1340
+ const cliSuffix =
1341
+ cliCheck.status === "unavailable" && engine === "acp"
1342
+ ? " (optional here; AskAntigravity delegations use the CLI)"
1343
+ : "";
1289
1344
  const lines = [
1290
1345
  "Antigravity doctor (no tokens spent)",
1291
1346
  ` engine: ${engine}`,
@@ -1294,6 +1349,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1294
1349
  ` driver stats: spawns=${snap.stats.spawns} turns=${snap.stats.turns} reused=${snap.stats.reused} recycles=${snap.stats.recycles}${snap.stats.lastRecycleReason ? ` (last: ${snap.stats.lastRecycleReason})` : ""}`,
1295
1350
  ` sessions: ${ctx.store.size} bound`,
1296
1351
  ` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
1352
+ ` agy cli: ${describeAgyVersionCheck(cliCheck)} (min ${MIN_AGY_VERSION})${cliSuffix}`,
1297
1353
  ` config: ${CONFIG_PATH}`,
1298
1354
  ` logs: ${logsDir()} (attach recent days' files when reporting issues; set AGY_DEBUG=1 to capture details)`,
1299
1355
  ];
@@ -1322,6 +1378,9 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1322
1378
  ` acp auth: ${setup.auth ?? "not configured (auto-setup bootstraps)"}`,
1323
1379
  );
1324
1380
  }
1381
+ // The config surface lives here too, so doctor is the ONE status
1382
+ // surface (former /agy status is gone).
1383
+ lines.push("", "settings:", ...settingsRows(ctx));
1325
1384
  ui?.notify(lines.join("\n"), "info");
1326
1385
  return;
1327
1386
  }
@@ -1338,6 +1397,214 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1338
1397
  }
1339
1398
  return;
1340
1399
  }
1400
+ if (sub === "tasks") {
1401
+ const engine = ctx.engine;
1402
+ const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1403
+ const conversationId = snap.conversationId ?? undefined;
1404
+ if (!conversationId) {
1405
+ ui?.notify("no Antigravity conversation bound yet; run a turn first.", "warning");
1406
+ return;
1407
+ }
1408
+ const dir = agyConversationDir(engine, conversationId);
1409
+ const rest = (args ?? "").trim().split(/\s+/);
1410
+
1411
+ // /agy tasks tail <id>: last bytes of one task log into chat.
1412
+ // Resolved against the scanned list, not a rebuilt filename:
1413
+ // the id->filename convention must not live in two places.
1414
+ if (rest[0]?.toLowerCase() === "tail") {
1415
+ const id = Number(rest[1]);
1416
+ if (!Number.isInteger(id) || id < 0) {
1417
+ ui?.notify("usage: /agy tasks tail <id>. /agy tasks lists them.", "warning");
1418
+ return;
1419
+ }
1420
+ const tasks = await listAgyTasks(dir);
1421
+ const task = tasks.find((t) => t.id === id);
1422
+ if (!task) {
1423
+ ui?.notify(`no task #${id} in this conversation. /agy tasks lists them.`, "warning");
1424
+ return;
1425
+ }
1426
+ const tail = await tailAgyTaskLog(task.logPath);
1427
+ if (tail === "") {
1428
+ ui?.notify(`task #${id} has no readable log.`, "warning");
1429
+ return;
1430
+ }
1431
+ const lines = tail.split("\n");
1432
+ const body = lines.length > 40 ? `…\n${lines.slice(-40).join("\n")}` : tail;
1433
+ ui?.notify(`task #${id} tail:\n${body}`, "info");
1434
+ return;
1435
+ }
1436
+
1437
+ // Dashboard form; r rescans by re-listing fresh.
1438
+ let tasks = await listAgyTasks(dir);
1439
+ if (tasks.length === 0) {
1440
+ ui?.notify("no background tasks recorded for this conversation.", "info");
1441
+ return;
1442
+ }
1443
+ if (!ui) {
1444
+ const lines = tasks.map((t) => {
1445
+ const state = t.livenessKnown ? (t.active ? "ACTIVE" : "idle") : "liveness?";
1446
+ return `#${t.id} ${state} ${t.bytes}B`;
1447
+ });
1448
+ console.log([`antigravity tasks (${tasks.length}):`, ...lines].join("\n"));
1449
+ return;
1450
+ }
1451
+ for (;;) {
1452
+ const action = await showTasksBrowser(ui, tasks, tasks[0]?.livenessKnown ?? false);
1453
+ if (action.type === "rescan") {
1454
+ tasks = await listAgyTasks(dir);
1455
+ if (tasks.length === 0) {
1456
+ ui?.notify("no background tasks recorded for this conversation.", "info");
1457
+ }
1458
+ continue;
1459
+ }
1460
+ break;
1461
+ }
1462
+ return;
1463
+ }
1464
+ if (sub === "artifacts") {
1465
+ const engine = ctx.engine;
1466
+ const snap = (engine === "acp" ? ctx.acpDriver : ctx.driver).snapshot();
1467
+ const conversationId = snap.conversationId ?? undefined;
1468
+ if (!conversationId) {
1469
+ ui?.notify("no Antigravity conversation bound yet; run a turn first.", "warning");
1470
+ return;
1471
+ }
1472
+ const dir = agyConversationDir(engine, conversationId);
1473
+ const rest = (args ?? "").trim().split(/\s+/).slice(1);
1474
+ const openWord = rest[0]?.toLowerCase();
1475
+
1476
+ // /agy artifacts open <name|index>: direct open, no overlay.
1477
+ if (openWord === "open") {
1478
+ const artifacts = await listAgyArtifacts(dir);
1479
+ if (artifacts.length === 0) {
1480
+ ui?.notify("no artifacts in this conversation.", "info");
1481
+ return;
1482
+ }
1483
+ const target = rest.slice(1).join(" ").trim();
1484
+ if (target === "") {
1485
+ ui?.notify("usage: /agy artifacts open <name|index>. /agy artifacts lists them.", "warning");
1486
+ return;
1487
+ }
1488
+ const byIndex = /^\d+$/.test(target) ? Number(target) : undefined;
1489
+ const artifact =
1490
+ byIndex !== undefined
1491
+ ? artifacts[byIndex]
1492
+ : artifacts.find((a) => a.name.toLowerCase() === target.toLowerCase()) ??
1493
+ (artifacts.filter((a) => a.name.toLowerCase().includes(target.toLowerCase())).length === 1
1494
+ ? artifacts.find((a) => a.name.toLowerCase().includes(target.toLowerCase()))
1495
+ : undefined); // ambiguous or no match
1496
+ if (!artifact) {
1497
+ ui?.notify(`no unique artifact matches ${JSON.stringify(target)}. /agy artifacts lists them.`, "warning");
1498
+ return;
1499
+ }
1500
+ const openCmd = artifactOpenCommand();
1501
+ if (!openCmd) {
1502
+ ui?.notify(`no file-open handler for ${process.platform}; the file is at ${artifact.absolutePath}`, "warning");
1503
+ return;
1504
+ }
1505
+ spawn(openCmd.cmd, [artifact.absolutePath], { detached: true, stdio: "ignore", shell: false }).unref();
1506
+ ui?.notify(`opened ${artifact.name}`, "info");
1507
+ return;
1508
+ }
1509
+
1510
+ // Browser loop; r rescans by re-listing fresh. The first scan is
1511
+ // reused so the emptiness gate does not cost a second readdir pass.
1512
+ let artifacts = await listAgyArtifacts(dir);
1513
+ if (artifacts.length === 0) {
1514
+ ui?.notify("no artifacts in this conversation (media dirs appear once agy generates or receives files).", "info");
1515
+ return;
1516
+ }
1517
+ if (!ui) {
1518
+ // Headless: no overlay is possible; print the indexed list.
1519
+ const lines = artifacts.map((a, i) => `${i} ${a.name} (${a.kind}, ${a.bytes}B)`);
1520
+ console.log([`antigravity artifacts (${artifacts.length}):`, ...lines].join("\n"));
1521
+ return;
1522
+ }
1523
+ for (;;) {
1524
+ const action = await showArtifactsBrowser(ui, artifacts);
1525
+ if (action.type === "rescan") {
1526
+ artifacts = await listAgyArtifacts(dir);
1527
+ continue;
1528
+ }
1529
+ if (action.type === "open") {
1530
+ const openCmd = artifactOpenCommand();
1531
+ if (!openCmd) {
1532
+ ui.notify(`no file-open handler for ${process.platform}.`, "warning");
1533
+ continue;
1534
+ }
1535
+ spawn(openCmd.cmd, [action.artifact.absolutePath], { detached: true, stdio: "ignore", shell: false }).unref();
1536
+ }
1537
+ break;
1538
+ }
1539
+ return;
1540
+ }
1541
+ if (sub === "quota") {
1542
+ // Quota lives in the CLI's Google identity. A pure-ACP install
1543
+ // without the CLI is a supported shape; say what is missing instead
1544
+ // of rendering a fetch failure.
1545
+ const bin = resolveAgyBinary();
1546
+ if (!isAgyInstalled(bin)) {
1547
+ ui?.notify(
1548
+ "quota comes from the agy CLI, which is not installed. ACP-only setups can install the CLI separately (same Google account) to see subscription quota (/agy doctor shows what IS available).",
1549
+ "warning",
1550
+ );
1551
+ return;
1552
+ }
1553
+ // /usage answers in ~10s live; surface the wait instead of a dead
1554
+ // command line.
1555
+ ui?.setStatus("agy-quota", "checking agy quota…");
1556
+ try {
1557
+ const report = await fetchAgyQuota(bin);
1558
+ if (report === undefined) {
1559
+ ui?.notify("quota unavailable: agy did not return a /usage payload (see /agy doctor for binary health).", "warning");
1560
+ return;
1561
+ }
1562
+ ui?.notify(formatAgyQuotaReport(report), "info");
1563
+ } finally {
1564
+ ui?.setStatus("agy-quota", undefined);
1565
+ }
1566
+ return;
1567
+ }
1568
+ if (sub === "subagents") {
1569
+ const entries = ctx.roster.snapshot();
1570
+ if (entries.length === 0) {
1571
+ ui?.notify("antigravity subagents: none tracked this session\n\nagy spawns subagents as ordinary tool steps; they appear here once a turn uses them.", "info");
1572
+ return;
1573
+ }
1574
+ ui?.notify(formatSubagentRoster(entries), "info");
1575
+ return;
1576
+ }
1577
+ if (sub === "agent") {
1578
+ // ACP has no agent slot in the protocol (RC01): refuse instead of
1579
+ // silently ignoring a configured agent.
1580
+ if (ctx.engine === "acp") {
1581
+ ui?.notify("the ACP engine has no agent selection (RC01). /agy engine stream-json first.", "warning");
1582
+ return;
1583
+ }
1584
+ const name = (args ?? "").trim().split(/\s+/).slice(1).join(" ").trim();
1585
+ if (!name) {
1586
+ const agents = await listAgyAgents(resolveAgyBinary());
1587
+ const current = loadConfig().agent;
1588
+ const agentList =
1589
+ agents.length > 0
1590
+ ? agents.map((a) => `${a === current ? ">" : " "} ${a}`).join("\n")
1591
+ : "(no custom agents defined)";
1592
+ ui?.notify(`current agent: ${current ?? "(agy default)"}\n\n${agentList}\n\nusage: /agy agent <name|off>`, "info");
1593
+ return;
1594
+ }
1595
+ if (name === "off" || name === "none" || name === "default") {
1596
+ saveConfig({ agent: undefined });
1597
+ ui?.notify("agent cleared: agy default", "info");
1598
+ return;
1599
+ }
1600
+ if (!isValidAgyAgentName(name)) {
1601
+ ui?.notify(`invalid agent name: ${name} (use letters, digits, dot, dash, underscore)`, "warning");
1602
+ return;
1603
+ }
1604
+ saveConfig({ agent: name });
1605
+ ui?.notify(`agent set: ${name} (takes effect on the next stream-json turn)`, "info");
1606
+ return;
1607
+ }
1341
1608
  if (sub === "permissions") {
1342
1609
  if (val === "on" || val === "off") {
1343
1610
  const next = saveConfig({ skipPermissions: val === "on" });
@@ -1457,9 +1724,11 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
1457
1724
  }
1458
1725
 
1459
1726
 
1460
- // No subcommand (or "status"): print status, or open the picker in TUI.
1461
- if (sub && sub !== "status") {
1462
- ui?.notify(`unknown subcommand: ${sub}\n${statusText(ctx)}`, "warning");
1727
+ // No subcommand: print status, or open the picker in TUI. The old
1728
+ // "status" alias is retired; /agy doctor is the one status surface.
1729
+ if (sub) {
1730
+ const pointer = sub === "status" ? "/agy status was removed; use /agy doctor.\n" : "";
1731
+ ui?.notify(`${pointer}unknown subcommand: ${sub}\n${statusText(ctx)}`, "warning");
1463
1732
  return;
1464
1733
  }
1465
1734
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@estebanforge/pi-antigravity-bridge",
3
- "version": "1.7.2",
3
+ "version": "1.7.4",
4
4
  "description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. antigravity/* models in Pi's /model picker, no-patch MCP bridge: agy runs Pi's tools. ToS safe to use.",
5
5
  "keywords": [
6
6
  "pi-package",
package/src/acp/driver.ts CHANGED
@@ -438,7 +438,7 @@ export class AcpDriver implements TurnDriver {
438
438
  case "tool_start": {
439
439
  const entry = { name: mapped.name, args: mapped.args, diff: mapped.diff, mcpServer: mapped.mcpServer };
440
440
  turn.toolCalls.set(mapped.toolCallId, entry);
441
- this.#emit(turn, { type: "tool_start", name: mapped.name, args: mapped.args, mcpServer: mapped.mcpServer });
441
+ this.#emit(turn, { type: "tool_start", name: mapped.name, args: mapped.args, mcpServer: mapped.mcpServer, toolCallId: mapped.toolCallId });
442
442
  return;
443
443
  }
444
444
  case "tool_done": {
@@ -457,14 +457,14 @@ export class AcpDriver implements TurnDriver {
457
457
  const args = entry?.args ?? {};
458
458
  // Native diff from the stored tool_call frame; the update's own
459
459
  // diff (future builds) wins when present.
460
- this.#emit(turn, { type: "tool_done", name, args, output: mapped.output, diff: mapped.diff ?? entry?.diff, mcpServer: mapped.mcpServer ?? entry?.mcpServer });
460
+ this.#emit(turn, { type: "tool_done", name, args, output: mapped.output, diff: mapped.diff ?? entry?.diff, mcpServer: mapped.mcpServer ?? entry?.mcpServer, toolCallId: mapped.toolCallId });
461
461
  return;
462
462
  }
463
463
  case "tool_error": {
464
464
  const entry = turn.toolCalls.get(mapped.toolCallId);
465
465
  turn.toolCalls.delete(mapped.toolCallId);
466
466
  const name = entry?.name ?? "tool";
467
- this.#emit(turn, { type: "tool_error", name, message: mapped.message, mcpServer: mapped.mcpServer ?? entry?.mcpServer });
467
+ this.#emit(turn, { type: "tool_error", name, message: mapped.message, mcpServer: mapped.mcpServer ?? entry?.mcpServer, toolCallId: mapped.toolCallId });
468
468
  return;
469
469
  }
470
470
  case "replay_user":
package/src/agents.ts ADDED
@@ -0,0 +1,64 @@
1
+ // agy custom-agent roster (stream-json engine only).
2
+ //
3
+ // `agy agent` (alias `agents`) lists the user's custom agents one per line.
4
+ // Live-verified against agy 1.2.10: bare names, no banner and no columns
5
+ // when agents exist; help text on bad flags; empty output when none. The
6
+ // parser still tolerates a name-plus-description column layout and banner
7
+ // lines so a future agy that annotates the listing keeps parsing.
8
+ //
9
+ // A picked name rides the turn request as `--agent` (same flag our web
10
+ // tools already pass for their throwaway agents). ACP has no agent slot in
11
+ // the protocol (RC01), so selection is refused at the command level there.
12
+
13
+ import { spawnAgyRaw } from "./models.js";
14
+
15
+ /** Agent names are agy agent-dir names: filesystem-safe tokens. */
16
+ export const AGENT_NAME_MAX = 128;
17
+ const AGENT_NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
18
+
19
+ /** Parse raw `agy agent` output into agent names. Tolerant: skips empty and
20
+ * banner/help lines, takes the first whitespace-separated token per line
21
+ * (a future description column survives), rejects names that are not
22
+ * filesystem-safe tokens, dedupes preserving order. */
23
+ export function parseAgyAgentsRaw(text: string): string[] {
24
+ const seen = new Set<string>();
25
+ const agents: string[] = [];
26
+ for (const line of text.split("\n")) {
27
+ const trimmed = line.trim();
28
+ if (!trimmed) continue;
29
+ // Help output and older banner formats are noise, not agent names
30
+ // (checked on the whole line: "List available agents" has no single
31
+ // banner token).
32
+ if (/^(usage:|flags:|-h\b|list available|available agents)/i.test(trimmed)) continue;
33
+ const token = trimmed.split(/\s+/)[0] ?? "";
34
+ if (!AGENT_NAME_RE.test(token) || seen.has(token)) continue;
35
+ seen.add(token);
36
+ agents.push(token);
37
+ }
38
+ return agents;
39
+ }
40
+
41
+ /** True when `name` is a valid agent-dir token (what `--agent` accepts). */
42
+ export function isValidAgyAgentName(name: string): boolean {
43
+ return AGENT_NAME_RE.test(name);
44
+ }
45
+
46
+ /** Normalize a configured agent value: trim, cap, strip control characters;
47
+ * empty result means "no agent configured". Kept permissive beyond the
48
+ * token regex so a user typo reaches agy's own validation (a visible
49
+ * turn error) instead of silently reverting to the default agent. */
50
+ export function normalizeAgyAgentName(value: string | undefined): string | undefined {
51
+ if (value === undefined) return undefined;
52
+ const cleaned = value
53
+ .trim()
54
+ .replace(/[\x00-\x1f\x7f]/g, "")
55
+ .slice(0, AGENT_NAME_MAX);
56
+ return cleaned === "" ? undefined : cleaned;
57
+ }
58
+
59
+ /** List the custom agents `agy agent` reports. "" stdout (missing binary,
60
+ * failure, cap) parses to [], so callers can treat "no agents" and "could
61
+ * not ask" the same way the command layer renders it. */
62
+ export async function listAgyAgents(binary: string = "agy"): Promise<string[]> {
63
+ return parseAgyAgentsRaw(await spawnAgyRaw(binary, ["agent"]));
64
+ }