@estebanforge/pi-antigravity-bridge 1.7.10 → 1.7.12
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 +18 -0
- package/extensions/index.ts +13 -1
- package/package.json +2 -1
- package/src/ask-tool.ts +69 -346
- package/src/provider.ts +210 -29
- package/src/discovery.ts +0 -199
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
## [1.7.12] - 2026-10-12
|
|
8
|
+
|
|
9
|
+
### Fixed
|
|
10
|
+
|
|
11
|
+
- **Unpolled escalated results are re-delivered instead of stranding.** Delivery of an escalated bridge call depends on the model polling `bridge_poll_result`, and the model sometimes ends its turn after the `STILL RUNNING` ack instead — observed live with `ask_user_question`: the user's answer settled into the escalation registry, the bridge printed a human-only `[bridge] ... available via bridge_poll_result` note, and the conversation dead-ended (`git_commit` worked only because the model happened to poll). The ack itself licensed stopping with a promise the bridge did not implement ("if you stop polling, the bridge re-delivers the result in a later turn"). Three changes close it: the ack now forbids ending the turn while a call is unresolved; the escalation registry tracks delivery (`markDelivered` when a poll returns a terminal answer, `stampConversation` at turn end so first-turn parks — which return before the provider's `store.set` — still know their conversation, first stamp wins) and exposes a single-use registry-wide sweep for done-but-undelivered results; and the provider runs that sweep at every turn boundary — the toolResult pass when the turn is dead, after a live turn's outcome, and any later pass, so a strand cannot outlive the next one — re-delivering as a late prompt in the stamped conversation through the existing `buildLateResultPrompt` path. A result whose conversation is unknowable settles quietly and stays pollable instead of prompting a brand-new conversation, and a delivery whose `driver.run` fails to start releases its claim for the next pass. Claims are single-use and entries stay pollable, so a compliant model sees no change; late delivery stays text-only on this path (image blocks are dropped, same as dead-park late delivery).
|
|
12
|
+
|
|
13
|
+
## [1.7.11] - 2026-10-10
|
|
14
|
+
|
|
15
|
+
### Added
|
|
16
|
+
|
|
17
|
+
- AskAntigravity prompts now carry a `[TIME BUDGET]` notice matching the resolved timeout (default 10 minutes), placed before the plan-mode guard banners so the safety constraints stay the last text the model reads. `timeoutMinutes` values that cannot arm a real timer (non-positive, non-finite, above the 35791-minute ceiling) are rejected with a clear error instead of arming an instant-kill watchdog.
|
|
18
|
+
|
|
19
|
+
### Changed
|
|
20
|
+
|
|
21
|
+
- **AskAntigravity dedup onto `pi-ask-shared` (behavior-preserving).** The tool's copy of the agy model grammar, tiered alias resolution, plan-mode guard prompts, prompt assembly, and argv construction now imports the shared peer adapter (new dependency `@estebanforge/pi-ask-shared`); `src/discovery.ts` is deleted — its conversation-bind technique (snapshot/diff + /proc FD scan, including the `onAmbiguous` hook, now upstream in the shared adapter) was the last consumer. The process block is deliberately kept bridge-local: it settles on close AND exit, leaves stdout/stderr uncapped, and threads the MCP-entry suppression release into cleanup — all three diverge from the shared `runProcess` contract on purpose. Shared `newConversationId` gained the `onAmbiguous` callback and exports `procTreeOpenDbResolver` (both absorbed from discovery.ts; `conversationDbPath` was unused and died with the module).
|
|
22
|
+
|
|
5
23
|
## [1.7.10] - 2026-10-07
|
|
6
24
|
|
|
7
25
|
### Added
|
package/extensions/index.ts
CHANGED
|
@@ -434,6 +434,10 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
434
434
|
outcome.status === "OK" ? "info" : "warn",
|
|
435
435
|
);
|
|
436
436
|
roundTrips.failAll(FAIL_REASON_TURN_END);
|
|
437
|
+
// Stamp live escalations with the ended turn's conversation: the park
|
|
438
|
+
// pass returns before the provider's store.set, so a first-turn park
|
|
439
|
+
// has no other record of where its result must be re-delivered.
|
|
440
|
+
if (outcome.conversationId) roundTrips.stampConversation(outcome.conversationId);
|
|
437
441
|
};
|
|
438
442
|
streamDriver.onTurnEnd = onTurnEnd;
|
|
439
443
|
acpDriver.onTurnEnd = onTurnEnd;
|
|
@@ -817,7 +821,15 @@ export default async function (pi: ExtensionAPI): Promise<void> {
|
|
|
817
821
|
if (name === POLL_TOOL_NAME) {
|
|
818
822
|
const wanted = typeof args.callId === "string" ? args.callId : "";
|
|
819
823
|
mcpLog("poll-tool", { callId: wanted });
|
|
820
|
-
|
|
824
|
+
const view = roundTrips.poll(wanted);
|
|
825
|
+
// A terminal answer means the model RECEIVED the result; mark it
|
|
826
|
+
// so the redelivery path never double-delivers. poll() itself
|
|
827
|
+
// stays pure (internal peeks must not mark). Accepted gap: the
|
|
828
|
+
// mark fires when the poll ANSWERS, not when the HTTP response
|
|
829
|
+
// lands; a drop inside that one-write window would mark a result
|
|
830
|
+
// the model never saw.
|
|
831
|
+
if (view && view.state !== "running") roundTrips.markDelivered(wanted);
|
|
832
|
+
return Promise.resolve(formatPollAnswer(wanted, view));
|
|
821
833
|
}
|
|
822
834
|
if (name !== ACTIVATE_SKILL_TOOL_NAME) {
|
|
823
835
|
// A cached MCP catalog is not authorization. An inactive/hidden tool
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@estebanforge/pi-antigravity-bridge",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.12",
|
|
4
4
|
"description": "Gemini provider for Pi on the Antigravity ACP server (official Google ACP) or the stream-json agy CLI. Use antigravity/* models in Pi. Full /model picker support. Models runs Pi's tools. ToS safe to use.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
|
@@ -54,6 +54,7 @@
|
|
|
54
54
|
}
|
|
55
55
|
},
|
|
56
56
|
"dependencies": {
|
|
57
|
+
"@estebanforge/pi-ask-shared": "^0.1.2",
|
|
57
58
|
"@modelcontextprotocol/sdk": "^1.29.0"
|
|
58
59
|
}
|
|
59
60
|
}
|
package/src/ask-tool.ts
CHANGED
|
@@ -21,10 +21,21 @@ import { Text } from "@earendil-works/pi-tui";
|
|
|
21
21
|
import { contentText, type ThinkingLevel } from "@earendil-works/pi-ai";
|
|
22
22
|
import { Type } from "typebox";
|
|
23
23
|
import {
|
|
24
|
+
buildAgyArgs,
|
|
25
|
+
buildFinalPrompt,
|
|
26
|
+
buildTimeBudgetNotice,
|
|
27
|
+
MAX_TIMEOUT_MINUTES,
|
|
24
28
|
CONVERSATIONS_DIR,
|
|
29
|
+
CONV_ID_RE,
|
|
30
|
+
filterHiddenModels,
|
|
31
|
+
mergeCatalog,
|
|
25
32
|
newConversationId,
|
|
33
|
+
parseModelLine,
|
|
34
|
+
resolveAgyModel,
|
|
26
35
|
snapshotConversations,
|
|
27
|
-
|
|
36
|
+
type ModelEntry,
|
|
37
|
+
type Mode,
|
|
38
|
+
} from "@estebanforge/pi-ask-shared";
|
|
28
39
|
import { loadConfig, type AgyMode, type ThinkingTier } from "./config.js";
|
|
29
40
|
import { redactText } from "./redact.js";
|
|
30
41
|
import { acquireBridgeSuppression } from "./mcp-registration.js";
|
|
@@ -33,49 +44,6 @@ import { sweepStaleWebAgents, webAgentsRoot } from "./web-tools.js";
|
|
|
33
44
|
|
|
34
45
|
// --- Constants -------------------------------------------------------------
|
|
35
46
|
|
|
36
|
-
const DEFAULT_TIMEOUT_MIN = 10;
|
|
37
|
-
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
38
|
-
const STATUS_INTERVAL_MS = 1000;
|
|
39
|
-
const STATUS_TAIL_CHARS = 160;
|
|
40
|
-
|
|
41
|
-
// renderCall / renderResult preview limits (match pi-claude-bridge).
|
|
42
|
-
const PREVIEW_MAX_CHARS = 1000;
|
|
43
|
-
const PREVIEW_MAX_LINES = 6;
|
|
44
|
-
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
45
|
-
const DISCOVERY_POLL_MS = 100;
|
|
46
|
-
|
|
47
|
-
// Per-family fallback tier when none is specified and no config default.
|
|
48
|
-
const FAMILY_DEFAULT_TIER: Record<Family, ThinkingTier> = {
|
|
49
|
-
flash: "medium",
|
|
50
|
-
pro: "high",
|
|
51
|
-
other: "medium",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
|
-
const TIER_RANK: Record<ThinkingTier, number> = { low: 0, medium: 1, high: 2 };
|
|
55
|
-
|
|
56
|
-
// Static alias overlay for non-Gemini models agy may or may not surface.
|
|
57
|
-
// Live catalog entries win on case-insensitive full-string equality; the
|
|
58
|
-
// overlay resolves the alias when agy doesn't list it. Names are agy's stable
|
|
59
|
-
// slugs (the same ids `agy models` prints and `--model` accepts). Claude ships
|
|
60
|
-
// tiered like Gemini now, so the overlay carries the full tier spread.
|
|
61
|
-
const STATIC_ALIAS_OVERLAY: ReadonlyArray<ModelEntry> = [
|
|
62
|
-
{ full: "claude-sonnet-5-5-low", family: "other", version: null, tier: "low" },
|
|
63
|
-
{ full: "claude-sonnet-5-5-medium", family: "other", version: null, tier: "medium" },
|
|
64
|
-
{ full: "claude-sonnet-5-5-high", family: "other", version: null, tier: "high" },
|
|
65
|
-
{ full: "claude-opus-5-5-low", family: "other", version: null, tier: "low" },
|
|
66
|
-
{ full: "claude-opus-5-5-medium", family: "other", version: null, tier: "medium" },
|
|
67
|
-
{ full: "claude-opus-5-5-high", family: "other", version: null, tier: "high" },
|
|
68
|
-
];
|
|
69
|
-
const STATIC_SHORT_ALIAS: ReadonlyMap<string, string> = new Map([
|
|
70
|
-
["sonnet", "claude-sonnet-5-5"],
|
|
71
|
-
["opus", "claude-opus-5-5"],
|
|
72
|
-
]);
|
|
73
|
-
|
|
74
|
-
// agy conversation ids are UUID DB-stems. First char must be alphanumeric so a
|
|
75
|
-
// leading-dash value can't misbind on agy's arg parser; hyphens allowed in the
|
|
76
|
-
// body (real UUIDs contain them).
|
|
77
|
-
const CONV_ID_RE = /^[A-Za-z0-9][A-Za-z0-9-]{0,127}$/;
|
|
78
|
-
|
|
79
47
|
const AGY_DESCRIPTION = `Delegate a self-contained sub-task to Google Antigravity. agy is the CLI for Gemini, so this tool is reached under three equivalent names the user may use interchangeably: **gemini**, **antigravity**, and **agy**. When the user says "ask gemini", "ask antigravity", "ask agy", or otherwise refers to any of these, call THIS tool. agy runs its OWN tool loop: it can read, write, edit, and execute inside the workspace, then returns its final answer. Use for a second opinion from a different model family, Gemini-specific reasoning, or isolated sub-tasks you do not need to drive step-by-step. Provide a complete, self-contained task description; agy will not see this conversation.
|
|
80
48
|
|
|
81
49
|
TWO MODES (you choose):
|
|
@@ -93,223 +61,28 @@ THINKING LEVEL (params: thinking, effort - SYNONYMS for one knob):
|
|
|
93
61
|
- Values (pi vocabulary): minimal|low|medium|high|xhigh|max. Clamped to agy's low|medium|high; unknown values fall back to low. "peer review on high thinking" -> thinking: "high".
|
|
94
62
|
- An explicit level beats a tier embedded in model ("flash high") and the configured default. Omit both for the configured default.`;
|
|
95
63
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
interface ModelEntry {
|
|
101
|
-
full: string; // exact agy slug, e.g. "gemini-3.6-flash-medium"
|
|
102
|
-
family: Family;
|
|
103
|
-
version: string | null; // "3.6"
|
|
104
|
-
tier: ThinkingTier | null;
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Argv-facing model resolution: the exact --model slug plus an optional
|
|
108
|
-
* --effort tier. Gemini bases split the tier out (the base slug alone is
|
|
109
|
-
* invalid without --effort); fixed-thinking families keep agy's exact slug
|
|
110
|
-
* and carry no effort. */
|
|
111
|
-
interface ResolvedModel {
|
|
112
|
-
model: string;
|
|
113
|
-
effort?: ThinkingTier;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
// --- Version helpers -------------------------------------------------------
|
|
117
|
-
|
|
118
|
-
/** Descending numeric version compare (3.10 > 3.9, not lexical). */
|
|
119
|
-
function compareVersionsDesc(a: string, b: string): number {
|
|
120
|
-
const pa = a.split(".").map(Number);
|
|
121
|
-
const pb = b.split(".").map(Number);
|
|
122
|
-
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
|
123
|
-
const da = pa[i] ?? 0;
|
|
124
|
-
const db = pb[i] ?? 0;
|
|
125
|
-
if (da !== db) return db - da;
|
|
126
|
-
}
|
|
127
|
-
return 0;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
// --- Model catalog ---------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
function mergeCatalog(live: ModelEntry[]): ModelEntry[] {
|
|
133
|
-
const seen = new Set(live.map((e) => e.full.toLowerCase()));
|
|
134
|
-
const merged = [...live];
|
|
135
|
-
for (const entry of STATIC_ALIAS_OVERLAY) {
|
|
136
|
-
if (!seen.has(entry.full.toLowerCase())) merged.push(entry);
|
|
137
|
-
}
|
|
138
|
-
return merged;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function parseModelLine(line: string): ModelEntry | null {
|
|
142
|
-
// agy prints TWO columns: "<slug> <display label>". --model takes only the
|
|
143
|
-
// slug (col 1), so split it off; the label is display-only and must never
|
|
144
|
-
// reach --model. A bare-slug line (no whitespace) splits to itself.
|
|
145
|
-
const full = line.trim().split(/\s+/)[0] ?? "";
|
|
146
|
-
if (!full) return null;
|
|
147
|
-
const lower = full.toLowerCase();
|
|
148
|
-
const family: Family = lower.includes("flash")
|
|
149
|
-
? "flash"
|
|
150
|
-
: lower.includes("pro")
|
|
151
|
-
? "pro"
|
|
152
|
-
: "other";
|
|
153
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
154
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
155
|
-
const tierMatch = lower.match(/-(low|medium|high)$/);
|
|
156
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
157
|
-
return { full, family, version, tier };
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
function nearestTier(available: ThinkingTier[], preferred: ThinkingTier): ThinkingTier {
|
|
161
|
-
if (available.includes(preferred)) return preferred;
|
|
162
|
-
const sorted = [...available].sort((a, b) => {
|
|
163
|
-
const da = Math.abs(TIER_RANK[a] - TIER_RANK[preferred]);
|
|
164
|
-
const db = Math.abs(TIER_RANK[b] - TIER_RANK[preferred]);
|
|
165
|
-
return da !== db ? da - db : TIER_RANK[b] - TIER_RANK[a];
|
|
166
|
-
});
|
|
167
|
-
return sorted[0] ?? preferred;
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
/** Build the argv-facing resolution from a picked catalog entry. Gemini and
|
|
171
|
-
* Claude bases (slugs starting "gemini-"/"claude-") accept a separate
|
|
172
|
-
* --effort, so split the tier suffix out of the slug: the base alone
|
|
173
|
-
* (claude-sonnet-5-5) is what --model wants, and the tier goes to --effort
|
|
174
|
-
* (agy rejects a bare base: "requires --effort"). Only unverified families
|
|
175
|
-
* keep agy's exact slug whole, so an unknown suffix can never trigger an
|
|
176
|
-
* unsupported --effort. */
|
|
177
|
-
function toResolved(full: string, tier: ThinkingTier | null): ResolvedModel {
|
|
178
|
-
if (tier && /^(gemini|claude)-/.test(full.toLowerCase())) {
|
|
179
|
-
return { model: full.replace(/-(low|medium|high)$/, ""), effort: tier };
|
|
180
|
-
}
|
|
181
|
-
return { model: full };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
/** Resolve a tiered base id - a short-alias target ("claude-sonnet-5-5") or a
|
|
185
|
-
* bare base slug the provider itself advertises - to the catalog variant
|
|
186
|
-
* nearest the requested tier. A bare base alone is invalid upstream
|
|
187
|
-
* ("requires --effort"), so the pick always rides the split in toResolved;
|
|
188
|
-
* with no variants in the catalog, the overlay/exact entry or the raw base
|
|
189
|
-
* passes through unchanged. */
|
|
190
|
-
function resolveTieredBase(
|
|
191
|
-
base: string,
|
|
192
|
-
entries: ModelEntry[],
|
|
193
|
-
defaultThinking: ThinkingTier,
|
|
194
|
-
preferredTier: ThinkingTier | undefined,
|
|
195
|
-
): ResolvedModel {
|
|
196
|
-
const needle = base.toLowerCase();
|
|
197
|
-
const variants = entries.filter((e) => e.full.toLowerCase().startsWith(`${needle}-`));
|
|
198
|
-
if (variants.length > 0) {
|
|
199
|
-
const tiers = variants.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null);
|
|
200
|
-
const preferred =
|
|
201
|
-
preferredTier ??
|
|
202
|
-
(tiers.includes(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER.other);
|
|
203
|
-
const chosen = nearestTier(tiers, preferred);
|
|
204
|
-
const picked = variants.find((e) => e.tier === chosen) ?? variants[0];
|
|
205
|
-
return toResolved(picked.full, picked.tier);
|
|
206
|
-
}
|
|
207
|
-
const fromCatalog = entries.find((e) => e.full.toLowerCase() === needle);
|
|
208
|
-
return toResolved(fromCatalog?.full ?? base, fromCatalog?.tier ?? null);
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/** Resolve a friendly alias / partial name to an argv-facing {model, effort?}.
|
|
212
|
-
* Returns null only when the family is unrecognized; the caller then passes
|
|
213
|
-
* the raw input straight to agy. */
|
|
214
|
-
export function resolveModel(
|
|
215
|
-
input: string,
|
|
216
|
-
entries: ModelEntry[],
|
|
217
|
-
defaultThinking: ThinkingTier,
|
|
218
|
-
/** Explicit thinking param (thinking/effort). Beats a tier embedded in
|
|
219
|
-
* the alias ("flash high") and the configured default; clamped to the
|
|
220
|
-
* family's real tiers, ignored for fixed-thinking families. */
|
|
221
|
-
preferredTier?: ThinkingTier,
|
|
222
|
-
): ResolvedModel | null {
|
|
223
|
-
const lower = input.toLowerCase().trim();
|
|
224
|
-
|
|
225
|
-
const exact = entries.find((e) => e.full.toLowerCase() === lower);
|
|
226
|
-
if (exact) return toResolved(exact.full, exact.tier);
|
|
227
|
-
|
|
228
|
-
if (STATIC_SHORT_ALIAS.has(lower)) {
|
|
229
|
-
return resolveTieredBase(
|
|
230
|
-
STATIC_SHORT_ALIAS.get(lower) as string,
|
|
231
|
-
entries,
|
|
232
|
-
defaultThinking,
|
|
233
|
-
preferredTier,
|
|
234
|
-
);
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
let family: Family | null = lower.includes("flash")
|
|
238
|
-
? "flash"
|
|
239
|
-
: lower.includes("pro")
|
|
240
|
-
? "pro"
|
|
241
|
-
: null;
|
|
242
|
-
const versionMatch = lower.match(/(\d+\.\d+)/);
|
|
243
|
-
const version = versionMatch ? versionMatch[1] : null;
|
|
244
|
-
const tierMatch = lower.match(/\b(low|medium|high)\b/);
|
|
245
|
-
const tier = tierMatch ? (tierMatch[1] as ThinkingTier) : null;
|
|
246
|
-
|
|
247
|
-
if (!family && (/gemini/.test(lower) || lower === "" || lower === "default")) {
|
|
248
|
-
family = "flash";
|
|
249
|
-
}
|
|
250
|
-
if (!family) {
|
|
251
|
-
// A bare base slug the provider itself advertises ("claude-sonnet-5-5",
|
|
252
|
-
// the id behind the antigravity/ entry in pi's picker) is invalid
|
|
253
|
-
// upstream without an effort: resolve it to the nearest tier variant
|
|
254
|
-
// exactly like the short aliases.
|
|
255
|
-
if (
|
|
256
|
-
entries.some((e) => e.full.toLowerCase().startsWith(`${lower}-`)) ||
|
|
257
|
-
entries.some((e) => e.full.toLowerCase() === lower)
|
|
258
|
-
) {
|
|
259
|
-
return resolveTieredBase(lower, entries, defaultThinking, preferredTier);
|
|
260
|
-
}
|
|
261
|
-
return null;
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
let candidates = entries.filter((e) => e.family === family);
|
|
265
|
-
if (candidates.length === 0) return null;
|
|
266
|
-
|
|
267
|
-
if (version) {
|
|
268
|
-
const versioned = candidates.filter((e) => e.version === version);
|
|
269
|
-
if (versioned.length > 0) candidates = versioned;
|
|
270
|
-
} else {
|
|
271
|
-
const aliases = candidates.filter((e) => e.version === null);
|
|
272
|
-
if (aliases.length > 0) {
|
|
273
|
-
candidates = aliases;
|
|
274
|
-
} else {
|
|
275
|
-
const versions = candidates
|
|
276
|
-
.map((e) => e.version)
|
|
277
|
-
.filter((v): v is string => v !== null)
|
|
278
|
-
.sort(compareVersionsDesc);
|
|
279
|
-
if (versions.length > 0) {
|
|
280
|
-
const top = versions[0];
|
|
281
|
-
const latest = candidates.filter((e) => e.version === top);
|
|
282
|
-
if (latest.length > 0) candidates = latest;
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
|
|
287
|
-
const familyTiers = new Set(
|
|
288
|
-
candidates.map((e) => e.tier).filter((t): t is ThinkingTier => t !== null),
|
|
289
|
-
);
|
|
290
|
-
if (familyTiers.size === 0) return toResolved(candidates[0].full, null);
|
|
291
|
-
|
|
292
|
-
const preferred =
|
|
293
|
-
preferredTier ??
|
|
294
|
-
tier ??
|
|
295
|
-
(familyTiers.has(defaultThinking) ? defaultThinking : FAMILY_DEFAULT_TIER[family]);
|
|
296
|
-
const chosenTier = nearestTier([...familyTiers], preferred);
|
|
297
|
-
const picked = candidates.find((e) => e.tier === chosenTier) ?? candidates[0];
|
|
298
|
-
return toResolved(picked.full, picked.tier);
|
|
299
|
-
}
|
|
64
|
+
const DEFAULT_TIMEOUT_MIN = 10;
|
|
65
|
+
const GRACE_AFTER_TIMEOUT_MS = 5000;
|
|
66
|
+
const STATUS_INTERVAL_MS = 1000;
|
|
67
|
+
const STATUS_TAIL_CHARS = 160;
|
|
300
68
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
const
|
|
69
|
+
// renderCall / renderResult preview limits (match pi-claude-bridge).
|
|
70
|
+
const PREVIEW_MAX_CHARS = 1000;
|
|
71
|
+
const PREVIEW_MAX_LINES = 6;
|
|
72
|
+
const DISCOVERY_POLL_ATTEMPTS = 5;
|
|
73
|
+
const DISCOVERY_POLL_MS = 100;
|
|
304
74
|
|
|
305
|
-
/** Parse raw `agy models` text into tool-catalog entries
|
|
306
|
-
*
|
|
75
|
+
/** Parse raw `agy models` text into tool-catalog entries: the shared line
|
|
76
|
+
* grammar + hidden-family filter + static sonnet/opus overlay. Pure: no
|
|
77
|
+
* spawn. */
|
|
307
78
|
export function toolModelsFromRaw(raw: string): ModelEntry[] {
|
|
308
79
|
return mergeCatalog(
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
80
|
+
filterHiddenModels(
|
|
81
|
+
raw
|
|
82
|
+
.split("\n")
|
|
83
|
+
.map(parseModelLine)
|
|
84
|
+
.filter((e): e is ModelEntry => e !== null),
|
|
85
|
+
),
|
|
313
86
|
);
|
|
314
87
|
}
|
|
315
88
|
|
|
@@ -325,56 +98,11 @@ function extraArgs(): string[] {
|
|
|
325
98
|
return raw ? raw.split(/\s+/).filter((s) => s.length > 0) : [];
|
|
326
99
|
}
|
|
327
100
|
|
|
328
|
-
// --- Prompt assembly
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
* keeps the answer-from-material discipline. The agent is a damper, not
|
|
334
|
-
* enforcement (upstream #1181); a headless command attempt can still be
|
|
335
|
-
* auto-denied, which ends the run, so the guard steers toward view_file
|
|
336
|
-
* and staged material first. */
|
|
337
|
-
export const AGENT_REVIEW_GUARD = [
|
|
338
|
-
"",
|
|
339
|
-
"--- Review constraints ---",
|
|
340
|
-
"- Read-only review. Do not create, modify, or delete any files, including through shell commands (no redirects, tee, rm, mv, git commit).",
|
|
341
|
-
"- Prefer the staged material and view_file for reading. If you run a read-only command (git log, git diff, rg, ls) and it comes back denied, the run ends without an answer: state what you could not check instead of retrying commands.",
|
|
342
|
-
"- Work from the material provided in this prompt; if information you need is missing, state exactly what is missing in your answer.",
|
|
343
|
-
].join("\n");
|
|
344
|
-
|
|
345
|
-
/** Appended to every headless plan-mode prompt. `agy -p` cannot answer
|
|
346
|
-
* permission prompts: in plan mode a run_command attempt is soft-denied and
|
|
347
|
-
* the turn ends AT the denial (exit 0, empty stdout, notice only on stderr -
|
|
348
|
-
* the empty-output branch in execute). Probed 2026-09-28 on agy 1.2.12:
|
|
349
|
-
* allow rules ARE consulted (a verbatim allow-listed `git log` runs), but
|
|
350
|
-
* --sandbox does NOT relax the command gate, so the only input every user
|
|
351
|
-
* is guaranteed is the prompt itself. The guard steers the model to answer
|
|
352
|
-
* from that material. accept-edits runs keep their tools and never get it. */
|
|
353
|
-
export const PLAN_HEADLESS_GUARD = [
|
|
354
|
-
"",
|
|
355
|
-
"--- Headless session constraints ---",
|
|
356
|
-
"- Do not run shell commands. Command execution is denied in this session, and any attempt ends the session immediately with no answer.",
|
|
357
|
-
"- Work only from the material provided in this prompt.",
|
|
358
|
-
"- If information you need is missing, state exactly what is missing in your answer instead of trying to fetch it.",
|
|
359
|
-
].join("\n");
|
|
360
|
-
|
|
361
|
-
/** Assemble the prompt sent to agy: digest marker first (existing behavior),
|
|
362
|
-
* then the caller's prompt, then the plan-mode guard last - the position
|
|
363
|
-
* the model reads with the most recency. No user-config mutation: this is
|
|
364
|
-
* the only plan-mode lever the tool itself owns. */
|
|
365
|
-
export function buildFinalPrompt(
|
|
366
|
-
prompt: string,
|
|
367
|
-
mode: AgyMode,
|
|
368
|
-
digest: boolean,
|
|
369
|
-
/** True when the restricted reviewer agent is staged: its empty edit
|
|
370
|
-
* toolset is a damper (the CLI does not enforce review-only, upstream
|
|
371
|
-
* #1181), so the prompt may ALLOW read-only commands. */
|
|
372
|
-
agentDamper = false,
|
|
373
|
-
): string {
|
|
374
|
-
let out = digest ? `(Use compact digests, not full file contents.)\n${prompt}` : prompt;
|
|
375
|
-
if (mode === "plan") out += agentDamper ? AGENT_REVIEW_GUARD : PLAN_HEADLESS_GUARD;
|
|
376
|
-
return out;
|
|
377
|
-
}
|
|
101
|
+
// --- Prompt assembly: shared peer adapter ----------------------------------
|
|
102
|
+
// PLAN_HEADLESS_GUARD / AGENT_REVIEW_GUARD / buildFinalPrompt live in
|
|
103
|
+
// pi-ask-shared (peers/antigravity.ts); the bridge inherits the identical
|
|
104
|
+
// guard discipline. Re-exported for the bridge's own tests.
|
|
105
|
+
export { AGENT_REVIEW_GUARD, buildFinalPrompt, PLAN_HEADLESS_GUARD } from "@estebanforge/pi-ask-shared";
|
|
378
106
|
|
|
379
107
|
// --- Plan-mode reviewer agent ----------------------------------------------
|
|
380
108
|
|
|
@@ -505,7 +233,7 @@ export async function registerAskAntigravityTool(
|
|
|
505
233
|
}),
|
|
506
234
|
),
|
|
507
235
|
timeoutMinutes: Type.Optional(
|
|
508
|
-
Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}.` }),
|
|
236
|
+
Type.Number({ description: `Hard cap on the agy run in minutes. Default ${DEFAULT_TIMEOUT_MIN}. Fractional minutes allowed; must be above 0 and at most ${MAX_TIMEOUT_MINUTES}. The prompt carries a matching [TIME BUDGET] notice.` }),
|
|
509
237
|
),
|
|
510
238
|
includeContext: Type.Optional(
|
|
511
239
|
Type.Boolean({
|
|
@@ -527,7 +255,7 @@ export async function registerAskAntigravityTool(
|
|
|
527
255
|
const requestedModel = (args.model as string | undefined)?.trim() || cfg.defaultModel;
|
|
528
256
|
const thinkingArg = (args.thinking as string | undefined) ?? (args.effort as string | undefined);
|
|
529
257
|
const resolved =
|
|
530
|
-
|
|
258
|
+
resolveAgyModel(
|
|
531
259
|
requestedModel,
|
|
532
260
|
entries,
|
|
533
261
|
cfg.defaultThinking,
|
|
@@ -674,7 +402,7 @@ export async function registerAskAntigravityTool(
|
|
|
674
402
|
? toAgyEffort(thinkingArg as ThinkingLevel, AGY_EFFORT_ORDER)
|
|
675
403
|
: undefined;
|
|
676
404
|
const resolved =
|
|
677
|
-
|
|
405
|
+
resolveAgyModel(requestedModel, entries, config.defaultThinking, preferredTier) ?? {
|
|
678
406
|
model: requestedModel,
|
|
679
407
|
};
|
|
680
408
|
|
|
@@ -696,11 +424,19 @@ export async function registerAskAntigravityTool(
|
|
|
696
424
|
}
|
|
697
425
|
|
|
698
426
|
const timeoutMin = params.timeoutMinutes ?? DEFAULT_TIMEOUT_MIN;
|
|
427
|
+
if (!(timeoutMin > 0 && Number.isFinite(timeoutMin) && timeoutMin <= MAX_TIMEOUT_MINUTES)) {
|
|
428
|
+
// timeoutMinutes: 0 would arm runProcess's watchdog with a 0ms
|
|
429
|
+
// timeout (Node clamps to 1ms) and kill the peer instantly.
|
|
430
|
+
return {
|
|
431
|
+
content: [{ type: "text", text: `timeoutMinutes must be a positive number of minutes up to ${MAX_TIMEOUT_MINUTES} (for example 0.5 for 30 seconds).` }],
|
|
432
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
433
|
+
};
|
|
434
|
+
}
|
|
699
435
|
|
|
700
436
|
const rawConvId = params.conversationId;
|
|
701
437
|
const isContinuation =
|
|
702
438
|
typeof rawConvId === "string" && rawConvId.length > 0 && CONV_ID_RE.test(rawConvId);
|
|
703
|
-
const snapshot = isContinuation ? null : snapshotConversations();
|
|
439
|
+
const snapshot = isContinuation ? null : snapshotConversations(CONVERSATIONS_DIR);
|
|
704
440
|
|
|
705
441
|
const mode: AgyMode =
|
|
706
442
|
(params.mode as AgyMode | undefined) ?? (config.mode === "plan" ? "plan" : "accept-edits");
|
|
@@ -721,8 +457,13 @@ export async function registerAskAntigravityTool(
|
|
|
721
457
|
}
|
|
722
458
|
const useDigest: boolean =
|
|
723
459
|
typeof params.digest === "boolean" ? params.digest : mode === "plan";
|
|
460
|
+
// Budget notice into the prompt BEFORE buildFinalPrompt: the plan-mode
|
|
461
|
+
// guard banners must stay the final text (last-read recency), so the
|
|
462
|
+
// notice can't go after them. The builder itself stays notice-free so
|
|
463
|
+
// callers can't double-wrap.
|
|
464
|
+
const budget = buildTimeBudgetNotice(timeoutMin * 60_000);
|
|
724
465
|
const finalPrompt: string = buildFinalPrompt(
|
|
725
|
-
params.prompt,
|
|
466
|
+
budget ? `${params.prompt}\n\n${budget}` : params.prompt,
|
|
726
467
|
mode,
|
|
727
468
|
useDigest,
|
|
728
469
|
reviewerAgent !== null,
|
|
@@ -751,45 +492,27 @@ export async function registerAskAntigravityTool(
|
|
|
751
492
|
? `The full pi conversation context (as markdown) is at: ${contextFile}\nRead that file first for context, then do the task below.\n\n---\n\n${finalPrompt}`
|
|
752
493
|
: finalPrompt;
|
|
753
494
|
|
|
754
|
-
const args: string[] = ["--add-dir", cwd];
|
|
755
495
|
log?.(
|
|
756
496
|
"ask-start",
|
|
757
497
|
{ model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
|
|
758
498
|
"info",
|
|
759
499
|
);
|
|
760
|
-
|
|
761
|
-
//
|
|
762
|
-
//
|
|
763
|
-
//
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
mode
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
// accept-edits: the flag keeps commands from hanging on an
|
|
777
|
-
// unanswerable prompt in -p mode. Plan runs NEVER get the flag: it
|
|
778
|
-
// auto-approves every permission request, and the CLI does not gate
|
|
779
|
-
// writes under plan mode (upstream
|
|
780
|
-
// google-antigravity/antigravity-cli#1181, probed 2026-10-07), so an
|
|
781
|
-
// auto-approved plan run is a write-capable run. The reviewer agent's
|
|
782
|
-
// toolset is a damper, not a guarantee; the 2026-09-28 "safe with the
|
|
783
|
-
// restricted toolset" probe predates the #1181 evidence. Without the
|
|
784
|
-
// flag, command attempts fail visibly (headless auto-deny) instead of
|
|
785
|
-
// running approved.
|
|
786
|
-
if (config.skipPermissions !== false && mode !== "plan") {
|
|
787
|
-
args.push("--dangerously-skip-permissions");
|
|
788
|
-
}
|
|
789
|
-
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
790
|
-
args.push("--print-timeout", `${timeoutMin}m`);
|
|
791
|
-
if (contextFile) args.push("--add-dir", askContextDir());
|
|
792
|
-
args.push("-p", effectivePrompt);
|
|
500
|
+
// Build argv via the shared peer adapter (see buildAgyArgs there for
|
|
501
|
+
// the flag-order contract and the fail-closed plan-run rules). The
|
|
502
|
+
// bridge's permissions gate is truthy (`!== false`), so an unset
|
|
503
|
+
// config counts as enabled - same knob as the provider.
|
|
504
|
+
const args = buildAgyArgs({
|
|
505
|
+
cwd,
|
|
506
|
+
resolved,
|
|
507
|
+
mode,
|
|
508
|
+
reviewerAgentName: reviewerAgent?.name ?? null,
|
|
509
|
+
skipPermissions: config.skipPermissions !== false,
|
|
510
|
+
conversationId: isContinuation ? (rawConvId as string) : null,
|
|
511
|
+
timeoutMinutes: timeoutMin,
|
|
512
|
+
addDirs: contextFile ? [askContextDir()] : [],
|
|
513
|
+
extraArgs: extraArgs(),
|
|
514
|
+
prompt: effectivePrompt,
|
|
515
|
+
});
|
|
793
516
|
|
|
794
517
|
const details: AgyDetails = {
|
|
795
518
|
model: requestedModel,
|
package/src/provider.ts
CHANGED
|
@@ -433,6 +433,14 @@ export interface PollView {
|
|
|
433
433
|
isError?: boolean;
|
|
434
434
|
reason?: string;
|
|
435
435
|
images?: Array<{ data: string; mimeType: string }>;
|
|
436
|
+
/** A terminal result the model already received via a poll. Set only by
|
|
437
|
+
* markDelivered (the real poll path); poll() stays pure so internal
|
|
438
|
+
* peeks (escalatedNames) never mark. */
|
|
439
|
+
delivered?: boolean;
|
|
440
|
+
/** The agy conversation this call belongs to. Stamped at turn end so an
|
|
441
|
+
* unpolled result can be re-delivered to the right conversation
|
|
442
|
+
* (first-turn parks never reach the provider's store.set). */
|
|
443
|
+
conversationId?: string;
|
|
436
444
|
}
|
|
437
445
|
|
|
438
446
|
/** Escalated bridge calls. Bounded: past the cap, oldest settled entries
|
|
@@ -443,8 +451,11 @@ export class EscalationRegistry {
|
|
|
443
451
|
// Soft cap: only settled entries evict. Evicting a RUNNING call would
|
|
444
452
|
// strand its result (settle becomes a no-op, poll reports unknown), so
|
|
445
453
|
// saturating the cap with in-flight calls grows the map instead.
|
|
454
|
+
// Delivered entries evict first: an undelivered result still has a
|
|
455
|
+
// recovery path (redelivery), a delivered one only serves late polls.
|
|
446
456
|
while (this.#calls.size > MAX_PARK_TOMBSTONES) {
|
|
447
|
-
const
|
|
457
|
+
const settled = [...this.#calls.entries()].filter(([, e]) => e.state !== "running");
|
|
458
|
+
const victim = (settled.find(([, e]) => e.delivered) ?? settled[0])?.[0];
|
|
448
459
|
if (victim === undefined) break;
|
|
449
460
|
this.#calls.delete(victim);
|
|
450
461
|
}
|
|
@@ -473,6 +484,63 @@ export class EscalationRegistry {
|
|
|
473
484
|
const e = this.#calls.get(callId);
|
|
474
485
|
return e ? { ...e } : undefined;
|
|
475
486
|
}
|
|
487
|
+
|
|
488
|
+
/** Record that the model received this terminal result (the real poll
|
|
489
|
+
* path calls this when the view is not running). Running entries and
|
|
490
|
+
* unknown ids are a no-op. */
|
|
491
|
+
markDelivered(callId: string): boolean {
|
|
492
|
+
const e = this.#calls.get(callId);
|
|
493
|
+
if (!e || e.state === "running") return false;
|
|
494
|
+
e.delivered = true;
|
|
495
|
+
return true;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Single-use claim of a done-but-undelivered result: returns the view
|
|
499
|
+
* and marks it delivered in one step, so a later pass can never deliver
|
|
500
|
+
* it twice. The entry stays pollable for a late model poll. Failed
|
|
501
|
+
* escalations are excluded: their pending entry is gone, so the
|
|
502
|
+
* tombstone path is their single delivery channel. */
|
|
503
|
+
claimUndelivered(callId: string): (PollView & { callId: string }) | undefined {
|
|
504
|
+
const e = this.#calls.get(callId);
|
|
505
|
+
if (!e || e.state !== "done" || e.delivered) return undefined;
|
|
506
|
+
e.delivered = true;
|
|
507
|
+
return { ...e, callId };
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/** Registry-wide sweep: claim every done-but-undelivered result. A
|
|
511
|
+
* toolResult is consumed by its own pass (resolve deletes the pending
|
|
512
|
+
* entry), so a strand that survives its first pass can only be found
|
|
513
|
+
* here, not in a later pass's results. Failed escalations stay with the
|
|
514
|
+
* tombstone path. */
|
|
515
|
+
claimAllUndelivered(): Array<PollView & { callId: string }> {
|
|
516
|
+
const out: Array<PollView & { callId: string }> = [];
|
|
517
|
+
for (const [callId, e] of this.#calls) {
|
|
518
|
+
const claimed = e.state === "done" && !e.delivered ? { ...e, callId } : undefined;
|
|
519
|
+
if (claimed) {
|
|
520
|
+
e.delivered = true;
|
|
521
|
+
out.push(claimed);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return out;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** Release a claim whose delivery failed to start (driver.run threw): the
|
|
528
|
+
* result returns to the undelivered pool for the next recovery pass. */
|
|
529
|
+
unclaim(callId: string): void {
|
|
530
|
+
const e = this.#calls.get(callId);
|
|
531
|
+
if (e && e.state === "done") e.delivered = false;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/** Stamp every running escalation with the ended turn's conversation id
|
|
535
|
+
* (extensions fire this from onTurnEnd). First stamp wins: the ended
|
|
536
|
+
* turn is the conversation the call was made in; a later turn-end (the
|
|
537
|
+
* call can outlive several turns while a human answers) must not
|
|
538
|
+
* re-route the delivery. Entries that escalate later stay unstamped. */
|
|
539
|
+
stampConversation(conversationId: string): void {
|
|
540
|
+
for (const e of this.#calls.values()) {
|
|
541
|
+
if (e.state === "running") e.conversationId ??= conversationId;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
476
544
|
}
|
|
477
545
|
|
|
478
546
|
export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
|
|
@@ -483,7 +551,7 @@ export function formatEscalatedAck(e: BridgeEscalation): BridgeCallResultShape {
|
|
|
483
551
|
text: [
|
|
484
552
|
`STILL RUNNING: the pi tool "${e.name}" has not finished yet.`,
|
|
485
553
|
`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.`,
|
|
486
|
-
"
|
|
554
|
+
"Do NOT end your turn while this call is unresolved: keep polling until it reports done or failed. Ending the turn strands the result.",
|
|
487
555
|
].join("\n"),
|
|
488
556
|
},
|
|
489
557
|
],
|
|
@@ -621,6 +689,36 @@ export class ToolRoundTrips {
|
|
|
621
689
|
return this.#escalations.poll(callId);
|
|
622
690
|
}
|
|
623
691
|
|
|
692
|
+
/** Mark a terminal escalated result as received by the model. Only the
|
|
693
|
+
* real poll path (extensions) calls this; poll() stays pure. */
|
|
694
|
+
markDelivered(callId: string): boolean {
|
|
695
|
+
return this.#escalations.markDelivered(callId);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
/** Single-use claim of a settled-but-undelivered escalated result (the
|
|
699
|
+
* model ended its turn instead of polling). */
|
|
700
|
+
claimUndelivered(callId: string): (PollView & { callId: string }) | undefined {
|
|
701
|
+
return this.#escalations.claimUndelivered(callId);
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
/** Registry-wide sweep for done-but-undelivered results: a toolResult is
|
|
705
|
+
* consumed by its own pass, so multi-pass strands are only findable
|
|
706
|
+
* here. */
|
|
707
|
+
claimAllUndelivered(): Array<PollView & { callId: string }> {
|
|
708
|
+
return this.#escalations.claimAllUndelivered();
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/** Release a claim whose delivery failed to start. */
|
|
712
|
+
unclaim(callId: string): void {
|
|
713
|
+
this.#escalations.unclaim(callId);
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
/** Stamp running escalations with the ended turn's conversation id so an
|
|
717
|
+
* unpolled result can be re-delivered to the right conversation. */
|
|
718
|
+
stampConversation(conversationId: string): void {
|
|
719
|
+
this.#escalations.stampConversation(conversationId);
|
|
720
|
+
}
|
|
721
|
+
|
|
624
722
|
/** Wire the approval park (mcp-server handle.approvals). The extension
|
|
625
723
|
* assigns this AFTER the shadow tools are registered; see onApproval. */
|
|
626
724
|
set approvalPark(api: ApprovalParkApi | undefined) {
|
|
@@ -902,6 +1000,9 @@ export function collectToolResults(
|
|
|
902
1000
|
}
|
|
903
1001
|
|
|
904
1002
|
export interface LateToolResult {
|
|
1003
|
+
/** Escalated results only: the registry callId, so a failed delivery can
|
|
1004
|
+
* unclaim it. Tombstone-borne results have none (already consumed). */
|
|
1005
|
+
callId?: string;
|
|
905
1006
|
name: string;
|
|
906
1007
|
reason: string;
|
|
907
1008
|
text: string;
|
|
@@ -909,12 +1010,12 @@ export interface LateToolResult {
|
|
|
909
1010
|
}
|
|
910
1011
|
|
|
911
1012
|
/** Frame late tool results so agy treats them as the results its bridge calls
|
|
912
|
-
* never received
|
|
913
|
-
*
|
|
1013
|
+
* never received: a park whose round-trip died while the pi tool ran, or an
|
|
1014
|
+
* escalated result the model never polled. The reason carries the framing. */
|
|
914
1015
|
export function buildLateResultPrompt(late: LateToolResult[], userPrompt?: string): string {
|
|
915
1016
|
const blocks = late.map((r) =>
|
|
916
1017
|
[
|
|
917
|
-
`pi tool "${r.name}":
|
|
1018
|
+
`pi tool "${r.name}": ${r.reason}.`,
|
|
918
1019
|
r.isError ? "The tool reported an error:" : "Result:",
|
|
919
1020
|
r.text.trim() || "(no output)",
|
|
920
1021
|
].join("\n"),
|
|
@@ -1194,28 +1295,67 @@ async function runTurnDriver(
|
|
|
1194
1295
|
deps.log?.("late-result-deferred", { count: deps.roundTrips.deadIds.length }, "info");
|
|
1195
1296
|
}
|
|
1196
1297
|
|
|
1197
|
-
|
|
1298
|
+
// Stranded escalated results: done but never delivered — the model ended
|
|
1299
|
+
// its turn instead of polling bridge_poll_result (observed live 2026-10:
|
|
1300
|
+
// the model stopped after the STILL RUNNING ack on an ask_user_question
|
|
1301
|
+
// and the user's answer stranded in the registry). The sweep is
|
|
1302
|
+
// registry-wide: a toolResult is consumed by its own pass, so a strand
|
|
1303
|
+
// that survives its first pass is only findable here. Claims are
|
|
1304
|
+
// single-use; entries stay pollable for a late model poll.
|
|
1305
|
+
let redeliveryConversation: string | undefined;
|
|
1306
|
+
const collectStranded = (): LateToolResult[] =>
|
|
1307
|
+
deps.roundTrips.claimAllUndelivered().map((view) => {
|
|
1308
|
+
redeliveryConversation ??= view.conversationId;
|
|
1309
|
+
deps.log?.("escalated-redelivery", { tool: view.name, callId: view.callId }, "info");
|
|
1310
|
+
return {
|
|
1311
|
+
callId: view.callId,
|
|
1312
|
+
name: view.name,
|
|
1313
|
+
reason: "you never received this result: you ended your turn without calling bridge_poll_result",
|
|
1314
|
+
text: view.text ?? "(no output)",
|
|
1315
|
+
isError: view.isError ?? false,
|
|
1316
|
+
};
|
|
1317
|
+
});
|
|
1318
|
+
|
|
1319
|
+
const entry = entries.find((e) => e.id === model.id) ?? null;
|
|
1320
|
+
const agyModel = entry?.full ?? model.id;
|
|
1321
|
+
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
1322
|
+
|
|
1323
|
+
let handle: TurnHandle | undefined;
|
|
1198
1324
|
// Name of the carrier agent staged for THIS turn's fresh conversation, if
|
|
1199
1325
|
// any. Recorded into stagedAgentByConversation once the outcome binds the
|
|
1200
1326
|
// conversation id, so later gate-closed turns pass the same agent.
|
|
1201
1327
|
let stagedAgentName: string | undefined;
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1328
|
+
const active = isContinuation ? deps.driver.reentry() : null;
|
|
1329
|
+
if (active) {
|
|
1330
|
+
handle = active;
|
|
1331
|
+
} else {
|
|
1332
|
+
// Dead turn: claim NOW (single-use). A live turn claims after outcome
|
|
1333
|
+
// instead — claiming here would eat the post-outcome recovery.
|
|
1334
|
+
late.push(...collectStranded());
|
|
1335
|
+
if (isContinuation && late.length === 0) {
|
|
1336
|
+
if (escalatedNames.length === 0) {
|
|
1337
|
+
deps.log?.("turn-error", { reason: "tool-result-no-active-turn" }, "warn");
|
|
1338
|
+
finalize(stream, blocks, "error", "tool result arrived but no antigravity turn is running");
|
|
1211
1339
|
return;
|
|
1212
1340
|
}
|
|
1213
|
-
|
|
1214
|
-
|
|
1341
|
+
// Escalated results, all already delivered through polls: the model
|
|
1342
|
+
// has them in context. Settle quietly instead of erroring.
|
|
1343
|
+
appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished; the result is available via ${POLL_TOOL_NAME}.`);
|
|
1344
|
+
finalize(stream, blocks, "stop");
|
|
1215
1345
|
return;
|
|
1216
1346
|
}
|
|
1217
|
-
|
|
1218
|
-
|
|
1347
|
+
if (isContinuation && late.length > 0 && redeliveryConversation === undefined && !existing?.conversationId) {
|
|
1348
|
+
// The owning turn ended before binding a conversation (no stamp, no
|
|
1349
|
+
// store record): a context-free late prompt would hit a brand-new
|
|
1350
|
+
// agy conversation that never asked the question. Release the
|
|
1351
|
+
// claims and settle quietly; the result stays pollable and a later
|
|
1352
|
+
// pass with a bound conversation can still sweep it.
|
|
1353
|
+
for (const l of late) if (l.callId) deps.roundTrips.unclaim(l.callId);
|
|
1354
|
+
appendText(stream, blocks, `[bridge] ${escalatedNames.join(", ")} finished, but no conversation is bound to re-deliver into; the result stays available via ${POLL_TOOL_NAME}.`);
|
|
1355
|
+
finalize(stream, blocks, "stop");
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
// Fresh pass, or a stranded escalated result being re-delivered.
|
|
1219
1359
|
const prompt = extractUserPrompt(context);
|
|
1220
1360
|
const images = extractImages(context);
|
|
1221
1361
|
// An image-only message (no text) is valid on the ACP engine; only fail
|
|
@@ -1226,9 +1366,6 @@ async function runTurnDriver(
|
|
|
1226
1366
|
finalize(stream, blocks, "error", "No user message to send to agy.");
|
|
1227
1367
|
return;
|
|
1228
1368
|
}
|
|
1229
|
-
const entry = entries.find((e) => e.id === model.id) ?? null;
|
|
1230
|
-
const agyModel = entry?.full ?? model.id;
|
|
1231
|
-
const effort = entry?.efforts?.length ? toAgyEffort(options?.reasoning, entry.efforts) : undefined;
|
|
1232
1369
|
const watermark = existing?.lastMessageCount ?? 0;
|
|
1233
1370
|
// Late turns re-open the conversation with a synthetic prompt; the digest
|
|
1234
1371
|
// would re-send context agy already holds, so skip it.
|
|
@@ -1293,7 +1430,7 @@ async function runTurnDriver(
|
|
|
1293
1430
|
skipPermissions: config.skipPermissions,
|
|
1294
1431
|
timeoutMin: config.turnTimeoutMin,
|
|
1295
1432
|
inactivityMin: config.inactivityTimeoutMin,
|
|
1296
|
-
conversationId: existing?.conversationId ?? null,
|
|
1433
|
+
conversationId: redeliveryConversation ?? existing?.conversationId ?? null,
|
|
1297
1434
|
prompt: fullPrompt,
|
|
1298
1435
|
images: images.length > 0 ? images : undefined,
|
|
1299
1436
|
agent,
|
|
@@ -1307,6 +1444,10 @@ async function runTurnDriver(
|
|
|
1307
1444
|
});
|
|
1308
1445
|
} catch (err) {
|
|
1309
1446
|
const msg = err instanceof Error ? err.message : String(err);
|
|
1447
|
+
// Release stranded claims: the delivery never started, so the next
|
|
1448
|
+
// pass must find them undelivered (tombstone-borne items have no
|
|
1449
|
+
// callId and stay consumed, as before).
|
|
1450
|
+
for (const l of late) if (l.callId) deps.roundTrips.unclaim(l.callId);
|
|
1310
1451
|
deps.log?.("turn-error", { reason: "driver-start-failed", error: msg }, "error");
|
|
1311
1452
|
finalize(stream, blocks, "error", `agy failed to start: ${msg}`);
|
|
1312
1453
|
return;
|
|
@@ -1324,13 +1465,16 @@ async function runTurnDriver(
|
|
|
1324
1465
|
roster: deps.roster,
|
|
1325
1466
|
};
|
|
1326
1467
|
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1468
|
+
const drainTurn = async (h: TurnHandle): Promise<"parked" | undefined> => {
|
|
1469
|
+
for (;;) {
|
|
1470
|
+
const activity = await h.next();
|
|
1471
|
+
if (!activity) break;
|
|
1472
|
+
if (consumeActivity(stream, blocks, activity, diffCtx, cwd, feats) === "parked") return "parked";
|
|
1473
|
+
}
|
|
1474
|
+
};
|
|
1475
|
+
if ((await drainTurn(handle)) === "parked") return;
|
|
1332
1476
|
|
|
1333
|
-
|
|
1477
|
+
let outcome = await handle.outcome;
|
|
1334
1478
|
// Issue #2 recovery paths. agy's stream-json prompt path silently drops an
|
|
1335
1479
|
// over-cap turn (SUCCESS, empty response, zero usage, NO model step) and a
|
|
1336
1480
|
// dropped turn leaves the conversation permanently unresponsive, so both
|
|
@@ -1374,6 +1518,43 @@ async function runTurnDriver(
|
|
|
1374
1518
|
);
|
|
1375
1519
|
return;
|
|
1376
1520
|
}
|
|
1521
|
+
// Gap closer: the result landed while the turn was still live (reentry
|
|
1522
|
+
// above), the turn ended, and the model never polled. Same strand, same
|
|
1523
|
+
// fix: sweep and deliver as a late run in the same conversation. Single
|
|
1524
|
+
// shot: claims are single-use, so a re-delivered strand cannot reappear
|
|
1525
|
+
// here; a late run that parks hands recovery to the next pi pass.
|
|
1526
|
+
if (isContinuation && outcome.status === "OK" && !outcome.aborted) {
|
|
1527
|
+
const more = collectStranded();
|
|
1528
|
+
if (more.length > 0) {
|
|
1529
|
+
const conv = redeliveryConversation ?? outcome.conversationId ?? existing?.conversationId ?? null;
|
|
1530
|
+
deps.log?.("escalated-redelivery-turn", { tools: more.map((s) => s.name), conversation: conv }, "info");
|
|
1531
|
+
try {
|
|
1532
|
+
const lateHandle = await deps.driver.run({
|
|
1533
|
+
cwd,
|
|
1534
|
+
model: agyModel,
|
|
1535
|
+
effort,
|
|
1536
|
+
mode: config.mode,
|
|
1537
|
+
skipPermissions: config.skipPermissions,
|
|
1538
|
+
timeoutMin: config.turnTimeoutMin,
|
|
1539
|
+
inactivityMin: config.inactivityTimeoutMin,
|
|
1540
|
+
conversationId: conv,
|
|
1541
|
+
prompt: buildLateResultPrompt(more),
|
|
1542
|
+
agent: config.agent ?? (conv ? stagedAgentByConversation.get(conv) : undefined),
|
|
1543
|
+
signal: options?.signal,
|
|
1544
|
+
});
|
|
1545
|
+
if ((await drainTurn(lateHandle)) === "parked") return;
|
|
1546
|
+
outcome = await lateHandle.outcome;
|
|
1547
|
+
} catch (err) {
|
|
1548
|
+
// Release the claims so the next pass can retry the delivery.
|
|
1549
|
+
for (const l of more) if (l.callId) deps.roundTrips.unclaim(l.callId);
|
|
1550
|
+
deps.log?.(
|
|
1551
|
+
"turn-error",
|
|
1552
|
+
{ reason: "redelivery-start-failed", error: err instanceof Error ? err.message : String(err) },
|
|
1553
|
+
"error",
|
|
1554
|
+
);
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1377
1558
|
if (outcome.conversationId) {
|
|
1378
1559
|
// A different id than the stored one means the driver recreated the
|
|
1379
1560
|
// conversation under us (ACP session-load fallback, stream-json CLI
|
package/src/discovery.ts
DELETED
|
@@ -1,199 +0,0 @@
|
|
|
1
|
-
// Discover the agy conversation id that `agy -p` creates but never prints.
|
|
2
|
-
//
|
|
3
|
-
// agy's print mode writes its steps to a fresh SQLite DB at
|
|
4
|
-
// ~/.gemini/antigravity-cli/conversations/<uuid>.db. It does NOT echo the id.
|
|
5
|
-
// The only reliable bind is to snapshot the *.db stems in that dir before
|
|
6
|
-
// spawn, then diff after: exactly one new file = ours. If several appear we
|
|
7
|
-
// refuse to bind (can't tell which is ours) - same approach as antigravity-acp
|
|
8
|
-
// (scan.ts), agy-acp (scan.ts), and pi-ask-antigravity.
|
|
9
|
-
//
|
|
10
|
-
// CONCURRENCY: when another agy (or subagent) starts in parallel, more than
|
|
11
|
-
// one new .db can land in the dir during our turn. mtime filtering does NOT
|
|
12
|
-
// disambiguate two *active* concurrent runs (both DBs are >= turn-start), and
|
|
13
|
-
// prompt-content matching is fragile (the user-message step_type 98 payload
|
|
14
|
-
// is undocumented and deeply nested; a field-number shift would silently
|
|
15
|
-
// misbind). The authoritative signal is which candidate .db OUR spawned agy
|
|
16
|
-
// process tree holds open right now - resolved via /proc/<pid>/fd on Linux.
|
|
17
|
-
// When that signal is unavailable (no pid, non-Linux, process already exited,
|
|
18
|
-
// or the scan itself is ambiguous) we fail safe to null, preserving the
|
|
19
|
-
// original "refuse to bind" behavior rather than guessing.
|
|
20
|
-
|
|
21
|
-
import fs from "node:fs";
|
|
22
|
-
import os from "node:os";
|
|
23
|
-
import path from "node:path";
|
|
24
|
-
|
|
25
|
-
/** Default conversations dir. Override with AGY_CONVERSATIONS_DIR. */
|
|
26
|
-
export const CONVERSATIONS_DIR =
|
|
27
|
-
process.env.AGY_CONVERSATIONS_DIR ||
|
|
28
|
-
path.join(os.homedir(), ".gemini", "antigravity-cli", "conversations");
|
|
29
|
-
|
|
30
|
-
/** Snapshot the set of conversation ids (*.db stems) currently on disk.
|
|
31
|
-
* Empty set (not throw) when the dir is missing - agy will create it. */
|
|
32
|
-
export function snapshotConversations(dir: string = CONVERSATIONS_DIR): Set<string> {
|
|
33
|
-
const out = new Set<string>();
|
|
34
|
-
let entries: string[];
|
|
35
|
-
try {
|
|
36
|
-
entries = fs.readdirSync(dir);
|
|
37
|
-
} catch {
|
|
38
|
-
return out;
|
|
39
|
-
}
|
|
40
|
-
for (const f of entries) {
|
|
41
|
-
if (f.endsWith(".db")) out.add(f.slice(0, -3));
|
|
42
|
-
}
|
|
43
|
-
return out;
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
/** Resolve which of the `candidates` DB ids is held open by the process tree
|
|
47
|
-
* rooted at `rootPid`. Used to disambiguate concurrent agy runs.
|
|
48
|
-
*
|
|
49
|
-
* Returns the single matching id, or null when none / several are open, when
|
|
50
|
-
* /proc is unavailable, or when reading fails. Injectable so tests can stub
|
|
51
|
-
* it without touching the filesystem. */
|
|
52
|
-
export type OpenDbResolver = (
|
|
53
|
-
rootPid: number,
|
|
54
|
-
dir: string,
|
|
55
|
-
candidates: Set<string>,
|
|
56
|
-
) => string | null;
|
|
57
|
-
|
|
58
|
-
/** Read pid + ppid from /proc/<pid>/stat. Returns null when the entry is gone
|
|
59
|
-
* (process exited) or unparseable. comm may contain spaces and parens, so we
|
|
60
|
-
* split on the LAST ')' rather than naively tokenizing. */
|
|
61
|
-
function readProcStat(pid: number): { pid: number; ppid: number } | null {
|
|
62
|
-
let raw: string;
|
|
63
|
-
try {
|
|
64
|
-
raw = fs.readFileSync(`/proc/${pid}/stat`, "utf8");
|
|
65
|
-
} catch {
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
const closeParen = raw.lastIndexOf(")");
|
|
69
|
-
if (closeParen < 0) return null;
|
|
70
|
-
// After "pid (comm)" come: state ppid pgrp session ...
|
|
71
|
-
const fields = raw.slice(closeParen + 2).trim().split(/\s+/);
|
|
72
|
-
const ppid = Number(fields[1]);
|
|
73
|
-
if (!Number.isFinite(ppid)) return null;
|
|
74
|
-
return { pid, ppid };
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** Collect rootPid and every descendant pid by walking /proc/<pid>/stat
|
|
78
|
-
* parent links. Single pass over /proc building a pid->ppid map, then
|
|
79
|
-
* iterated to closure. */
|
|
80
|
-
function collectDescendants(rootPid: number): Set<number> {
|
|
81
|
-
const out = new Set<number>([rootPid]);
|
|
82
|
-
let entries: string[];
|
|
83
|
-
try {
|
|
84
|
-
entries = fs.readdirSync("/proc");
|
|
85
|
-
} catch {
|
|
86
|
-
return out;
|
|
87
|
-
}
|
|
88
|
-
const ppidOf = new Map<number, number>();
|
|
89
|
-
for (const e of entries) {
|
|
90
|
-
if (!/^\d+$/.test(e)) continue;
|
|
91
|
-
const s = readProcStat(Number(e));
|
|
92
|
-
if (s) ppidOf.set(s.pid, s.ppid);
|
|
93
|
-
}
|
|
94
|
-
let changed = true;
|
|
95
|
-
while (changed) {
|
|
96
|
-
changed = false;
|
|
97
|
-
for (const [pid, ppid] of ppidOf) {
|
|
98
|
-
if (out.has(pid)) continue;
|
|
99
|
-
if (out.has(ppid)) {
|
|
100
|
-
out.add(pid);
|
|
101
|
-
changed = true;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
}
|
|
105
|
-
return out;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/** Linux /proc implementation of OpenDbResolver. Scans every FD symlink in
|
|
109
|
-
* the root process's tree and returns the one candidate .db that is open.
|
|
110
|
-
* Non-authoritative when zero or >1 candidates are open - caller fails safe. */
|
|
111
|
-
export const procTreeOpenDbResolver: OpenDbResolver = (rootPid, dir, candidates) => {
|
|
112
|
-
if (candidates.size <= 1) return null; // nothing to disambiguate
|
|
113
|
-
if (process.platform !== "linux") return null; // /proc/<pid>/fd is Linux-only
|
|
114
|
-
const dirResolved = safeRealpath(dir);
|
|
115
|
-
const tree = collectDescendants(rootPid);
|
|
116
|
-
const found = new Set<string>();
|
|
117
|
-
for (const pid of tree) {
|
|
118
|
-
let fds: string[];
|
|
119
|
-
try {
|
|
120
|
-
fds = fs.readdirSync(`/proc/${pid}/fd`);
|
|
121
|
-
} catch {
|
|
122
|
-
continue; // process gone or fd dir unreadable
|
|
123
|
-
}
|
|
124
|
-
for (const fd of fds) {
|
|
125
|
-
let target: string;
|
|
126
|
-
try {
|
|
127
|
-
target = fs.readlinkSync(`/proc/${pid}/fd/${fd}`);
|
|
128
|
-
} catch {
|
|
129
|
-
continue;
|
|
130
|
-
}
|
|
131
|
-
// agy also holds .db-wal / .db-shm open; only the base .db matters.
|
|
132
|
-
const base = path.basename(target);
|
|
133
|
-
if (!base.endsWith(".db")) continue;
|
|
134
|
-
if (dirResolved && safeRealpath(path.dirname(target)) !== dirResolved) continue;
|
|
135
|
-
const id = base.slice(0, -3);
|
|
136
|
-
if (candidates.has(id)) found.add(id);
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
if (found.size === 1) return [...found][0] ?? null;
|
|
140
|
-
return null;
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
/** fs.realpathSync that swallows ENOENT (dir may not be canonicalized yet). */
|
|
144
|
-
function safeRealpath(p: string): string | null {
|
|
145
|
-
try {
|
|
146
|
-
return fs.realpathSync(p);
|
|
147
|
-
} catch {
|
|
148
|
-
return null;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
/** Options for {@link newConversationId}. */
|
|
153
|
-
export interface BindOptions {
|
|
154
|
-
/** Pid of the spawned agy (the process we want to bind to). When set and
|
|
155
|
-
* multiple candidate DBs exist, the process-tree FD resolver tries to
|
|
156
|
-
* pick ours. Omit to keep the legacy fail-safe behavior. */
|
|
157
|
-
pid?: number;
|
|
158
|
-
/** Override resolver (tests). Defaults to the Linux /proc scanner. */
|
|
159
|
-
resolveOpenDb?: OpenDbResolver;
|
|
160
|
-
/** Invoked when the snapshot is ambiguous (more than one new DB since
|
|
161
|
-
* `before`) and the resolver could not authoritatively pick ours. Lets a
|
|
162
|
-
* caller bound its retry budget to the genuinely-ambiguous case only, so
|
|
163
|
-
* the ordinary "agy hasn't created its DB yet" wait is not counted
|
|
164
|
-
* against it. Not invoked when there is nothing new yet, or when exactly
|
|
165
|
-
* one new DB binds, or when the resolver succeeds. */
|
|
166
|
-
onAmbiguous?: () => void;
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/** Find the conversation id created since `before`. Returns null when none
|
|
170
|
-
* appeared, or when several appeared and we cannot authoritatively tie one
|
|
171
|
-
* to our process. Pass `opts.pid` to enable concurrent-run disambiguation. */
|
|
172
|
-
export function newConversationId(
|
|
173
|
-
dir: string,
|
|
174
|
-
before: Set<string>,
|
|
175
|
-
opts: BindOptions = {},
|
|
176
|
-
): string | null {
|
|
177
|
-
const after = snapshotConversations(dir);
|
|
178
|
-
const created = [...after].filter((id) => !before.has(id));
|
|
179
|
-
if (created.length === 0) return null;
|
|
180
|
-
if (created.length === 1) return created[0] ?? null;
|
|
181
|
-
// Ambiguous: more than one new DB since the snapshot. Try to authoritatively
|
|
182
|
-
// identify ours via the spawned process's open files. Fail safe to null
|
|
183
|
-
// (refuse to bind) when the resolver can't pick exactly one.
|
|
184
|
-
if (opts.pid !== undefined) {
|
|
185
|
-
const resolve = opts.resolveOpenDb ?? procTreeOpenDbResolver;
|
|
186
|
-
const hit = resolve(opts.pid, dir, new Set(created));
|
|
187
|
-
if (hit && created.includes(hit)) return hit;
|
|
188
|
-
}
|
|
189
|
-
opts.onAmbiguous?.();
|
|
190
|
-
return null;
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
/** Path to the DB file for a given conversation id. */
|
|
194
|
-
export function conversationDbPath(
|
|
195
|
-
id: string,
|
|
196
|
-
dir: string = CONVERSATIONS_DIR,
|
|
197
|
-
): string {
|
|
198
|
-
return path.join(dir, `${id}.db`);
|
|
199
|
-
}
|