@estebanforge/pi-antigravity-bridge 1.4.6 → 1.4.8
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 +33 -0
- package/README.md +47 -2
- package/docs/ACP-PROTOCOL-REFERENCE.md +1 -0
- package/docs/ARCHITECTURE.md +1 -0
- package/docs/DEVELOPMENT.md +1 -0
- package/extensions/index.ts +141 -12
- package/package.json +1 -1
- package/src/acp/driver.ts +8 -0
- package/src/ask-tool.ts +15 -0
- package/src/config.ts +15 -0
- package/src/daily-log.ts +182 -0
- package/src/driver.ts +14 -2
- package/src/mcp-server.ts +8 -0
- package/src/provider.ts +286 -15
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [1.4.8] - 2026-09-05
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Early-ack + poll for long bridge calls. agy's MCP client abandons a `tools/call` request at a flat ~180s (observed twice at exactly 180.000s), so any pi tool that ran longer died with "agy disconnected before the tool result arrived": the 225.7s `AskClaude` peer review that exposed it never reached agy, and agy salvaged its turn without the result. Now a call still running after ~20 seconds settles the HTTP request with a `STILL RUNNING` answer carrying a `callId`, and the new bridge-local `bridge_poll_result` tool returns the result when it lands (or "still running" on the way). pi keeps executing the whole time; fast calls never see any of this. An escalated park re-arms its own timeout to 30 minutes, so human-gated tools (commit previews, permission dialogs) can take as long as the human takes.
|
|
10
|
+
- Late tool-result delivery as a backstop: a park that does fail (abort, timeout, recycle) leaves a bounded tombstone, and when the toolResult arrives anyway the provider re-routes it to agy as a new prompt in the same conversation ("Late tool delivery: ...") instead of erroring the turn. A late result that lands while another park still anchors the pass is deferred to the next pass (`late-result-deferred`), never dropped.
|
|
11
|
+
- A `progress-token` probe in the bridge server: if agy's requests ever carry `_meta.progressToken`, MCP progress notifications become a testable zero-UX fix for the deadline. The exact-180s signature says it likely never fires; one log line settles it.
|
|
12
|
+
|
|
13
|
+
### Fixed
|
|
14
|
+
|
|
15
|
+
- A toolResult whose park already died no longer misclassifies the turn as "No user message to send to agy." That was the second half of the incident, and it turned a recoverable late delivery into a hard error.
|
|
16
|
+
- `failAll` (fired on every turn end, OK turns included) no longer marks escalated calls failed: an escalated call outlives its agy turn by design, and the poll handle must not lie about a still-running tool (peer-review blocker).
|
|
17
|
+
- `EscalationRegistry` eviction can no longer strand a running call: only settled entries evict, so a saturated cap grows instead of losing an in-flight result.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- The tool-priority note now also teaches the poll pattern: long bridge calls answer `STILL RUNNING` + `bridge_poll_result`, and work that is known-long should use `exec_command`'s session output or background agents.
|
|
22
|
+
- New daily-log events: `call-tool-escalated`, `poll-tool`, `late-result` (with a `freshConversation` flag), `late-result-deferred`, `progress-token`. Docs: README bridge section, ACP-PROTOCOL-REFERENCE timing table.
|
|
23
|
+
|
|
24
|
+
## [1.4.7] - 2026-09-05
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- Daily debug log on disk, sorted by day: `~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson`, one JSON record per line, 14-day retention. Built for support: when something breaks, the last days' files replay the failure (engine, session, bridge call, error) without reproducing it. Both engines and everything around them feed it: turn start/outcome with error text, driver failures (stall, abort, timeout, nonzero exit), ACP session load/new and connection exits, bridge tool calls and round-trip failures, AskAntigravity runs, `/agy` commands, ACP setup/self-heal/auth, and the login URL (query string stripped). Secret-shaped values (tokens, API keys, credentials, header blocks) are redacted; prompt text, tool arguments, and tool output never land in the log; an unwritable directory is skipped silently and retried. `/agy doctor` prints the directory.
|
|
29
|
+
- Two verbosity tiers keep the disk cost negligible: by default only the info/warn/error skeleton is written (a handful of records per turn). `AGY_DEBUG=1` (or `true`/`on`) restores the full per-event trail: spawn/exit, session load/new, unparks, recycle causes, raw bridge chatter. Turn it on to reproduce, then off.
|
|
30
|
+
- Pre-dispatch turn errors now reach the log (previously they only surfaced as a one-line pi error and were lost): driver start failures, stray tool results with no active turn, the ACP plan-mode refusal, and a miswired extension.
|
|
31
|
+
- The engine capabilities comparison table in the README: 20 rows comparing `stream-json` vs `acp` (thinking text, token usage, image/audio input, plan mode, slash-command handling, model/effort switching, process lifecycle, session resume, abort, bridge routing, edit diffs, permissions, digest and system-prompt delivery, auth, wire protocol, doctor diagnostics), peer-reviewed against the code and the live probes.
|
|
32
|
+
|
|
33
|
+
### Changed
|
|
34
|
+
|
|
35
|
+
- `/agy doctor` prints the log directory with a hint to attach recent days' files when reporting issues.
|
|
36
|
+
- README, architecture module map, and the regression-test list document the logger, its support flow, and the `AGY_DEBUG` gate.
|
|
37
|
+
|
|
5
38
|
## [1.4.6] - 2026-09-04
|
|
6
39
|
|
|
7
40
|
### Added
|
package/README.md
CHANGED
|
@@ -23,7 +23,30 @@ Turns run through one of two engines behind the same provider surface (`config.e
|
|
|
23
23
|
- **stream-json** (default): the persistent `agy` CLI process. The tested default; live token usage; conversation resume via `--conversation`.
|
|
24
24
|
- **acp**: Google's official ACP server (`agy_acp_server.par`), JSON-RPC 2.0 over stdio. Opt-in while it matures: the current build (RC01) ships no usage fields (token display shows zero) and no cancel (abort tears the server down and reloads it next turn). Everything else is parity-verified live - text streaming, multi-turn resume via `session/load`, bridge tools, effort switching, serialization, abort recovery - see `scripts/parity-live.mjs`.
|
|
25
25
|
|
|
26
|
-
Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text.
|
|
26
|
+
Engine-dependent features: pi image attachments ride natively only on the ACP engine (the picker offers image attach automatically when `config.engine` is `acp`; the stream-json CLI prompt is text-only). With the optional G1 digest enabled, its delivery also differs: ACP ships it as a native `embeddedContext` resource block, stream-json prepends it to the prompt text. The `AskAntigravity` delegation tool is unaffected by `config.engine` and runs the `stream-json` CLI (`agy -p`) across both configurations.
|
|
27
|
+
|
|
28
|
+
| Capability | `stream-json` (default) | `acp` |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| Show thinking text | No (token count only, floor 64, no text body) | Yes (streams thought text via `agent_thought_chunk`; sparse on RC01 where reasoning often arrives in message text) |
|
|
31
|
+
| Live token usage | Yes (live metrics from CLI step events) | No (absent in RC01, displays zero tokens) |
|
|
32
|
+
| Image prompt input | No (CLI prompt is text-only; images dropped) | Yes (native image blocks forwarded to server) |
|
|
33
|
+
| Audio prompt input | No (dropped) | Protocol advertised (`promptCapabilities.audio: true`) |
|
|
34
|
+
| Review-only plan mode | Yes (`--mode plan` review-only via `/agy mode plan`) | No (RC01 modes are permission levels; plan mode refused) |
|
|
35
|
+
| Leading slash commands in prompt | Disabled via `--disable-slash-commands` (sent as plain text) | Server intercepts recognized commands (e.g. `/plan`) and executes them under the active policy |
|
|
36
|
+
| Dynamic model / effort switch | Recycles process on model or effort change | Dynamic per-turn via `session/set_config_option` (no restart) |
|
|
37
|
+
| Process lifecycle | 1 persistent `agy` process per provider; recycles on drift | 1 persistent server process hosting N sessions concurrently |
|
|
38
|
+
| Session resume & persistence | Client-side map in `sessions.json` via `--conversation <id>` | Server-side session store via `session/load` and `session/new` |
|
|
39
|
+
| Turn cancel / abort | Kills process group; in-flight turn terminates | Teardown, kill, and auto-reload on RC01 (-32601 fallback) |
|
|
40
|
+
| MCP tool bridge routing | Injected filesystem config via `--add-dir` | Direct `mcpServers` param in `session/new` and `session/load` |
|
|
41
|
+
| Tool execution & visibility | Native re-exec (read-only) + wrapper replay (mutating) | Server executes tools natively; events stream with content |
|
|
42
|
+
| Inline file edit diffs | Sourced from git working tree in thinking block | Sourced from `tool_call content[]` or disk vs git HEAD |
|
|
43
|
+
| Permission handling | `--dangerously-skip-permissions` (unattended CLI requirement) | Protocol-native `session/request_permission` (auto-approve when `skipPermissions` is on; auto-deny when off) |
|
|
44
|
+
| Context digest delivery (G1) | Prepend plain text inline in prompt | Native `embeddedContext` resource block |
|
|
45
|
+
| System prompt delivery (G10) | Prepend to first prompt of conversation | Prepend to first prompt of conversation |
|
|
46
|
+
| Authentication methods | Inherits existing `agy` CLI OAuth state | 4 methods: `oauth-personal`, `oauth-business`, `gemini-api-key`, `agent-platform` |
|
|
47
|
+
| Wire protocol | Undocumented CLI NDJSON stream format | Versioned JSON-RPC 2.0 over stdio (`protocolVersion: 1`) |
|
|
48
|
+
| Diagnostics (`/agy doctor`) | Child PID, state, process spawns, recycles, queue stats | Server version, agentInfo, session counts, reconnect count, cancel support |
|
|
49
|
+
| Integration channel | Spawns internal CLI stream-json dialect | Official Google first-party ACP server binary |
|
|
27
50
|
|
|
28
51
|
Switch with `/agy engine acp|stream-json` (takes effect on restart). Setup is automatic: switching to `acp` installs Google's official ACP server binary from the [antigravity-acp registry entry](https://github.com/agentclientprotocol/registry) (`~/.local/opt/agy-acp/<build>/` + a `current` symlink, zip sha256 recorded; layout and pinning in [docs/ACP-ADOPTION-PLAN.md](docs/ACP-ADOPTION-PLAN.md)) and prepares the login. The login is your Antigravity subscription: the same account and plan you use for the Antigravity CLI (`agy`). Sign in explicitly with `/agy auth` (engine `acp` selected): it opens the Google login in your browser and completes when you finish it. If no browser is available (an SSH session on a remote machine), pi shows the sign-in URL to copy, plus the ssh port-forward command for the login redirect. It is no different from logging into the CLI; the server just keeps its own token file on your machine, like any Google tool, and this extension never sees your credentials. If you also export `GEMINI_API_KEY`, it is ignored: the server uses the auth type in settings.json, and setup always writes `oauth-personal`. A session start self-heals the same way, silently when everything is ready. Manual instructions (`/agy auth-manual`) surface only when a step fails. Sessions are engine-scoped, so switching engines never crosses conversations.
|
|
29
52
|
|
|
@@ -45,6 +68,8 @@ The bridge starts a localhost MCP server inside pi's process. `tools/list` retur
|
|
|
45
68
|
|
|
46
69
|
**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.
|
|
47
70
|
|
|
71
|
+
**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.
|
|
72
|
+
|
|
48
73
|
**Recursion safety.** Only the provider's agy receives the extra `--add-dir`. The `AskAntigravity` tool spawns its own agy with just the workspace, so that inner agy starts plain (no pi tools) and cannot re-enter. `AskAntigravity` is also filtered from the exposed tool list. Standalone agy is unaffected because nothing is written to its global config.
|
|
49
74
|
|
|
50
75
|
**Cost / fan-out.** Every registered pi tool except builtins (and `AskAntigravity`) is exposed, including other delegation tools like `AskClaude`/`AskCodex`. agy can therefore chain into other models via the bridge, which is a new cost/time fan-out vector that did not exist before this feature.
|
|
@@ -120,7 +145,7 @@ The `activate_skill` catalog mirrors pi's directory-based skill discovery: the t
|
|
|
120
145
|
```
|
|
121
146
|
/agy status, or open the full settings picker (TUI)
|
|
122
147
|
/agy status print current settings + session counts
|
|
123
|
-
/agy doctor bridge state, driver counters, bridge port, last lifecycle events
|
|
148
|
+
/agy doctor bridge state, driver counters, bridge port, last lifecycle events, log dir
|
|
124
149
|
/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
|
|
125
150
|
/agy mode plan review-only: agy plans but writes nothing
|
|
126
151
|
/agy mode accept-edits agy applies edits directly (default)
|
|
@@ -163,6 +188,26 @@ For isolation when running any agent that executes commands without a confirmati
|
|
|
163
188
|
| `AGY_SKIP_PERMISSIONS` | `1`/`true` (default) to pass `--dangerously-skip-permissions` so commands don't hang on an unanswerable prompt in `-p` mode. `0`/`false` to prompt (hangs any `run_command` non-interactively). Wins over the config file. |
|
|
164
189
|
| `AGY_DEFAULT_MODEL` | Default model alias for the `AskAntigravity` tool (`flash`/`pro`/`gemini`, or a tier/version qualifier). Wins over the config file. |
|
|
165
190
|
| `AGY_DEFAULT_THINKING` | Default thinking tier for the `AskAntigravity` tool: `low`/`medium`/`high`. Anything else falls back to `medium`. Wins over the config file. |
|
|
191
|
+
| `AGY_DEBUG` | `1`/`true`/`on` writes verbose debug records to the daily log (driver lifecycle, raw bridge traffic). Default off: only the light info/warn/error stream. |
|
|
192
|
+
|
|
193
|
+
## Debug logs
|
|
194
|
+
|
|
195
|
+
The extension keeps a daily log on your machine, sorted by day:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
~/.pi/extensions-data/estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
One JSON record per line. Two verbosity tiers keep the disk cost negligible for regular users: by default only `info`/`warn`/`error` records land on disk, which is the useful skeleton: turn starts and outcomes with error text (both engines), bridge tool calls, escalations and poll traffic, late deliveries, and round-trip failures, `AskAntigravity` runs, `/agy` commands, ACP setup/self-heal, auth URLs, and every driver failure (stall, abort, timeout, nonzero exit). Set `AGY_DEBUG=1` before reproducing a problem for the full trail: per-event driver lifecycle (spawn, exit, session load/new, unparks), list-tools traffic, recycle causes, and the raw bridge chatter. `/agy doctor` prints the log directory.
|
|
202
|
+
|
|
203
|
+
Notes:
|
|
204
|
+
|
|
205
|
+
- Retention: 14 days. Older files are pruned automatically.
|
|
206
|
+
- Privacy: prompt text, tool arguments, and tool output never land in the log. Secret-shaped values (tokens, API keys, credentials, header blocks) are redacted, and long strings are truncated. The `auth-url` record strips the login URL's query string.
|
|
207
|
+
- Logging never throws: an unwritable directory is skipped silently and retried on the next record.
|
|
208
|
+
- SSD wear: default volume is a handful of records per turn. Verbose mode (`AGY_DEBUG=1`) writes more; turn it off after reproducing.
|
|
209
|
+
|
|
210
|
+
When you report an issue, attach the last day or two of files from that directory. For anything that needs reproduction, run with `AGY_DEBUG=1` once and attach that day's file. They usually contain the exact failure sequence (engine, session, bridge call, error) with no need to guess.
|
|
166
211
|
|
|
167
212
|
## Development
|
|
168
213
|
|
|
@@ -349,6 +349,7 @@ or `terminal/*` delegation occurred with capabilities off.
|
|
|
349
349
|
| set_config_option response | < 1 s |
|
|
350
350
|
| Prompt first chunk | ~1-2 s (Flash, low effort) |
|
|
351
351
|
| OAuth onboarding window | minutes-scale; one timeout observed at ~8.5 min |
|
|
352
|
+
| Bridge `tools/call` HTTP request | agy's MCP client abandons the request at ~180s (observed twice at exactly 180.000s on 2026-09-05: AskClaude at 225.7s, then a follow-up exec_command). Mitigations: the bridge early-acks any call still running after ~20s with a poll handle (`bridge_poll_result`, `call-tool-escalated` log event) so the request never reaches the deadline; escalated parks re-arm at 30 min for human-gated latency; a result that outlives polling is re-delivered as a new same-conversation prompt (late delivery, `late-result` log event). `mcp-server` logs `progress-token` when a request carries `_meta.progressToken`: if agy ever sends one, progress notifications become a testable zero-UX fix |
|
|
352
353
|
| Steady RSS | ~327 MB (5 min mixed load; VSZ ~5.3 GB is TCMalloc reservation) |
|
|
353
354
|
|
|
354
355
|
## Run 6 findings (2026-09-03, post-restart session; raw traffic
|
package/docs/ARCHITECTURE.md
CHANGED
|
@@ -24,6 +24,7 @@ src/discovery.ts conversation-id binding for the AskAntigravity one-shot to
|
|
|
24
24
|
src/models.ts agy models -> pi Model projection (full catalog, per-model effort)
|
|
25
25
|
src/sessions.ts atomic JSON store: pi session -> agy conversation + watermark
|
|
26
26
|
src/config.ts persisted runtime config (engine + acp block, bridgeTools, digest, mode, permissions, model/thinking defaults)
|
|
27
|
+
src/daily-log.ts daily NDJSON support log (one file per day, 14-day retention, secret redaction, AGY_DEBUG verbose gate); fed by both drivers, the bridge, round-trips, /agy, and ask-tool
|
|
27
28
|
src/ask-tool.ts the AskAntigravity one-shot delegation tool (model/thinking defaults)
|
|
28
29
|
src/mcp-server.ts MCP tool bridge server: ferries tools/list + tools/call; calls park in the provider round-trip
|
|
29
30
|
src/diff-render.ts stream-json: render agy's file edits as git diffs in pi's thinking stream; formatInlineDiff (no git) renders ACP's native diffs
|
package/docs/DEVELOPMENT.md
CHANGED
|
@@ -85,6 +85,7 @@ Most "stuck" reports trace to one of:
|
|
|
85
85
|
- `tests/acp-events.test.ts` - ACP session/update mapping onto pi activities (text, thought, tool cards) and the session/load replay suppression.
|
|
86
86
|
- `tests/acp-driver.test.ts` - the ACP driver over the fake server (`tests/helpers/fake-acp-server.mjs`, scenario-selected): happy flow, load-replay, permission auto-answer, Gate D abort (cancel probe, teardown, `cancelSupported` memory), the stale-exit race (a killed connection's late exit must not fail its replacement - `ACP_FAKE_SLOW_DEATH_MS`), auth errors, park/kickIdle timer pause with remaining budget.
|
|
87
87
|
- `tests/acp-config.test.ts` - engine selection narrowing (`AGY_ENGINE`/`config.engine`), acp block parsing.
|
|
88
|
+
- `tests/daily-log.test.ts` - the support log: day rotation, retention cutoff boundary, secret redaction (incl. header blocks), the 4 KB record cap, never-throw on a broken dir, and the two-tier gate (debug records dropped unless `AGY_DEBUG`).
|
|
88
89
|
|
|
89
90
|
## Module map
|
|
90
91
|
|
package/extensions/index.ts
CHANGED
|
@@ -37,14 +37,22 @@ import {
|
|
|
37
37
|
type AgyModelEntry,
|
|
38
38
|
} from "../src/models.js";
|
|
39
39
|
import { SessionStore } from "../src/sessions.js";
|
|
40
|
-
import {
|
|
40
|
+
import {
|
|
41
|
+
POLL_TOOL_NAME,
|
|
42
|
+
ToolRoundTrips,
|
|
43
|
+
WrapperReplay,
|
|
44
|
+
createStreamSimple,
|
|
45
|
+
formatEscalatedAck,
|
|
46
|
+
formatPollAnswer,
|
|
47
|
+
} from "../src/provider.js";
|
|
41
48
|
import { AgyDriver } from "../src/driver.js";
|
|
42
49
|
import { AcpDriver } from "../src/acp/driver.js";
|
|
43
50
|
import { runAcpAuth } from "../src/acp/auth.js";
|
|
44
51
|
import { setupAuthUrlCapture } from "../src/acp/browser-capture.js";
|
|
45
52
|
import { ensureAcpReady, inspectAcpSetup } from "../src/acp/setup.js";
|
|
46
|
-
import type { TurnDriver } from "../src/driver-types.js";
|
|
47
|
-
import { CONFIG_PATH, loadConfig, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
53
|
+
import type { TurnDriver, TurnOutcome } from "../src/driver-types.js";
|
|
54
|
+
import { CONFIG_PATH, loadConfig, logsDir, saveConfig, type AgyMode, type BridgeTools, type Engine, type ThinkingTier } from "../src/config.js";
|
|
55
|
+
import { createDailyLogger, type DailyLogger } from "../src/daily-log.js";
|
|
48
56
|
import { registerAskAntigravityTool, toolModelsFromRaw } from "../src/ask-tool.js";
|
|
49
57
|
import { startMcpServer, TOKEN_HEADER, type McpServerHandle } from "../src/mcp-server.js";
|
|
50
58
|
import {
|
|
@@ -103,6 +111,17 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
103
111
|
const modelInput: Array<"text" | "image"> = engine === "acp" ? ["text", "image"] : ["text"];
|
|
104
112
|
const models = entries.map((e) => toPiModel(e, modelInput));
|
|
105
113
|
|
|
114
|
+
// Daily file log: every sink below feeds ~/.pi/extensions-data/
|
|
115
|
+
// estebanforge/pi-antigravity-bridge/logs/<YYYY-MM-DD>.ndjson (see
|
|
116
|
+
// src/daily-log.ts). Fire-and-forget, secrets redacted, old days pruned.
|
|
117
|
+
// Support flow: "attach the last days' files from that dir".
|
|
118
|
+
const fileLog = createDailyLogger({ dir: logsDir() });
|
|
119
|
+
fileLog.log(
|
|
120
|
+
"extension-load",
|
|
121
|
+
{ engine, models: models.length, fallback: usingFallback, bridge: loadConfig().bridgeTools, askTool: loadConfig().askTool },
|
|
122
|
+
"info",
|
|
123
|
+
);
|
|
124
|
+
|
|
106
125
|
const store = new SessionStore();
|
|
107
126
|
// MCP bridge handle, declared early: the ACP engine reads the bridge port
|
|
108
127
|
// at session/new / session/load time.
|
|
@@ -136,9 +155,41 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
136
155
|
"unsupported-server-request",
|
|
137
156
|
]);
|
|
138
157
|
const legacyDriver = new AgyDriver();
|
|
158
|
+
// Mirror the legacy driver's lifecycle ring into the daily file log
|
|
159
|
+
// (spawn/exit/abort/stall/recycle). The ACP driver reaches the same file
|
|
160
|
+
// through acpLog below.
|
|
161
|
+
legacyDriver.log = (msg, data) => {
|
|
162
|
+
// Level classification mirrors acpLog's failure set: stalls, aborts,
|
|
163
|
+
// timeouts and nonzero exits are the "what broke" greps (warn);
|
|
164
|
+
// turn-start is the per-turn skeleton (info); everything else is
|
|
165
|
+
// verbose-only (debug, needs AGY_DEBUG).
|
|
166
|
+
const failed =
|
|
167
|
+
msg.startsWith("timeout:") ||
|
|
168
|
+
msg.startsWith("stall:") ||
|
|
169
|
+
msg.startsWith("abort:") ||
|
|
170
|
+
(msg.startsWith("exit:") && msg !== "exit:0");
|
|
171
|
+
const level = failed ? "warn" : msg === "turn-start" ? "info" : "debug";
|
|
172
|
+
fileLog.log(msg, data, level);
|
|
173
|
+
};
|
|
139
174
|
// Shared ACP log sink (driver turns AND /agy auth): the login URL event
|
|
140
175
|
// toasts so SSH users can copy it; genuine failures reach stderr.
|
|
141
176
|
const acpLog = (msg: string, data?: unknown): void => {
|
|
177
|
+
// The daily file log gets EVERY driver event (auth-url stripped of its
|
|
178
|
+
// query string - it carries one-time login state); the filters below
|
|
179
|
+
// only decide what reaches the user.
|
|
180
|
+
const fileData =
|
|
181
|
+
msg === "auth-url"
|
|
182
|
+
? { port: (data as { port?: number | null } | undefined)?.port ?? null, url: String((data as { url?: string } | undefined)?.url ?? "").split("?")[0] }
|
|
183
|
+
: data;
|
|
184
|
+
// Failures warn; turn-start + auth-url are the always-on skeleton;
|
|
185
|
+
// routine per-event lifecycle (spawn, session-load, unparked, ...) is
|
|
186
|
+
// verbose-only.
|
|
187
|
+
const level = acpFailures.has(msg)
|
|
188
|
+
? "warn"
|
|
189
|
+
: msg === "turn-start" || msg === "auth-url"
|
|
190
|
+
? "info"
|
|
191
|
+
: "debug";
|
|
192
|
+
fileLog.log(msg, fileData, level);
|
|
142
193
|
if (msg === "auth-url") {
|
|
143
194
|
const { url, port } = (data ?? {}) as { url?: string; port?: number | null };
|
|
144
195
|
if (!url) return;
|
|
@@ -190,7 +241,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
190
241
|
// The no-patch pi-tool round-trip store: the MCP bridge parks calls here;
|
|
191
242
|
// the provider emits them as real pi toolUse turns and completes them from
|
|
192
243
|
// the next call's toolResult.
|
|
193
|
-
const roundTrips = new ToolRoundTrips(activeDriver);
|
|
244
|
+
const roundTrips = new ToolRoundTrips(activeDriver, (s, d) => fileLog.log(s, d, s === "round-trip-fail" ? "warn" : "debug"));
|
|
194
245
|
const replay = new WrapperReplay();
|
|
195
246
|
// Native re-exec only emits for builtins actually active in the session;
|
|
196
247
|
// anything else (or an unknown name) falls back to the wrapper card.
|
|
@@ -204,7 +255,18 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
204
255
|
};
|
|
205
256
|
// A settled turn cannot answer its parked calls; the driver never sees
|
|
206
257
|
// ToolRoundTrips, so the provider bridges the two here (both engines).
|
|
207
|
-
|
|
258
|
+
// The file log records the outcome first: ERROR/aborted turns are the
|
|
259
|
+
// single most useful support signal.
|
|
260
|
+
const onTurnEnd = (outcome: TurnOutcome) => {
|
|
261
|
+
fileLog.log(
|
|
262
|
+
"turn-end",
|
|
263
|
+
// Error text can embed the child's stderr tail; cap it in line with
|
|
264
|
+
// the ACP driver's 200-char stderr slices.
|
|
265
|
+
{ status: outcome.status, error: outcome.error?.slice(0, 500), aborted: outcome.aborted },
|
|
266
|
+
outcome.status === "OK" ? "info" : "warn",
|
|
267
|
+
);
|
|
268
|
+
roundTrips.failAll("antigravity turn ended with an unresolved pi tool call");
|
|
269
|
+
};
|
|
208
270
|
legacyDriver.onTurnEnd = onTurnEnd;
|
|
209
271
|
acpDriver.onTurnEnd = onTurnEnd;
|
|
210
272
|
const streamSimple = createStreamSimple({
|
|
@@ -216,6 +278,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
216
278
|
replay,
|
|
217
279
|
nativeActive,
|
|
218
280
|
engine,
|
|
281
|
+
log: fileLog.log.bind(fileLog),
|
|
219
282
|
});
|
|
220
283
|
|
|
221
284
|
pi.registerProvider("antigravity", {
|
|
@@ -246,6 +309,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
246
309
|
engine,
|
|
247
310
|
getMcpPort: () => mcpHandle?.port ?? null,
|
|
248
311
|
acpLog,
|
|
312
|
+
fileLog,
|
|
249
313
|
authCapture: authCapture ?? null,
|
|
250
314
|
});
|
|
251
315
|
|
|
@@ -258,7 +322,7 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
258
322
|
// Note: the active flag below is set regardless of askTool, so
|
|
259
323
|
// pi-ask-antigravity keeps deferring even then: off means NO delegation
|
|
260
324
|
// tool from either package, not a fallback to pi-ask-antigravity.
|
|
261
|
-
if (loadConfig().askTool) await registerAskAntigravityTool(pi, toolModels);
|
|
325
|
+
if (loadConfig().askTool) await registerAskAntigravityTool(pi, toolModels, fileLog.log.bind(fileLog));
|
|
262
326
|
|
|
263
327
|
// Display-only wrapper tool: the provider emits mutating agy steps as
|
|
264
328
|
// toolCalls against it (never re-executed - execute() replays the output
|
|
@@ -309,6 +373,13 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
309
373
|
if (engine === "acp" && !acpSelfHealRan) {
|
|
310
374
|
acpSelfHealRan = true;
|
|
311
375
|
void ensureAcpReady({ configBin: loadConfig().acp.bin }).then((status) => {
|
|
376
|
+
fileLog.log(
|
|
377
|
+
"acp-self-heal",
|
|
378
|
+
status.ok
|
|
379
|
+
? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
|
|
380
|
+
: { ok: false, error: status.error },
|
|
381
|
+
status.ok ? "info" : "warn",
|
|
382
|
+
);
|
|
312
383
|
if (status.ok) {
|
|
313
384
|
if (status.binarySource === "installed" || status.binarySource === "existing") {
|
|
314
385
|
saveConfig({ acp: { bin: status.bin, permissions: loadConfig().acp.permissions } });
|
|
@@ -332,17 +403,28 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
332
403
|
// warning toast (ctx.ui.notify, ephemeral) or stderr when headless.
|
|
333
404
|
// Per-turn success events (list-tools / call-tool) stay silent.
|
|
334
405
|
const mcpLog = (s: string, d?: unknown) => {
|
|
406
|
+
const failures = new Set([
|
|
407
|
+
"http-error", "bridge-config-write-failed", "call-tool-fail",
|
|
408
|
+
"transport-error", "handleRequest-error", "request-error",
|
|
409
|
+
"request-handler-error", "unauthorized",
|
|
410
|
+
]);
|
|
411
|
+
// Daily file log gets every bridge event (call-tool/list-tools
|
|
412
|
+
// traffic included - it is how a parked round-trip is traced); the
|
|
413
|
+
// filters below only decide what reaches the user. Bridge calls
|
|
414
|
+
// start/end at info (one record per tool call, the fragile-path
|
|
415
|
+
// skeleton); list-tools and startup chatter stay verbose.
|
|
416
|
+
const level = failures.has(s)
|
|
417
|
+
? "warn"
|
|
418
|
+
: s === "call-tool" || s === "call-tool-ok"
|
|
419
|
+
? "info"
|
|
420
|
+
: "debug";
|
|
421
|
+
fileLog.log(s, d, level);
|
|
335
422
|
// Routine abort traffic: failAll fires on turn end / session shutdown
|
|
336
423
|
// and the bridge answers every parked call with an error. Not a fault.
|
|
337
424
|
if (s === "call-tool-fail") {
|
|
338
425
|
const detail = (d as { msg?: string } | undefined)?.msg ?? "";
|
|
339
426
|
if (detail.includes("unresolved pi tool call") || detail.includes("session shut down")) return;
|
|
340
427
|
}
|
|
341
|
-
const failures = new Set([
|
|
342
|
-
"http-error", "bridge-config-write-failed", "call-tool-fail",
|
|
343
|
-
"transport-error", "handleRequest-error", "request-error",
|
|
344
|
-
"request-handler-error", "unauthorized",
|
|
345
|
-
]);
|
|
346
428
|
if (!failures.has(s)) return;
|
|
347
429
|
const msg = `[antigravity-bridge mcp] ${s}${d !== undefined ? " " + JSON.stringify(d) : ""}`;
|
|
348
430
|
if (ctx.hasUI) ctx.ui.notify(msg, "warning");
|
|
@@ -384,6 +466,19 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
384
466
|
inputSchema: activateSkillSchema(skills) as object,
|
|
385
467
|
});
|
|
386
468
|
}
|
|
469
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
470
|
+
// registry without a pi round-trip. Pairs with the STILL RUNNING
|
|
471
|
+
// early-ack that keeps slow calls under agy's ~180s request deadline.
|
|
472
|
+
tools.push({
|
|
473
|
+
name: POLL_TOOL_NAME,
|
|
474
|
+
description:
|
|
475
|
+
"Fetch the result of a long-running bridge tool call that answered STILL RUNNING with a callId. Poll again if it still reports running; the result or an error arrives here.",
|
|
476
|
+
inputSchema: {
|
|
477
|
+
type: "object",
|
|
478
|
+
properties: { callId: { type: "string", description: "callId from the STILL RUNNING answer" } },
|
|
479
|
+
required: ["callId"],
|
|
480
|
+
},
|
|
481
|
+
});
|
|
387
482
|
return tools;
|
|
388
483
|
};
|
|
389
484
|
// activate_skill never round-trips through pi: the bridge answers it
|
|
@@ -394,7 +489,22 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
394
489
|
args: Record<string, unknown>,
|
|
395
490
|
signal: AbortSignal,
|
|
396
491
|
) => {
|
|
397
|
-
|
|
492
|
+
// Bridge-local, like activate_skill: answered from the escalation
|
|
493
|
+
// registry, never parked into pi.
|
|
494
|
+
if (name === POLL_TOOL_NAME) {
|
|
495
|
+
const wanted = typeof args.callId === "string" ? args.callId : "";
|
|
496
|
+
mcpLog("poll-tool", { callId: wanted });
|
|
497
|
+
return Promise.resolve(formatPollAnswer(wanted, roundTrips.poll(wanted)));
|
|
498
|
+
}
|
|
499
|
+
if (name !== ACTIVATE_SKILL_TOOL_NAME) {
|
|
500
|
+
return roundTrips.onToolCall(callId, name, args, signal).then((r) => {
|
|
501
|
+
// Early-ack: answer the HTTP request before agy's ~180s client
|
|
502
|
+
// deadline with a poll handle; pi keeps executing meanwhile.
|
|
503
|
+
if (!("escalated" in r)) return r;
|
|
504
|
+
mcpLog("call-tool-escalated", { name, callId: r.callId });
|
|
505
|
+
return formatEscalatedAck(r);
|
|
506
|
+
});
|
|
507
|
+
}
|
|
398
508
|
const wanted = typeof args.name === "string" ? args.name : "";
|
|
399
509
|
const skill = findSkillByName(skills, wanted);
|
|
400
510
|
const body = skill ? readSkillBody(skill) : `unknown skill: ${wanted || "(none given)"}`;
|
|
@@ -449,6 +559,8 @@ interface AgyCommandCtx {
|
|
|
449
559
|
getMcpPort: () => number | null;
|
|
450
560
|
/** Shared ACP log sink (login URL surfacing + failure events). */
|
|
451
561
|
acpLog: (msg: string, data?: unknown) => void;
|
|
562
|
+
/** Daily file logger (src/daily-log.ts); command + doctor surfacing. */
|
|
563
|
+
fileLog: DailyLogger;
|
|
452
564
|
/** BROWSER-capture handles; null when unavailable (Windows, unwritable
|
|
453
565
|
* data dir). /agy auth passes them to the sign-in process. */
|
|
454
566
|
authCapture: { browserEnv: Record<string, string>; file: string } | null;
|
|
@@ -502,6 +614,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
502
614
|
const mode = cmdCtx.mode;
|
|
503
615
|
const sub = (args ?? "").trim().split(/\s+/)[0]?.toLowerCase();
|
|
504
616
|
const val = (args ?? "").trim().split(/\s+/)[1]?.toLowerCase();
|
|
617
|
+
ctx.fileLog.log("agy-command", { args }, "info");
|
|
505
618
|
|
|
506
619
|
// Direct subcommands work everywhere (headless + TUI).
|
|
507
620
|
if (sub === "clear") {
|
|
@@ -548,6 +661,13 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
548
661
|
configBin: loadConfig().acp.bin,
|
|
549
662
|
onProgress: (m) => ui?.notify(m, "info"),
|
|
550
663
|
});
|
|
664
|
+
ctx.fileLog.log(
|
|
665
|
+
"acp-setup",
|
|
666
|
+
status.ok
|
|
667
|
+
? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
|
|
668
|
+
: { ok: false, error: status.error },
|
|
669
|
+
status.ok ? "info" : "warn",
|
|
670
|
+
);
|
|
551
671
|
if (!status.ok) {
|
|
552
672
|
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
553
673
|
return;
|
|
@@ -576,6 +696,13 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
576
696
|
}
|
|
577
697
|
ui?.notify("Preparing the ACP server (binary + auth settings)…", "info");
|
|
578
698
|
const status = await ensureAcpReady({ configBin: loadConfig().acp.bin, onProgress: (m) => ui?.notify(m, "info") });
|
|
699
|
+
ctx.fileLog.log(
|
|
700
|
+
"acp-setup",
|
|
701
|
+
status.ok
|
|
702
|
+
? { ok: true, binarySource: status.binarySource, needsLogin: status.needsLogin }
|
|
703
|
+
: { ok: false, error: status.error },
|
|
704
|
+
status.ok ? "info" : "warn",
|
|
705
|
+
);
|
|
579
706
|
if (!status.ok) {
|
|
580
707
|
ui?.notify(`ACP auto-setup failed (${status.error}).\n${status.manual}`, "warning");
|
|
581
708
|
return;
|
|
@@ -595,6 +722,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
595
722
|
authUrlFile: ctx.authCapture?.file,
|
|
596
723
|
log: ctx.acpLog,
|
|
597
724
|
});
|
|
725
|
+
ctx.fileLog.log("acp-auth", r.ok ? { ok: true } : { ok: false, error: r.error }, r.ok ? "info" : "warn");
|
|
598
726
|
if (r.ok) {
|
|
599
727
|
ui?.notify("Signed in. The ACP engine is ready; takes effect on the next pi start (or /reload).", "info");
|
|
600
728
|
} else {
|
|
@@ -665,6 +793,7 @@ function registerAgyCommand(pi: ExtensionAPI, ctx: AgyCommandCtx): void {
|
|
|
665
793
|
` sessions: ${ctx.store.size} bound`,
|
|
666
794
|
` models: ${ctx.entries.length} ${ctx.usingFallback ? "FALLBACK (agy models failed)" : "discovered"}`,
|
|
667
795
|
` config: ${CONFIG_PATH}`,
|
|
796
|
+
` logs: ${logsDir()} (attach recent days' files when reporting issues)`,
|
|
668
797
|
];
|
|
669
798
|
if (snap.engine === "acp" && snap.acp) {
|
|
670
799
|
lines.push(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.4.
|
|
3
|
+
"version": "1.4.8",
|
|
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
|
@@ -197,6 +197,14 @@ export class AcpDriver implements TurnDriver {
|
|
|
197
197
|
this.#active = turn;
|
|
198
198
|
this.#state = "running";
|
|
199
199
|
this.#stats.turns += 1;
|
|
200
|
+
this.#log("turn-start", {
|
|
201
|
+
model: request.model,
|
|
202
|
+
effort: request.effort,
|
|
203
|
+
mode: request.mode,
|
|
204
|
+
conversation: request.conversationId ?? null,
|
|
205
|
+
images: request.images?.length ?? 0,
|
|
206
|
+
contextBlock: request.contextBlock ? true : undefined,
|
|
207
|
+
});
|
|
200
208
|
|
|
201
209
|
// Abort wiring first: a kill during session setup must still settle the
|
|
202
210
|
// turn (Gate D teardown applies from the first request).
|
package/src/ask-tool.ts
CHANGED
|
@@ -266,6 +266,10 @@ const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
|
266
266
|
export async function registerAskAntigravityTool(
|
|
267
267
|
pi: ExtensionAPI,
|
|
268
268
|
entries: ModelEntry[],
|
|
269
|
+
/** Daily file log sink (src/daily-log.ts). Records lifecycle, never
|
|
270
|
+
* prompt text. Level matches DailyLogger: debug is the AGY_DEBUG-only
|
|
271
|
+
* verbose tier; info/warn/error always land. */
|
|
272
|
+
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void,
|
|
269
273
|
): Promise<void> {
|
|
270
274
|
pi.registerTool({
|
|
271
275
|
name: "AskAntigravity",
|
|
@@ -469,6 +473,11 @@ export async function registerAskAntigravityTool(
|
|
|
469
473
|
: finalPrompt;
|
|
470
474
|
|
|
471
475
|
const args: string[] = ["--add-dir", cwd];
|
|
476
|
+
log?.(
|
|
477
|
+
"ask-start",
|
|
478
|
+
{ model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
|
|
479
|
+
"info",
|
|
480
|
+
);
|
|
472
481
|
const extra = extraArgs();
|
|
473
482
|
if (extra.length) args.push(...extra);
|
|
474
483
|
if (resolved.model) args.push("--model", resolved.model);
|
|
@@ -625,6 +634,11 @@ export async function registerAskAntigravityTool(
|
|
|
625
634
|
details.aborted = outcome.aborted;
|
|
626
635
|
details.timedOut = outcome.timedOut;
|
|
627
636
|
details.durationMs = Date.now() - start;
|
|
637
|
+
log?.(
|
|
638
|
+
"ask-end",
|
|
639
|
+
{ exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, durationMs: details.durationMs, conversationId: details.conversationId },
|
|
640
|
+
outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut ? "warn" : "info",
|
|
641
|
+
);
|
|
628
642
|
|
|
629
643
|
if (!isContinuation && !details.conversationId && snapshot) {
|
|
630
644
|
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
@@ -680,6 +694,7 @@ export async function registerAskAntigravityTool(
|
|
|
680
694
|
if (statusInterval) clearInterval(statusInterval);
|
|
681
695
|
details.durationMs = Date.now() - start;
|
|
682
696
|
const msg = err instanceof Error ? err.message : String(err);
|
|
697
|
+
log?.("ask-fail", { error: msg, durationMs: details.durationMs }, "error");
|
|
683
698
|
return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
|
|
684
699
|
}
|
|
685
700
|
finally {
|
package/src/config.ts
CHANGED
|
@@ -22,6 +22,21 @@ const CONFIG_PATH = path.join(
|
|
|
22
22
|
"config.json",
|
|
23
23
|
);
|
|
24
24
|
|
|
25
|
+
/** Daily debug logs land here, sorted by day, for post-mortems and user
|
|
26
|
+
* support (see src/daily-log.ts). Same homedir convention as CONFIG_PATH;
|
|
27
|
+
* lives under the scoped extensions-data convention shared with other
|
|
28
|
+
* EstebanForge extensions. */
|
|
29
|
+
export function logsDir(): string {
|
|
30
|
+
return path.join(
|
|
31
|
+
os.homedir(),
|
|
32
|
+
".pi",
|
|
33
|
+
"extensions-data",
|
|
34
|
+
"estebanforge",
|
|
35
|
+
"pi-antigravity-bridge",
|
|
36
|
+
"logs",
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
25
40
|
/** Which turn engine drives turns. "stream-json" is the tested default;
|
|
26
41
|
* "acp" is the official-server engine, opt-in (plan §9.5). */
|
|
27
42
|
export type Engine = "stream-json" | "acp";
|
package/src/daily-log.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Daily NDJSON debug log for support and post-mortems.
|
|
2
|
+
//
|
|
3
|
+
// One file per local day: <dir>/YYYY-MM-DD.ndjson, one JSON record per line.
|
|
4
|
+
// Everything the extension logs (driver lifecycle, bridge traffic, round
|
|
5
|
+
// trips, turn outcomes, /agy commands) lands here so a broken session can be
|
|
6
|
+
// replayed from disk instead of from a user's memory. Users attach the last
|
|
7
|
+
// days' files when reporting issues.
|
|
8
|
+
//
|
|
9
|
+
// Hard rules (learned from the pi-token-cost-ledger writer):
|
|
10
|
+
// - Never throw, never await in the hot path. Logging must never disrupt
|
|
11
|
+
// a turn; a broken/unwritable dir is swallowed.
|
|
12
|
+
// - One appendFile per record: O_APPEND keeps single-line writes atomic,
|
|
13
|
+
// so two pi tabs sharing the dir stay line-consistent.
|
|
14
|
+
// - No secrets, no prompt content: values of secret-shaped keys are
|
|
15
|
+
// redacted and long strings are truncated before they reach disk.
|
|
16
|
+
// - Retention: files older than `retentionDays` are pruned once per
|
|
17
|
+
// process, so the dir cannot grow unbounded.
|
|
18
|
+
// - Two tiers, to keep SSD wear negligible for regular users: only
|
|
19
|
+
// info/warn/error records (failures, turn/tool boundaries, commands,
|
|
20
|
+
// setup) are written by default. Full verbose trails (per-event driver
|
|
21
|
+
// lifecycle, raw bridge traffic) require AGY_DEBUG=1.
|
|
22
|
+
|
|
23
|
+
import { appendFile, mkdir, readdir, unlink } from "node:fs/promises";
|
|
24
|
+
import path from "node:path";
|
|
25
|
+
|
|
26
|
+
export type LogLevel = "debug" | "info" | "warn" | "error";
|
|
27
|
+
|
|
28
|
+
export interface DailyLoggerOptions {
|
|
29
|
+
/** Target directory (created on first write). */
|
|
30
|
+
dir: string;
|
|
31
|
+
/** Files older than this many days are pruned once per process. Default 14. */
|
|
32
|
+
retentionDays?: number;
|
|
33
|
+
/** Verbose gate. When false (default), debug-level records are dropped:
|
|
34
|
+
* only info/warn/error land on disk, the light stream regular users
|
|
35
|
+
* keep. AGY_DEBUG=1 (or this option) restores the full trail. */
|
|
36
|
+
debug?: boolean;
|
|
37
|
+
/** Injectable clock for tests. */
|
|
38
|
+
now?: () => Date;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface DailyLogger {
|
|
42
|
+
log(event: string, data?: unknown, level?: LogLevel): void;
|
|
43
|
+
/** Resolves when every queued write settled (tests, shutdown). */
|
|
44
|
+
flush(): Promise<void>;
|
|
45
|
+
readonly dir: string;
|
|
46
|
+
/** Path of the file the next record lands in (doctor display). */
|
|
47
|
+
todayPath(): string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Local-date key, lexicographically sortable: 2026-02-05. */
|
|
51
|
+
function dayKey(d: Date): string {
|
|
52
|
+
const p = (n: number) => String(n).padStart(2, "0");
|
|
53
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const MAX_STRING = 2000;
|
|
57
|
+
const MAX_DEPTH = 6;
|
|
58
|
+
/** Cap for one serialized line: keeps the O_APPEND atomic-write guarantee
|
|
59
|
+
* honest for PIPE_BUF-sized writes even when many capped fields combine. */
|
|
60
|
+
const MAX_RECORD = 4000;
|
|
61
|
+
// Key-name redaction. `headers` is included wholesale: MCP server configs
|
|
62
|
+
// carry auth material under headers[] ({name, value} pairs) and the token
|
|
63
|
+
// lives in `value`, which a name-only regex would miss.
|
|
64
|
+
const SECRET_KEY = /token|secret|password|passphrase|authorization|api[-_]?key|cookie|headers/i;
|
|
65
|
+
|
|
66
|
+
/** Redact secret-shaped values and cap runaway strings before disk. */
|
|
67
|
+
function scrub(value: unknown, depth: number): unknown {
|
|
68
|
+
if (value === null || value === undefined) return value;
|
|
69
|
+
if (value instanceof Error) {
|
|
70
|
+
return { name: value.name, message: value.message, stack: scrub(value.stack, depth) };
|
|
71
|
+
}
|
|
72
|
+
if (typeof value === "string") {
|
|
73
|
+
return value.length > MAX_STRING ? value.slice(0, MAX_STRING) + "…(truncated)" : value;
|
|
74
|
+
}
|
|
75
|
+
if (typeof value === "number" || typeof value === "boolean") return value;
|
|
76
|
+
if (depth >= MAX_DEPTH) return "(depth limit)";
|
|
77
|
+
if (Array.isArray(value)) return value.slice(0, 50).map((v) => scrub(v, depth + 1));
|
|
78
|
+
if (typeof value === "object") {
|
|
79
|
+
const out: Record<string, unknown> = {};
|
|
80
|
+
for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
|
|
81
|
+
out[k] = SECRET_KEY.test(k) ? "[redacted]" : scrub(v, depth + 1);
|
|
82
|
+
}
|
|
83
|
+
return out;
|
|
84
|
+
}
|
|
85
|
+
return String(value);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Matches the truthy set used across the extension's env parsing. */
|
|
89
|
+
function isEnvTruthy(v: string | undefined): boolean {
|
|
90
|
+
return v !== undefined && ["1", "true", "on"].includes(v.toLowerCase());
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function createDailyLogger(opts: DailyLoggerOptions): DailyLogger {
|
|
94
|
+
const dir = opts.dir;
|
|
95
|
+
const retentionDays = opts.retentionDays ?? 14;
|
|
96
|
+
const verbose = opts.debug ?? isEnvTruthy(process.env.AGY_DEBUG);
|
|
97
|
+
const now = opts.now ?? (() => new Date());
|
|
98
|
+
// Serialized write chain: keeps day-rotation (mkdir + prune) ordered
|
|
99
|
+
// ahead of the records that triggered it. Volume is lifecycle-events
|
|
100
|
+
// low, so chaining costs nothing.
|
|
101
|
+
let chain: Promise<void> = Promise.resolve();
|
|
102
|
+
let ensuredDay = "";
|
|
103
|
+
let pruned = false;
|
|
104
|
+
|
|
105
|
+
async function prune(): Promise<void> {
|
|
106
|
+
if (pruned) return;
|
|
107
|
+
pruned = true;
|
|
108
|
+
const cutoff = dayKey(new Date(now().getTime() - retentionDays * 86_400_000));
|
|
109
|
+
const names = await readdir(dir).catch(() => [] as string[]);
|
|
110
|
+
for (const name of names) {
|
|
111
|
+
if (!name.endsWith(".ndjson")) continue;
|
|
112
|
+
if (name.slice(0, 10) >= cutoff) continue;
|
|
113
|
+
await unlink(path.join(dir, name)).catch(() => {
|
|
114
|
+
/* in use or gone: keep the rest */
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function write(level: LogLevel, event: string, data: unknown): void {
|
|
120
|
+
const d = now();
|
|
121
|
+
const day = dayKey(d);
|
|
122
|
+
// Build the line outside the chain but guarded: scrub()/stringify run
|
|
123
|
+
// synchronously in log(), and a throwing getter in a future caller's
|
|
124
|
+
// data must not break the never-throw contract.
|
|
125
|
+
let line: string;
|
|
126
|
+
try {
|
|
127
|
+
const record: Record<string, unknown> = {
|
|
128
|
+
ts: d.toISOString(),
|
|
129
|
+
level,
|
|
130
|
+
event,
|
|
131
|
+
};
|
|
132
|
+
if (data !== undefined) record.data = scrub(data, 0);
|
|
133
|
+
line = JSON.stringify(record);
|
|
134
|
+
if (line.length > MAX_RECORD) {
|
|
135
|
+
record.data = `(record exceeded ${MAX_RECORD} bytes; payload dropped)`;
|
|
136
|
+
line = JSON.stringify(record);
|
|
137
|
+
}
|
|
138
|
+
line += "\n";
|
|
139
|
+
} catch {
|
|
140
|
+
line = `${JSON.stringify({ ts: d.toISOString(), level, event, data: "(unserializable)" })}\n`;
|
|
141
|
+
}
|
|
142
|
+
// Single chained task per record: an append failure retries ONCE inside
|
|
143
|
+
// the same task (day gate reset -> recursive mkdir rebuild -> append
|
|
144
|
+
// again). flush() therefore always waits past the retry, and a
|
|
145
|
+
// persistent failure costs at most two fs attempts, never a loop.
|
|
146
|
+
chain = chain.then(async () => {
|
|
147
|
+
try {
|
|
148
|
+
if (ensuredDay !== day) {
|
|
149
|
+
await mkdir(dir, { recursive: true });
|
|
150
|
+
ensuredDay = day;
|
|
151
|
+
await prune();
|
|
152
|
+
}
|
|
153
|
+
await appendFile(path.join(dir, `${day}.ndjson`), line, "utf8");
|
|
154
|
+
} catch {
|
|
155
|
+
try {
|
|
156
|
+
ensuredDay = "";
|
|
157
|
+
await mkdir(dir, { recursive: true });
|
|
158
|
+
ensuredDay = day;
|
|
159
|
+
await appendFile(path.join(dir, `${day}.ndjson`), line, "utf8");
|
|
160
|
+
} catch {
|
|
161
|
+
/* swallow: an unwritable dir must not disrupt the chat */
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return {
|
|
168
|
+
log(event, data, level = "debug") {
|
|
169
|
+
// Volume gate: debug is the verbose tier. Default installs write
|
|
170
|
+
// only info/warn/error so the disk cost stays negligible.
|
|
171
|
+
if (level === "debug" && !verbose) return;
|
|
172
|
+
write(level, event, data);
|
|
173
|
+
},
|
|
174
|
+
flush() {
|
|
175
|
+
return chain.catch(() => {});
|
|
176
|
+
},
|
|
177
|
+
dir,
|
|
178
|
+
todayPath() {
|
|
179
|
+
return path.join(dir, `${dayKey(now())}.ndjson`);
|
|
180
|
+
},
|
|
181
|
+
};
|
|
182
|
+
}
|
package/src/driver.ts
CHANGED
|
@@ -137,6 +137,9 @@ export class AgyDriver implements TurnDriver {
|
|
|
137
137
|
// it ate large tool frames and could hide the result frame of a turn.
|
|
138
138
|
#stdoutBuf = "";
|
|
139
139
|
#lifecycle: string[] = [];
|
|
140
|
+
/** Optional external lifecycle sink (the extension's daily file log).
|
|
141
|
+
* Fire-and-forget: the ring buffer stays the source for /agy doctor. */
|
|
142
|
+
log?: (msg: string, data?: unknown) => void;
|
|
140
143
|
#onTurnEnd: ((outcome: TurnOutcome) => void) | undefined;
|
|
141
144
|
#stats = {
|
|
142
145
|
spawns: 0,
|
|
@@ -227,6 +230,13 @@ export class AgyDriver implements TurnDriver {
|
|
|
227
230
|
this.#active = turn;
|
|
228
231
|
this.#state = "running";
|
|
229
232
|
this.#stats.turns += 1;
|
|
233
|
+
this.#log("turn-start", {
|
|
234
|
+
model: request.model,
|
|
235
|
+
effort: request.effort,
|
|
236
|
+
mode: request.mode,
|
|
237
|
+
conversation: request.conversationId ?? null,
|
|
238
|
+
images: request.images?.length ?? 0,
|
|
239
|
+
});
|
|
230
240
|
this.#armTimers(turn);
|
|
231
241
|
|
|
232
242
|
const line = `${JSON.stringify({
|
|
@@ -628,8 +638,10 @@ export class AgyDriver implements TurnDriver {
|
|
|
628
638
|
}
|
|
629
639
|
}
|
|
630
640
|
|
|
631
|
-
#log(msg: string): void {
|
|
632
|
-
|
|
641
|
+
#log(msg: string, data?: unknown): void {
|
|
642
|
+
const line = `${nowIso()} ${msg}${data !== undefined ? ` ${JSON.stringify(data)}` : ""}`;
|
|
643
|
+
this.#lifecycle.push(line);
|
|
633
644
|
if (this.#lifecycle.length > LIFECYCLE_LIMIT) this.#lifecycle.shift();
|
|
645
|
+
this.log?.(msg, data);
|
|
634
646
|
}
|
|
635
647
|
}
|
package/src/mcp-server.ts
CHANGED
|
@@ -256,6 +256,14 @@ export async function startMcpServer(
|
|
|
256
256
|
|
|
257
257
|
const callHandler = async (request: { params: { name: string; arguments?: unknown } }, signal?: AbortSignal) => {
|
|
258
258
|
const { name, arguments: args } = request.params;
|
|
259
|
+
// Progress probe: agy's MCP client killed long bridge calls at exactly
|
|
260
|
+
// ~180s (see ACP-PROTOCOL-REFERENCE). If its requests ever carry a
|
|
261
|
+
// progressToken, MCP progress notifications become a testable zero-UX
|
|
262
|
+
// fix for that deadline; log presence to find out.
|
|
263
|
+
const meta = (request.params as { _meta?: { progressToken?: unknown } })._meta;
|
|
264
|
+
if (meta && meta.progressToken !== undefined) {
|
|
265
|
+
log("progress-token", { name, token: String(meta.progressToken) });
|
|
266
|
+
}
|
|
259
267
|
const callId = crypto.randomUUID();
|
|
260
268
|
log("call-tool", { name, callId });
|
|
261
269
|
try {
|
package/src/provider.ts
CHANGED
|
@@ -119,7 +119,7 @@ export const SYSTEM_PROMPT_END = "[END SYSTEM PROMPT]";
|
|
|
119
119
|
* and the question never displayed. Rides the systemPrompt gate: the note
|
|
120
120
|
* ships only when the system prompt ships. */
|
|
121
121
|
export const TOOL_PRIORITY_NOTE =
|
|
122
|
-
"[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question.]";
|
|
122
|
+
"[Tool priority: this conversation runs inside pi, not as a standalone agy session; the user only sees what surfaces in pi. Native interactive tools, for example ask_question, never reach the user. When a Pi Bridge tool covers the same purpose, always use the Pi Bridge tool; for user questions use ask_user_question. Long-running bridge calls do not fail: after ~20 seconds the bridge answers STILL RUNNING with a callId; fetch the result with bridge_poll_result and poll until it lands. For work you already know is long, prefer exec_command's session-output pattern or background agents so you keep working while it runs.]";
|
|
123
123
|
|
|
124
124
|
/** Assemble the full agy prompt: system prompt block, pi-side digest, user
|
|
125
125
|
* prompt. Empty parts are dropped. Pure; exported for unit testing.
|
|
@@ -329,6 +329,9 @@ export interface StreamSimpleDeps {
|
|
|
329
329
|
* read decides (tests); production wiring always passes it so a
|
|
330
330
|
* mid-session config flip cannot move one side of a parked turn. */
|
|
331
331
|
engine?: "stream-json" | "acp";
|
|
332
|
+
/** Daily file log sink (src/daily-log.ts). Records pre-dispatch turn
|
|
333
|
+
* errors that never create a driver turn (and so never reach onTurnEnd). */
|
|
334
|
+
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
332
335
|
}
|
|
333
336
|
|
|
334
337
|
/** pi thinking-effort order mirrors agy's, for clamping. */
|
|
@@ -382,21 +385,138 @@ export function toAgyEffort(
|
|
|
382
385
|
// agy continues its still-running turn. No pi patch, no privileged API.
|
|
383
386
|
|
|
384
387
|
const BRIDGE_TIMEOUT_MS = 480_000;
|
|
388
|
+
/** Bounded memory of failed bridge parks (late-delivery tombstones). */
|
|
389
|
+
const MAX_PARK_TOMBSTONES = 64;
|
|
385
390
|
|
|
386
391
|
export interface BridgeCallResultShape {
|
|
387
392
|
content: Array<{ type: string; text?: string }>;
|
|
388
393
|
isError: boolean;
|
|
389
394
|
}
|
|
390
395
|
|
|
396
|
+
/** Early-ack sentinel: onToolCall settles with this when the pi tool is still
|
|
397
|
+
* running after escalateAfterMs (~20s). agy's MCP client abandons a
|
|
398
|
+
* tools/call HTTP request at ~180s (observed; see ACP-PROTOCOL-REFERENCE), so
|
|
399
|
+
* slow calls must not hold the request. The bridge answers with
|
|
400
|
+
* formatEscalatedAck and the real result arrives via bridge_poll_result (or,
|
|
401
|
+
* if agy never polls, the late-delivery path). */
|
|
402
|
+
export interface BridgeEscalation {
|
|
403
|
+
escalated: true;
|
|
404
|
+
callId: string;
|
|
405
|
+
name: string;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
export const POLL_TOOL_NAME = "bridge_poll_result";
|
|
409
|
+
|
|
410
|
+
/** Default quiet period before a park escalates to a poll handle. Well under
|
|
411
|
+
* agy's ~180s request deadline; fast tools never see it. */
|
|
412
|
+
export const ESCALATE_AFTER_MS = 20_000;
|
|
413
|
+
/** Escalated parks carry a longer TTL: human-gated tools (commit previews,
|
|
414
|
+
* permission dialogs) legitimately block for many minutes. */
|
|
415
|
+
export const ESCALATED_TIMEOUT_MS = 1_800_000;
|
|
416
|
+
|
|
417
|
+
export interface PollView {
|
|
418
|
+
state: "running" | "done" | "failed";
|
|
419
|
+
name: string;
|
|
420
|
+
text?: string;
|
|
421
|
+
isError?: boolean;
|
|
422
|
+
reason?: string;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/** Escalated bridge calls. Bounded: past the cap, oldest settled entries
|
|
426
|
+
* evict first (a running call is never evicted while a newer one is). */
|
|
427
|
+
export class EscalationRegistry {
|
|
428
|
+
#calls = new Map<string, PollView>();
|
|
429
|
+
#trim(): void {
|
|
430
|
+
// Soft cap: only settled entries evict. Evicting a RUNNING call would
|
|
431
|
+
// strand its result (settle becomes a no-op, poll reports unknown), so
|
|
432
|
+
// saturating the cap with in-flight calls grows the map instead.
|
|
433
|
+
while (this.#calls.size > MAX_PARK_TOMBSTONES) {
|
|
434
|
+
const victim = [...this.#calls.entries()].find(([, e]) => e.state !== "running")?.[0];
|
|
435
|
+
if (victim === undefined) break;
|
|
436
|
+
this.#calls.delete(victim);
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
escalate(callId: string, name: string): void {
|
|
440
|
+
this.#calls.set(callId, { name, state: "running" });
|
|
441
|
+
this.#trim();
|
|
442
|
+
}
|
|
443
|
+
settleDone(callId: string, text: string, isError: boolean): void {
|
|
444
|
+
const e = this.#calls.get(callId);
|
|
445
|
+
if (!e) return;
|
|
446
|
+
e.state = "done";
|
|
447
|
+
e.text = text;
|
|
448
|
+
e.isError = isError;
|
|
449
|
+
this.#trim();
|
|
450
|
+
}
|
|
451
|
+
settleFailed(callId: string, reason: string): void {
|
|
452
|
+
const e = this.#calls.get(callId);
|
|
453
|
+
if (!e) return;
|
|
454
|
+
e.state = "failed";
|
|
455
|
+
e.reason = reason;
|
|
456
|
+
this.#trim();
|
|
457
|
+
}
|
|
458
|
+
poll(callId: string): PollView | undefined {
|
|
459
|
+
const e = this.#calls.get(callId);
|
|
460
|
+
return e ? { ...e } : undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
|
|
465
|
+
return {
|
|
466
|
+
content: [
|
|
467
|
+
{
|
|
468
|
+
type: "text",
|
|
469
|
+
text: [
|
|
470
|
+
`STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
|
|
471
|
+
`Call ${POLL_TOOL_NAME} with callId "${e.callId}" to get the result. Poll again if it still reports running; you may do other work between polls.`,
|
|
472
|
+
"This is not an error and nothing is lost: if you stop polling, the bridge re-delivers the result in a later turn.",
|
|
473
|
+
].join("\n"),
|
|
474
|
+
},
|
|
475
|
+
],
|
|
476
|
+
isError: false,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
export function formatPollAnswer(callId: string, view: PollView | undefined): BridgeCallResultShape {
|
|
481
|
+
if (!view) {
|
|
482
|
+
return {
|
|
483
|
+
content: [
|
|
484
|
+
{
|
|
485
|
+
type: "text",
|
|
486
|
+
text: `Error: no escalated bridge call "${callId}". It either finished within the first seconds (its result is in your original tool result) or the callId is wrong.`,
|
|
487
|
+
},
|
|
488
|
+
],
|
|
489
|
+
isError: true,
|
|
490
|
+
};
|
|
491
|
+
}
|
|
492
|
+
if (view.state === "running") {
|
|
493
|
+
return {
|
|
494
|
+
content: [{ type: "text", text: `STILL RUNNING: "${view.name}" (callId ${callId}) has not finished. Poll again later.` }],
|
|
495
|
+
isError: false,
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
if (view.state === "failed") {
|
|
499
|
+
return {
|
|
500
|
+
content: [{ type: "text", text: `Error: bridge call "${view.name}" (callId ${callId}) failed: ${view.reason}` }],
|
|
501
|
+
isError: true,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
return { content: [{ type: "text", text: view.text || "(no output)" }], isError: view.isError ?? false };
|
|
505
|
+
}
|
|
506
|
+
|
|
391
507
|
interface PendingRoundTrip {
|
|
392
508
|
/** "bridge": parked MCP HTTP call; resolve() completes it.
|
|
393
509
|
* "rt": native re-exec / wrapper round-trip; pi already executed, the
|
|
394
510
|
* toolResult only confirms continuation, nothing remote to settle. */
|
|
395
511
|
kind: "bridge" | "rt";
|
|
396
512
|
name: string;
|
|
397
|
-
resolve?: (r: BridgeCallResultShape) => void;
|
|
513
|
+
resolve?: (r: BridgeCallResultShape | BridgeEscalation) => void;
|
|
398
514
|
reject?: (e: Error) => void;
|
|
399
515
|
timer?: NodeJS.Timeout;
|
|
516
|
+
/** Set when the early-ack fired: the HTTP request was answered with a poll
|
|
517
|
+
* handle, so the settling value must go to the registry, not the socket. */
|
|
518
|
+
escalated?: boolean;
|
|
519
|
+
escalateTimer?: NodeJS.Timeout;
|
|
400
520
|
onAbort?: () => void;
|
|
401
521
|
signal?: AbortSignal;
|
|
402
522
|
}
|
|
@@ -427,23 +547,61 @@ export class WrapperReplay {
|
|
|
427
547
|
|
|
428
548
|
export class ToolRoundTrips {
|
|
429
549
|
#pending = new Map<string, PendingRoundTrip>();
|
|
550
|
+
/** Failed bridge parks: the pi tool keeps running and its toolResult will
|
|
551
|
+
* arrive with the park already gone. Bounded; consumed by the
|
|
552
|
+
* late-delivery path (see buildLateResultPrompt). */
|
|
553
|
+
#dead = new Map<string, { name: string; reason: string }>();
|
|
554
|
+
#escalations = new EscalationRegistry();
|
|
555
|
+
#escalateAfterMs: number;
|
|
430
556
|
#getDriver: () => TurnDriver;
|
|
431
557
|
#log: (s: string, d?: unknown) => void;
|
|
432
558
|
|
|
433
559
|
/** Accepts a driver or a getter: with two engines wired, the ACTIVE driver
|
|
434
560
|
* is resolved at call time from config (plan §9.5). */
|
|
435
|
-
constructor(
|
|
561
|
+
constructor(
|
|
562
|
+
driver: TurnDriver | (() => TurnDriver),
|
|
563
|
+
log?: (s: string, d?: unknown) => void,
|
|
564
|
+
opts: { escalateAfterMs?: number } = {},
|
|
565
|
+
) {
|
|
436
566
|
this.#getDriver = typeof driver === "function" ? driver : () => driver;
|
|
437
567
|
this.#log = log ?? (() => {});
|
|
568
|
+
this.#escalateAfterMs = opts.escalateAfterMs ?? ESCALATE_AFTER_MS;
|
|
438
569
|
}
|
|
439
570
|
|
|
440
571
|
get pendingIds(): string[] {
|
|
441
572
|
return [...this.#pending.keys()];
|
|
442
573
|
}
|
|
443
574
|
|
|
444
|
-
/**
|
|
575
|
+
/** Call ids whose park already failed (tombstones). */
|
|
576
|
+
get deadIds(): string[] {
|
|
577
|
+
return [...this.#dead.keys()];
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
/** Take and clear the tombstone for a failed park, if any. */
|
|
581
|
+
consumeDead(toolCallId: string): { name: string; reason: string } | undefined {
|
|
582
|
+
const dead = this.#dead.get(toolCallId);
|
|
583
|
+
if (!dead) return undefined;
|
|
584
|
+
this.#dead.delete(toolCallId);
|
|
585
|
+
return dead;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/** Poll view for an escalated call (undefined when the id never escalated:
|
|
589
|
+
* fast calls settle synchronously and need no handle). */
|
|
590
|
+
poll(callId: string): PollView | undefined {
|
|
591
|
+
return this.#escalations.poll(callId);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
/** Fail all pending calls (driver recycle/shutdown path). Escalated bridge
|
|
595
|
+
* calls are skipped: their HTTP request was already answered with a poll
|
|
596
|
+
* handle, and the agy turn ending does NOT make the still-running pi tool
|
|
597
|
+
* a failure. They settle through resolve(), their own 30m timer, or an
|
|
598
|
+
* abort signal on the pi tool call. */
|
|
445
599
|
failAll(reason: string): void {
|
|
446
|
-
for (const id of [...this.#pending.keys()])
|
|
600
|
+
for (const id of [...this.#pending.keys()]) {
|
|
601
|
+
const entry = this.#pending.get(id);
|
|
602
|
+
if (entry?.kind === "bridge" && entry.escalated) continue;
|
|
603
|
+
this.#fail(id, reason);
|
|
604
|
+
}
|
|
447
605
|
}
|
|
448
606
|
|
|
449
607
|
#fail(callId: string, reason: string): void {
|
|
@@ -456,20 +614,33 @@ export class ToolRoundTrips {
|
|
|
456
614
|
}
|
|
457
615
|
this.#pending.delete(callId);
|
|
458
616
|
clearTimeout(entry.timer);
|
|
617
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
459
618
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
619
|
+
this.#dead.set(callId, { name: entry.name, reason });
|
|
620
|
+
while (this.#dead.size > MAX_PARK_TOMBSTONES) {
|
|
621
|
+
const oldest = this.#dead.keys().next().value;
|
|
622
|
+
if (oldest === undefined) break;
|
|
623
|
+
this.#dead.delete(oldest);
|
|
624
|
+
}
|
|
625
|
+
if (entry.escalated) this.#escalations.settleFailed(callId, reason);
|
|
460
626
|
entry.reject!(new Error(reason));
|
|
461
627
|
this.#getDriver().kickIdle();
|
|
462
628
|
this.#log("round-trip-fail", { callId, name: entry.name, reason });
|
|
463
629
|
}
|
|
464
630
|
|
|
465
|
-
/** Park the MCP call: inject into the live agy turn
|
|
466
|
-
*
|
|
631
|
+
/** Park the MCP call: inject into the live agy turn. Fast calls settle
|
|
632
|
+
* with the real BridgeCallResultShape. Calls still running after
|
|
633
|
+
* escalateAfterMs settle with a BridgeEscalation sentinel instead: the
|
|
634
|
+
* bridge answers the HTTP request with a poll handle while pi keeps
|
|
635
|
+
* executing, so agy's ~180s request deadline is never hit. The real
|
|
636
|
+
* result reaches agy via bridge_poll_result, or via the late-delivery
|
|
637
|
+
* path if agy never polls. Fail-closed: timeout/abort still reject. */
|
|
467
638
|
onToolCall = (
|
|
468
639
|
callId: string,
|
|
469
640
|
name: string,
|
|
470
641
|
args: Record<string, unknown>,
|
|
471
642
|
signal: AbortSignal,
|
|
472
|
-
): Promise<BridgeCallResultShape> => {
|
|
643
|
+
): Promise<BridgeCallResultShape | BridgeEscalation> => {
|
|
473
644
|
const handle = this.#getDriver().activeHandle;
|
|
474
645
|
if (!handle) {
|
|
475
646
|
return Promise.reject(
|
|
@@ -478,13 +649,32 @@ export class ToolRoundTrips {
|
|
|
478
649
|
),
|
|
479
650
|
);
|
|
480
651
|
}
|
|
481
|
-
return new Promise<BridgeCallResultShape>((resolve, reject) => {
|
|
652
|
+
return new Promise<BridgeCallResultShape | BridgeEscalation>((resolve, reject) => {
|
|
482
653
|
const timer = setTimeout(() => {
|
|
483
654
|
this.#fail(callId, `pi tool round-trip timed out after ${BRIDGE_TIMEOUT_MS / 1000}s`);
|
|
484
655
|
}, BRIDGE_TIMEOUT_MS);
|
|
485
656
|
const onAbort = () => this.#fail(callId, "agy disconnected before the tool result arrived");
|
|
486
657
|
signal.addEventListener("abort", onAbort, { once: true });
|
|
487
|
-
|
|
658
|
+
const entry: PendingRoundTrip = { kind: "bridge", name, resolve, reject, timer, onAbort, signal };
|
|
659
|
+
if (this.#escalateAfterMs > 0) {
|
|
660
|
+
entry.escalateTimer = setTimeout(() => {
|
|
661
|
+
const e = this.#pending.get(callId);
|
|
662
|
+
// Resolved (or failed) between arm and fire: nothing to escalate.
|
|
663
|
+
if (!e || e.kind !== "bridge") return;
|
|
664
|
+
e.escalated = true;
|
|
665
|
+
// Human-gated calls (commit previews, permission dialogs) can
|
|
666
|
+
// block far longer than the standard park TTL; re-arm generously.
|
|
667
|
+
if (e.timer) {
|
|
668
|
+
clearTimeout(e.timer);
|
|
669
|
+
e.timer = setTimeout(() => {
|
|
670
|
+
this.#fail(callId, `escalated bridge call timed out after ${ESCALATED_TIMEOUT_MS / 60_000} minutes`);
|
|
671
|
+
}, ESCALATED_TIMEOUT_MS);
|
|
672
|
+
}
|
|
673
|
+
this.#escalations.escalate(callId, e.name);
|
|
674
|
+
resolve({ escalated: true, callId, name: e.name });
|
|
675
|
+
}, this.#escalateAfterMs);
|
|
676
|
+
}
|
|
677
|
+
this.#pending.set(callId, entry);
|
|
488
678
|
handle.pushExternal({ type: "bridge_call", callId, name, args });
|
|
489
679
|
});
|
|
490
680
|
};
|
|
@@ -502,12 +692,21 @@ export class ToolRoundTrips {
|
|
|
502
692
|
if (!entry) return false;
|
|
503
693
|
this.#pending.delete(toolCallId);
|
|
504
694
|
clearTimeout(entry.timer);
|
|
695
|
+
if (entry.escalateTimer) clearTimeout(entry.escalateTimer);
|
|
505
696
|
if (entry.onAbort && entry.signal) entry.signal.removeEventListener("abort", entry.onAbort);
|
|
506
697
|
if (entry.kind === "rt") {
|
|
507
698
|
this.#log("round-trip-rt-done", { callId: toolCallId, name: entry.name, isError });
|
|
508
699
|
return true;
|
|
509
700
|
}
|
|
510
|
-
|
|
701
|
+
// Escalated call: the HTTP response already carried the poll handle, so
|
|
702
|
+
// the result lands in the registry for the next bridge_poll_result. The
|
|
703
|
+
// original promise settled with the sentinel; re-resolving is a silent
|
|
704
|
+
// no-op, so gate it to keep that explicit.
|
|
705
|
+
if (entry.escalated) {
|
|
706
|
+
this.#escalations.settleDone(toolCallId, text, isError);
|
|
707
|
+
} else {
|
|
708
|
+
entry.resolve!({ content: [{ type: "text", text }], isError });
|
|
709
|
+
}
|
|
511
710
|
this.#getDriver().kickIdle();
|
|
512
711
|
this.#log("round-trip-resolved", { callId: toolCallId, name: entry.name, isError });
|
|
513
712
|
return true;
|
|
@@ -531,6 +730,29 @@ export function collectToolResults(
|
|
|
531
730
|
return out;
|
|
532
731
|
}
|
|
533
732
|
|
|
733
|
+
export interface LateToolResult {
|
|
734
|
+
name: string;
|
|
735
|
+
reason: string;
|
|
736
|
+
text: string;
|
|
737
|
+
isError: boolean;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
/** Frame late tool results so agy treats them as the results its bridge calls
|
|
741
|
+
* never received (the round-trip died while the pi tool was still running,
|
|
742
|
+
* e.g. agy's ~180s MCP client timeout on tools/call). */
|
|
743
|
+
export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
|
|
744
|
+
const blocks = late.map((r) =>
|
|
745
|
+
[
|
|
746
|
+
`pi tool "${r.name}": the bridge round-trip expired before this result reached you (${r.reason}).`,
|
|
747
|
+
r.isError ? "The tool reported an error:" : "Result:",
|
|
748
|
+
r.text.trim() || "(no output)",
|
|
749
|
+
].join("\n"),
|
|
750
|
+
);
|
|
751
|
+
const header = "Late tool delivery: treat the following as the results of your earlier tool calls.";
|
|
752
|
+
const body = [header, ...blocks].join("\n\n");
|
|
753
|
+
return userPrompt ? `${body}\n\n${userPrompt}` : body;
|
|
754
|
+
}
|
|
755
|
+
|
|
534
756
|
// --- stream-json engine -------------------------------------------------------
|
|
535
757
|
|
|
536
758
|
export interface DriverDeps {
|
|
@@ -540,6 +762,8 @@ export interface DriverDeps {
|
|
|
540
762
|
nativeActive?: (name: string) => boolean;
|
|
541
763
|
/** Active engine (config), for engine-scoped session keys. */
|
|
542
764
|
engine: "stream-json" | "acp";
|
|
765
|
+
/** Daily file log sink for pre-dispatch errors (see StreamSimpleDeps). */
|
|
766
|
+
log?: (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
543
767
|
}
|
|
544
768
|
|
|
545
769
|
/** Map one DriverActivity onto the open pi stream. Returns "parked" when the
|
|
@@ -734,12 +958,48 @@ async function runTurnDriver(
|
|
|
734
958
|
// agy receives the result via the bridge's MCP HTTP response.
|
|
735
959
|
const results = collectToolResults(context.messages, deps.roundTrips.pendingIds);
|
|
736
960
|
const isContinuation = results.length > 0;
|
|
961
|
+
// Escalated calls answer through bridge_poll_result, not through an agy
|
|
962
|
+
// turn waiting on the park, so note them before resolving.
|
|
963
|
+
const escalatedNames = results
|
|
964
|
+
.map((r) => deps.roundTrips.poll(r.toolCallId)?.name)
|
|
965
|
+
.filter((n): n is string => Boolean(n));
|
|
737
966
|
for (const r of results) deps.roundTrips.resolve(r.toolCallId, r.text, r.isError);
|
|
738
967
|
|
|
968
|
+
// Late delivery: a toolResult whose park already failed (the abort/timeout
|
|
969
|
+
// path failed the park while the pi tool kept running). The work is done,
|
|
970
|
+
// so re-route the result to agy as a new prompt in the same conversation
|
|
971
|
+
// instead of dropping it. Both drivers serialize run(), so delivery queues
|
|
972
|
+
// behind agy's own salvaged turn when one is still active.
|
|
973
|
+
// A pass that anchors a still-pending park (isContinuation) has nowhere to
|
|
974
|
+
// put a late result: it can neither ride the pending call's HTTP response
|
|
975
|
+
// nor start a new prompt. Leave the tombstone for the next fresh pass
|
|
976
|
+
// instead of consuming it blind.
|
|
977
|
+
const late: LateToolResult[] = [];
|
|
978
|
+
if (!isContinuation) {
|
|
979
|
+
for (const r of collectToolResults(context.messages, deps.roundTrips.deadIds)) {
|
|
980
|
+
const dead = deps.roundTrips.consumeDead(r.toolCallId);
|
|
981
|
+
if (dead) late.push({ name: dead.name, reason: dead.reason, text: r.text, isError: r.isError });
|
|
982
|
+
}
|
|
983
|
+
if (late.length > 0) {
|
|
984
|
+
deps.log?.("late-result", { tools: late.map((l) => l.name), freshConversation: !existing?.conversationId }, "info");
|
|
985
|
+
}
|
|
986
|
+
} else if (deps.roundTrips.deadIds.length > 0) {
|
|
987
|
+
deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
|
|
988
|
+
}
|
|
989
|
+
|
|
739
990
|
let handle: TurnHandle;
|
|
740
991
|
if (isContinuation) {
|
|
741
992
|
const active = deps.driver.reentry();
|
|
742
993
|
if (!active) {
|
|
994
|
+
// Escalated calls have no turn to re-enter BY DESIGN: agy already
|
|
995
|
+
// got the poll handle and the result lives in the registry. Settle
|
|
996
|
+
// quietly instead of erroring the turn.
|
|
997
|
+
if (escalatedNames.length > 0) {
|
|
998
|
+
appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
|
|
999
|
+
finalize(stream, blocks, "stop");
|
|
1000
|
+
return;
|
|
1001
|
+
}
|
|
1002
|
+
deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
|
|
743
1003
|
finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
|
|
744
1004
|
return;
|
|
745
1005
|
}
|
|
@@ -748,8 +1008,10 @@ async function runTurnDriver(
|
|
|
748
1008
|
const prompt = extractUserPrompt(context);
|
|
749
1009
|
const images = extractImages(context);
|
|
750
1010
|
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
751
|
-
// when there is nothing at all to send
|
|
752
|
-
|
|
1011
|
+
// when there is nothing at all to send (no text, no images, no late
|
|
1012
|
+
// tool results to deliver).
|
|
1013
|
+
if (!prompt && images.length === 0 && late.length === 0) {
|
|
1014
|
+
deps.log?.("turn-error", { reason: "no-user-message" }, "debug");
|
|
753
1015
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
754
1016
|
return;
|
|
755
1017
|
}
|
|
@@ -757,7 +1019,9 @@ async function runTurnDriver(
|
|
|
757
1019
|
const agyModel = entry?.full ?? model.id;
|
|
758
1020
|
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
759
1021
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
760
|
-
|
|
1022
|
+
// Late turns re-open the conversation with a synthetic prompt; the digest
|
|
1023
|
+
// would re-send context agy already holds, so skip it.
|
|
1024
|
+
const digest = config.digest && late.length === 0 ? buildContextDigest(context.messages, watermark) : "";
|
|
761
1025
|
// G1 delivery per engine. stream-json: digest rides inline in the prompt
|
|
762
1026
|
// (the CLI has no context channel). ACP: the server advertises
|
|
763
1027
|
// `embeddedContext`, so the digest ships as a native resource block
|
|
@@ -771,7 +1035,10 @@ async function runTurnDriver(
|
|
|
771
1035
|
// re-sending it every turn would bloat each prompt and bust the cache.
|
|
772
1036
|
const sysPrompt =
|
|
773
1037
|
config.systemPrompt && !existing?.conversationId ? context.systemPrompt : undefined;
|
|
774
|
-
const fullPrompt =
|
|
1038
|
+
const fullPrompt =
|
|
1039
|
+
late.length > 0
|
|
1040
|
+
? buildLateResultPrompt(late, prompt || undefined)
|
|
1041
|
+
: buildFullPrompt(sysPrompt, embeddedDigest ? "" : digest, prompt ?? "");
|
|
775
1042
|
try {
|
|
776
1043
|
handle = await deps.driver.run({
|
|
777
1044
|
cwd,
|
|
@@ -792,6 +1059,7 @@ async function runTurnDriver(
|
|
|
792
1059
|
});
|
|
793
1060
|
} catch (err) {
|
|
794
1061
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1062
|
+
deps.log?.("turn-error", { reason: "driver-start-failed", error: msg }, "error");
|
|
795
1063
|
finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
|
|
796
1064
|
return;
|
|
797
1065
|
}
|
|
@@ -860,6 +1128,7 @@ export function createStreamSimple(
|
|
|
860
1128
|
if (selected === deps.acpDriver && config.mode === "plan") {
|
|
861
1129
|
const partial = newAssistant(model);
|
|
862
1130
|
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
1131
|
+
deps.log?.("turn-error", { reason: "acp-plan-refused" }, "warn");
|
|
863
1132
|
finalize(stream, blocks, "error", "ACP engine has no plan mode. /agy mode accept-edits, or /agy engine stream-json.");
|
|
864
1133
|
return stream;
|
|
865
1134
|
}
|
|
@@ -874,12 +1143,14 @@ export function createStreamSimple(
|
|
|
874
1143
|
// and keying the session as @acp would store a legacy
|
|
875
1144
|
// conversationId under the wrong engine scope.
|
|
876
1145
|
engine: selected === deps.acpDriver ? "acp" : "stream-json",
|
|
1146
|
+
log: deps.log,
|
|
877
1147
|
});
|
|
878
1148
|
} else {
|
|
879
1149
|
// Miswired extension: no driver means no engine. Fail the turn visibly
|
|
880
1150
|
// instead of silently producing an empty assistant message.
|
|
881
1151
|
const partial = newAssistant(model);
|
|
882
1152
|
const blocks: BlockState = { partial, textIdx: null, thinkingIdx: null, started: false };
|
|
1153
|
+
deps.log?.("turn-error", { reason: "driver-not-configured" }, "warn");
|
|
883
1154
|
finalize(stream, blocks, "error", "antigravity driver not configured");
|
|
884
1155
|
}
|
|
885
1156
|
return stream;
|