@estebanforge/pi-antigravity-bridge 1.8.7 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +28 -0
- package/README.md +3 -2
- package/extensions/index.ts +7 -3
- package/package.json +2 -2
- package/src/acp/driver.ts +7 -0
- package/src/acp/events.ts +40 -14
- package/src/acp/setup.ts +7 -1
- package/src/ask-tool.ts +573 -235
- package/src/driver-types.ts +2 -1
- package/src/driver.ts +45 -13
- package/src/mcp-registration.ts +6 -1
- package/src/models.ts +4 -6
- package/src/prompt-images.ts +92 -0
- package/src/provider.ts +104 -12
- package/src/queued-messages.ts +171 -0
- package/src/stream-sanitizer.ts +180 -0
package/src/ask-tool.ts
CHANGED
|
@@ -21,9 +21,13 @@ 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
|
+
BackgroundRunRegistry,
|
|
25
|
+
backgroundFlagText,
|
|
24
26
|
buildAgyArgs,
|
|
25
27
|
buildFinalPrompt,
|
|
26
28
|
buildTimeBudgetNotice,
|
|
29
|
+
createStopHandler,
|
|
30
|
+
createWakeSender,
|
|
27
31
|
MAX_TIMEOUT_MINUTES,
|
|
28
32
|
CONVERSATIONS_DIR,
|
|
29
33
|
CONV_ID_RE,
|
|
@@ -33,6 +37,7 @@ import {
|
|
|
33
37
|
parseModelLine,
|
|
34
38
|
resolveAgyModel,
|
|
35
39
|
snapshotConversations,
|
|
40
|
+
summarizePrompt,
|
|
36
41
|
type ModelEntry,
|
|
37
42
|
type Mode,
|
|
38
43
|
} from "@estebanforge/pi-ask-shared";
|
|
@@ -165,6 +170,266 @@ export function stageReviewerAgent(root: string): { name: string; dir: string }
|
|
|
165
170
|
|
|
166
171
|
const sleep = (ms: number) => new Promise<void>((r) => setTimeout(r, ms));
|
|
167
172
|
|
|
173
|
+
// --- One-shot agy run -------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
type AskLogFn = (event: string, data?: unknown, level?: "debug" | "info" | "warn" | "error") => void;
|
|
176
|
+
|
|
177
|
+
/** Terminal result of one `agy -p` lifecycle. */
|
|
178
|
+
interface AgyOutcome {
|
|
179
|
+
exitCode: number;
|
|
180
|
+
aborted: boolean;
|
|
181
|
+
timedOut: boolean;
|
|
182
|
+
/** Full stdout, trimmed. Empty means the headless auto-deny failure mode. */
|
|
183
|
+
answerText: string;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Tool-result shape the execute path returns; keeps the background helper's literals narrow. */
|
|
187
|
+
interface AskToolResult {
|
|
188
|
+
content: Array<{ type: "text"; text: string }>;
|
|
189
|
+
details: AgyDetails;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Spawns `agy -p` and resolves when the process tree closes. Blocking and
|
|
194
|
+
* background runs share this lifecycle. Owns the delegation-suppression
|
|
195
|
+
* window for the WHOLE run: agy watches the global mcp_config and
|
|
196
|
+
* hot-reloads it, so a mid-run re-enable pokes the live delegation
|
|
197
|
+
* (observed live 2026-09-09). Background runs outlive execute()'s return,
|
|
198
|
+
* which is why the window lives here and not in the caller's finally.
|
|
199
|
+
*/
|
|
200
|
+
async function runAgyProcess(o: {
|
|
201
|
+
binary: string;
|
|
202
|
+
args: string[];
|
|
203
|
+
cwd: string;
|
|
204
|
+
timeoutMin: number;
|
|
205
|
+
startAt: number;
|
|
206
|
+
/** Mutated in place: conversationId discovery, stderr, outcome fields. */
|
|
207
|
+
details: AgyDetails;
|
|
208
|
+
isContinuation: boolean;
|
|
209
|
+
snapshot: ReturnType<typeof snapshotConversations> | null;
|
|
210
|
+
background: boolean;
|
|
211
|
+
/** Blocking passes pi's per-run signal; background passes none on purpose: a later Esc must not kill a detached run. */
|
|
212
|
+
signal?: AbortSignal;
|
|
213
|
+
/** Blocking only: throttled progress partials. */
|
|
214
|
+
onPartial?: (text: string) => void;
|
|
215
|
+
/** Background only: hands over the tree-kill switch as soon as it exists. */
|
|
216
|
+
registerKill?: (kill: () => void) => void;
|
|
217
|
+
/** Background only: the child closed; discovery/classification still running. Arms the stop handler's finishing guard. */
|
|
218
|
+
onClose?: () => void;
|
|
219
|
+
log?: AskLogFn;
|
|
220
|
+
}): Promise<AgyOutcome> {
|
|
221
|
+
const restoreBridge = acquireBridgeSuppression();
|
|
222
|
+
let bridgeReleased = false;
|
|
223
|
+
const releaseBridge = () => {
|
|
224
|
+
if (bridgeReleased) return;
|
|
225
|
+
bridgeReleased = true;
|
|
226
|
+
restoreBridge();
|
|
227
|
+
};
|
|
228
|
+
let out = "";
|
|
229
|
+
// Local: o.snapshot narrowing does not survive into the bind-poll closure.
|
|
230
|
+
const snapshot = o.snapshot;
|
|
231
|
+
const statusInterval = o.onPartial
|
|
232
|
+
? setInterval(() => {
|
|
233
|
+
const elapsed = Math.floor((Date.now() - o.startAt) / 1000);
|
|
234
|
+
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
235
|
+
const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
|
|
236
|
+
o.onPartial!(text);
|
|
237
|
+
}, STATUS_INTERVAL_MS)
|
|
238
|
+
: null;
|
|
239
|
+
|
|
240
|
+
try {
|
|
241
|
+
// Bind the conversation id DURING the run (agy is alive then) so the
|
|
242
|
+
// pid-based /proc FD resolver can disambiguate when a concurrent agy
|
|
243
|
+
// also drops a new .db. Awaited after the run; the post-exit loop
|
|
244
|
+
// below is the fallback for runs that exit before the poll binds.
|
|
245
|
+
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
246
|
+
const outcome = await new Promise<{ exitCode: number; aborted: boolean; timedOut: boolean }>((resolveP, rejectP) => {
|
|
247
|
+
const proc = spawn(o.binary, o.args, {
|
|
248
|
+
cwd: o.cwd,
|
|
249
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
250
|
+
shell: false,
|
|
251
|
+
detached: true,
|
|
252
|
+
});
|
|
253
|
+
proc.stdout?.setEncoding("utf8");
|
|
254
|
+
proc.stderr?.setEncoding("utf8");
|
|
255
|
+
// Valve: a runaway child must not grow these without bound over a
|
|
256
|
+
// detached run's lifetime (timeouts can reach days).
|
|
257
|
+
proc.stdout?.on("data", (d: string) => {
|
|
258
|
+
if (out.length < 1_000_000) out += d;
|
|
259
|
+
});
|
|
260
|
+
proc.stderr?.on("data", (d: string) => {
|
|
261
|
+
if (o.details.stderr.length < 65_536) o.details.stderr += d;
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
if (!o.isContinuation && snapshot && proc.pid) {
|
|
265
|
+
bindDuringRun = (async () => {
|
|
266
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
267
|
+
if (o.details.conversationId) return;
|
|
268
|
+
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
269
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
270
|
+
pid: proc.pid,
|
|
271
|
+
});
|
|
272
|
+
if (found) {
|
|
273
|
+
o.details.conversationId = found;
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
277
|
+
}
|
|
278
|
+
})().catch(() => {
|
|
279
|
+
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
284
|
+
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
285
|
+
let settled = false;
|
|
286
|
+
let timedOut = false;
|
|
287
|
+
|
|
288
|
+
const killTree = () => {
|
|
289
|
+
try {
|
|
290
|
+
if (proc.pid) process.kill(-proc.pid, "SIGTERM");
|
|
291
|
+
} catch {
|
|
292
|
+
/* process group already gone */
|
|
293
|
+
}
|
|
294
|
+
if (!sigkillTimer) {
|
|
295
|
+
sigkillTimer = setTimeout(() => {
|
|
296
|
+
try {
|
|
297
|
+
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
298
|
+
} catch {
|
|
299
|
+
/* give up */
|
|
300
|
+
}
|
|
301
|
+
}, GRACE_AFTER_TIMEOUT_MS);
|
|
302
|
+
}
|
|
303
|
+
};
|
|
304
|
+
|
|
305
|
+
// Hand the tree-kill switch to the background registry before the
|
|
306
|
+
// watchdog arms, so /agy-stop can land the moment the run exists.
|
|
307
|
+
o.registerKill?.(killTree);
|
|
308
|
+
|
|
309
|
+
const cleanup = () => {
|
|
310
|
+
if (watchdog) clearTimeout(watchdog);
|
|
311
|
+
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
312
|
+
if (o.signal) o.signal.removeEventListener("abort", onAbort);
|
|
313
|
+
releaseBridge();
|
|
314
|
+
o.onClose?.();
|
|
315
|
+
};
|
|
316
|
+
const onAbort = () => killTree();
|
|
317
|
+
|
|
318
|
+
watchdog = setTimeout(() => {
|
|
319
|
+
timedOut = true;
|
|
320
|
+
killTree();
|
|
321
|
+
}, o.timeoutMin * 60_000);
|
|
322
|
+
|
|
323
|
+
if (o.signal) {
|
|
324
|
+
if (o.signal.aborted) killTree();
|
|
325
|
+
else o.signal.addEventListener("abort", onAbort, { once: true });
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const finish = (code: number | null) => {
|
|
329
|
+
if (settled) return;
|
|
330
|
+
settled = true;
|
|
331
|
+
cleanup();
|
|
332
|
+
resolveP({ exitCode: code ?? 0, aborted: !!o.signal?.aborted, timedOut });
|
|
333
|
+
};
|
|
334
|
+
|
|
335
|
+
proc.on("error", (err) => {
|
|
336
|
+
cleanup();
|
|
337
|
+
rejectP(err);
|
|
338
|
+
});
|
|
339
|
+
// Settle on close, never exit: exit can strand unread stdout
|
|
340
|
+
// (truncated answer) and would clear the SIGKILL escalation while
|
|
341
|
+
// group members are still dying.
|
|
342
|
+
proc.on("close", finish);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
// Let the during-run bind poll finish (it bails immediately once agy
|
|
346
|
+
// has exited, so this rarely blocks).
|
|
347
|
+
await bindDuringRun;
|
|
348
|
+
|
|
349
|
+
o.details.exitCode = outcome.exitCode;
|
|
350
|
+
o.details.aborted = outcome.aborted;
|
|
351
|
+
o.details.timedOut = outcome.timedOut;
|
|
352
|
+
// details ride the tool result and are persisted in session files;
|
|
353
|
+
// stderr can carry auth material, so it is scrubbed at this boundary.
|
|
354
|
+
o.details.stderr = redactText(o.details.stderr);
|
|
355
|
+
o.details.durationMs = Date.now() - o.startAt;
|
|
356
|
+
o.log?.(
|
|
357
|
+
"ask-end",
|
|
358
|
+
{ exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !out.trim(), background: o.background, durationMs: o.details.durationMs, conversationId: o.details.conversationId },
|
|
359
|
+
outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !out.trim() ? "warn" : "info",
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
if (!o.isContinuation && !o.details.conversationId && snapshot) {
|
|
363
|
+
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
364
|
+
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
365
|
+
if (found) {
|
|
366
|
+
o.details.conversationId = found;
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
await sleep(DISCOVERY_POLL_MS);
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
return { ...outcome, answerText: out.trim() };
|
|
374
|
+
} finally {
|
|
375
|
+
if (statusInterval) clearInterval(statusInterval);
|
|
376
|
+
// Belt and braces: cleanup() already releases on close/error; this
|
|
377
|
+
// covers paths that never reached the process (sync spawn throw).
|
|
378
|
+
releaseBridge();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Builds the model/user-facing answer text for every terminal outcome.
|
|
384
|
+
* Pure except the empty-output marker it stamps on details. Blocking and
|
|
385
|
+
* background share it so both surfaces read identically.
|
|
386
|
+
*/
|
|
387
|
+
function shapeFinalText(details: AgyDetails, outcome: AgyOutcome, timeoutMin: number): string {
|
|
388
|
+
const text = outcome.answerText;
|
|
389
|
+
|
|
390
|
+
if (outcome.aborted) {
|
|
391
|
+
return text ? `agy was aborted. Partial output:\n\n${text}` : "agy was aborted before producing output.";
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
if (outcome.timedOut) {
|
|
395
|
+
const note = `agy exceeded the ${timeoutMin}m timeout and was killed`;
|
|
396
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
if (outcome.exitCode !== 0) {
|
|
400
|
+
// stderr can carry auth material; the note is chat-visible.
|
|
401
|
+
const stderr = redactText(details.stderr.trim());
|
|
402
|
+
const note = stderr
|
|
403
|
+
? `agy exited with status ${outcome.exitCode}: ${stderr}`
|
|
404
|
+
: `agy exited with status ${outcome.exitCode}`;
|
|
405
|
+
return text ? `${text}\n\n[${note}]` : note;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
// Exit 0 with nothing on stdout is still a failure for the
|
|
409
|
+
// delegator: headless agy auto-denies a permission-gated tool call
|
|
410
|
+
// (e.g. the command gate in plan mode), prints the reason only to
|
|
411
|
+
// stderr, and ends cleanly. Falling through to the success path here
|
|
412
|
+
// returned just the conversation footer, which read as an empty
|
|
413
|
+
// success (silent-failure bug found 2026-09-25).
|
|
414
|
+
if (!text) {
|
|
415
|
+
details.empty = true;
|
|
416
|
+
const stderr = redactText(details.stderr.trim());
|
|
417
|
+
const note = [
|
|
418
|
+
"agy exited cleanly but produced no output.",
|
|
419
|
+
stderr ? `stderr: ${stderr}` : null,
|
|
420
|
+
"Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
|
|
421
|
+
]
|
|
422
|
+
.filter(Boolean)
|
|
423
|
+
.join(" ");
|
|
424
|
+
return note;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
const footer = details.conversationId
|
|
428
|
+
? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
|
|
429
|
+
: "";
|
|
430
|
+
return text + footer;
|
|
431
|
+
}
|
|
432
|
+
|
|
168
433
|
// --- Registration ----------------------------------------------------------
|
|
169
434
|
|
|
170
435
|
/** Register the AskAntigravity tool. Call once from the extension entry.
|
|
@@ -184,6 +449,209 @@ export async function registerAskAntigravityTool(
|
|
|
184
449
|
} catch {
|
|
185
450
|
// a sweep failure must never block registration
|
|
186
451
|
}
|
|
452
|
+
|
|
453
|
+
// --- Background-run state (one set per registration/load) ----------------
|
|
454
|
+
// Pi wipes module state on /new, /resume, /fork and /reload: an in-flight
|
|
455
|
+
// background run is killed and its result is never delivered. Accepted
|
|
456
|
+
// trade-off, same as the standalone pi-ask-antigravity extension.
|
|
457
|
+
const registry = new BackgroundRunRegistry({ toolName: "ask-antigravity" });
|
|
458
|
+
const kills = new Map<string, () => void>();
|
|
459
|
+
// Staged-artifact cleanup (contextFile + reviewer agent dir), keyed by run:
|
|
460
|
+
// the session_shutdown backstop runs these when a hard exit would skip the
|
|
461
|
+
// close-path cleanup.
|
|
462
|
+
const cleanups = new Map<string, () => void>();
|
|
463
|
+
// One run per conversationId at a time: two runs resuming the same
|
|
464
|
+
// conversation interleave their turns inside agy's SQLite state.
|
|
465
|
+
const busyHandles = new Set<string>();
|
|
466
|
+
// The child closed but the answer is not settled yet (discovery +
|
|
467
|
+
// classification): a stop in this window must refuse instead of settling
|
|
468
|
+
// failed and discarding a computed answer.
|
|
469
|
+
const finishing = new Set<string>();
|
|
470
|
+
// The wake is chat-visible and persisted: run it through the bridge's
|
|
471
|
+
// secret redactor like every other reassembled-output boundary.
|
|
472
|
+
const sendWake = createWakeSender(pi, {
|
|
473
|
+
customType: "ask-antigravity-result",
|
|
474
|
+
isDisposed: () => registry.isDisposed(),
|
|
475
|
+
redact: redactText,
|
|
476
|
+
});
|
|
477
|
+
const stopBackground = createStopHandler(registry, {
|
|
478
|
+
kill: (run) => kills.get(run.runId)?.(),
|
|
479
|
+
onStopped: (run) =>
|
|
480
|
+
sendWake({
|
|
481
|
+
toolLabel: "agy",
|
|
482
|
+
runId: run.runId,
|
|
483
|
+
ok: false,
|
|
484
|
+
elapsedS: Math.round((Date.now() - run.startedAt) / 1000),
|
|
485
|
+
error: "stopped via /agy-stop",
|
|
486
|
+
}),
|
|
487
|
+
// The guard keeps /agy-stop from discarding an answer that already
|
|
488
|
+
// finished computing and is riding discovery to its wake.
|
|
489
|
+
guard: (run) =>
|
|
490
|
+
finishing.has(run.runId) ? `Run ${run.runId} already finished; its result is being delivered.` : null,
|
|
491
|
+
});
|
|
492
|
+
pi.on("session_shutdown", () => {
|
|
493
|
+
// Latch first so close-handler wakes stay silent, then kill children,
|
|
494
|
+
// then sweep staged artifacts (a hard exit would skip the close path).
|
|
495
|
+
for (const run of registry.dispose()) kills.get(run.runId)?.();
|
|
496
|
+
for (const fn of cleanups.values()) fn();
|
|
497
|
+
cleanups.clear();
|
|
498
|
+
kills.clear();
|
|
499
|
+
});
|
|
500
|
+
pi.registerCommand("agy-stop", {
|
|
501
|
+
description: "Stop a background AskAntigravity run (id prefix, or the only running one). Usage: /agy-stop [runId]",
|
|
502
|
+
handler: async (args, ctx) => {
|
|
503
|
+
const message = await stopBackground(args ?? "");
|
|
504
|
+
if (ctx.hasUI) ctx.ui.notify(message, "info");
|
|
505
|
+
},
|
|
506
|
+
});
|
|
507
|
+
|
|
508
|
+
/** Fire-and-forget background run: registry slot, detached agy, wake on
|
|
509
|
+
* settle. Mirrors the standalone pi-ask-antigravity extension call for
|
|
510
|
+
* call; only the suppression window and the redaction are bridge-local. */
|
|
511
|
+
const startBackgroundRun = (o: {
|
|
512
|
+
summary: string;
|
|
513
|
+
binary: string;
|
|
514
|
+
args: string[];
|
|
515
|
+
workdir: string;
|
|
516
|
+
timeoutMin: number;
|
|
517
|
+
startAt: number;
|
|
518
|
+
details: AgyDetails;
|
|
519
|
+
contextFile: string | null;
|
|
520
|
+
reviewerAgent: { name: string; dir: string } | null;
|
|
521
|
+
modelLabel: string;
|
|
522
|
+
isContinuation: boolean;
|
|
523
|
+
snapshot: ReturnType<typeof snapshotConversations> | null;
|
|
524
|
+
conversationId?: string;
|
|
525
|
+
}): AskToolResult => {
|
|
526
|
+
const cleanupStaging = () => {
|
|
527
|
+
// Both staged artifacts must outlive the execute() return in
|
|
528
|
+
// background mode: agy reads them mid-run.
|
|
529
|
+
if (o.contextFile) {
|
|
530
|
+
try {
|
|
531
|
+
fs.unlinkSync(o.contextFile);
|
|
532
|
+
} catch {}
|
|
533
|
+
}
|
|
534
|
+
if (o.reviewerAgent) {
|
|
535
|
+
try {
|
|
536
|
+
fs.rmSync(o.reviewerAgent.dir, { recursive: true, force: true });
|
|
537
|
+
} catch {}
|
|
538
|
+
}
|
|
539
|
+
};
|
|
540
|
+
registry.sweep();
|
|
541
|
+
if (o.conversationId && busyHandles.has(o.conversationId)) {
|
|
542
|
+
cleanupStaging();
|
|
543
|
+
return {
|
|
544
|
+
content: [
|
|
545
|
+
{
|
|
546
|
+
type: "text",
|
|
547
|
+
text: `background refused: conversation ${o.conversationId} already has a background run. Wait for its wake message, or use blocking mode.`,
|
|
548
|
+
},
|
|
549
|
+
],
|
|
550
|
+
details: { ...o.details, refused: true },
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
if (o.conversationId) busyHandles.add(o.conversationId);
|
|
554
|
+
let run;
|
|
555
|
+
try {
|
|
556
|
+
run = registry.start(summarizePrompt(o.summary));
|
|
557
|
+
} catch (err) {
|
|
558
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
559
|
+
cleanupStaging();
|
|
560
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
561
|
+
return {
|
|
562
|
+
content: [
|
|
563
|
+
{
|
|
564
|
+
type: "text",
|
|
565
|
+
text: `background refused: ${msg}. Use blocking (omit background) or free a slot with /agy-stop.`,
|
|
566
|
+
},
|
|
567
|
+
],
|
|
568
|
+
details: { ...o.details, refused: true },
|
|
569
|
+
};
|
|
570
|
+
}
|
|
571
|
+
const runId = run.runId;
|
|
572
|
+
cleanups.set(runId, cleanupStaging);
|
|
573
|
+
log?.(
|
|
574
|
+
"ask-start",
|
|
575
|
+
{ model: o.details.resolvedModel, thinking: o.details.thinking, mode: o.details.mode, digest: o.details.digest, continue: o.isContinuation, timeoutMin: o.timeoutMin, background: true },
|
|
576
|
+
"info",
|
|
577
|
+
);
|
|
578
|
+
void runAgyProcess({
|
|
579
|
+
binary: o.binary,
|
|
580
|
+
args: o.args,
|
|
581
|
+
cwd: o.workdir,
|
|
582
|
+
timeoutMin: o.timeoutMin,
|
|
583
|
+
startAt: o.startAt,
|
|
584
|
+
details: o.details,
|
|
585
|
+
isContinuation: o.isContinuation,
|
|
586
|
+
snapshot: o.snapshot,
|
|
587
|
+
background: true,
|
|
588
|
+
registerKill: (kill) => {
|
|
589
|
+
kills.set(runId, kill);
|
|
590
|
+
},
|
|
591
|
+
onClose: () => finishing.add(runId),
|
|
592
|
+
log,
|
|
593
|
+
})
|
|
594
|
+
.then((outcome) => {
|
|
595
|
+
kills.delete(runId);
|
|
596
|
+
const finalText = shapeFinalText(o.details, outcome, o.timeoutMin);
|
|
597
|
+
const elapsedS = Math.round((Date.now() - o.startAt) / 1000);
|
|
598
|
+
const handle = o.details.conversationId ? `conversationId=${o.details.conversationId}` : undefined;
|
|
599
|
+
const failed = outcome.timedOut || outcome.exitCode !== 0 || !outcome.answerText;
|
|
600
|
+
// The settle latch makes /agy-stop and shutdown win races
|
|
601
|
+
// against this handler: a false return means their wake went out.
|
|
602
|
+
const settled = registry.settle(runId, {
|
|
603
|
+
status: failed ? "failed" : "done",
|
|
604
|
+
output: finalText,
|
|
605
|
+
handle,
|
|
606
|
+
error: outcome.timedOut
|
|
607
|
+
? `timeout after ${o.timeoutMin}m`
|
|
608
|
+
: outcome.exitCode !== 0
|
|
609
|
+
? `exit status ${outcome.exitCode}`
|
|
610
|
+
: !outcome.answerText
|
|
611
|
+
? "agy exited cleanly but produced no output"
|
|
612
|
+
: undefined,
|
|
613
|
+
});
|
|
614
|
+
if (!settled) return;
|
|
615
|
+
if (outcome.timedOut) {
|
|
616
|
+
const partial = outcome.answerText
|
|
617
|
+
? ` Partial output: ${redactText(outcome.answerText).slice(0, 400)}`
|
|
618
|
+
: "";
|
|
619
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: `timeout after ${o.timeoutMin}m.${partial}`, handle });
|
|
620
|
+
} else if (outcome.exitCode !== 0) {
|
|
621
|
+
// stderr can carry auth material; the reason is chat-visible.
|
|
622
|
+
const reason = redactText(o.details.stderr.trim()).slice(0, 400) || `exit status ${outcome.exitCode}`;
|
|
623
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: reason, handle });
|
|
624
|
+
} else if (!outcome.answerText) {
|
|
625
|
+
sendWake({ toolLabel: "agy", runId, ok: false, elapsedS, error: "agy exited cleanly but produced no output (headless auto-deny?)", handle });
|
|
626
|
+
} else {
|
|
627
|
+
sendWake({ toolLabel: "agy", runId, ok: true, elapsedS, handle }, finalText);
|
|
628
|
+
}
|
|
629
|
+
})
|
|
630
|
+
.catch((err) => {
|
|
631
|
+
kills.delete(runId);
|
|
632
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
633
|
+
// Settle first: an /agy-stop that won the race owns the wake.
|
|
634
|
+
const settled = registry.settle(runId, { status: "failed", error: msg });
|
|
635
|
+
if (!settled) return;
|
|
636
|
+
sendWake({ toolLabel: "agy", runId, ok: false, error: `failed to run agy: ${msg}` });
|
|
637
|
+
})
|
|
638
|
+
.finally(() => {
|
|
639
|
+
cleanups.delete(runId);
|
|
640
|
+
finishing.delete(runId);
|
|
641
|
+
cleanupStaging();
|
|
642
|
+
if (o.conversationId) busyHandles.delete(o.conversationId);
|
|
643
|
+
});
|
|
644
|
+
return {
|
|
645
|
+
content: [
|
|
646
|
+
{
|
|
647
|
+
type: "text",
|
|
648
|
+
text: `Background run ${runId} started (model=${o.modelLabel}). The result arrives as a message when agy finishes; the resume handle (conversationId) comes with it when agy reports one. Do not poll. Continue with other work or end the turn.`,
|
|
649
|
+
},
|
|
650
|
+
],
|
|
651
|
+
details: { ...o.details },
|
|
652
|
+
};
|
|
653
|
+
};
|
|
654
|
+
|
|
187
655
|
pi.registerTool({
|
|
188
656
|
name: "AskAntigravity",
|
|
189
657
|
label: "Ask Antigravity",
|
|
@@ -245,8 +713,8 @@ export async function registerAskAntigravityTool(
|
|
|
245
713
|
),
|
|
246
714
|
background: Type.Optional(
|
|
247
715
|
Type.Boolean({
|
|
248
|
-
description:
|
|
249
|
-
|
|
716
|
+
description: backgroundFlagText("conversationId"),
|
|
717
|
+
default: false,
|
|
250
718
|
}),
|
|
251
719
|
),
|
|
252
720
|
}),
|
|
@@ -277,6 +745,7 @@ export async function registerAskAntigravityTool(
|
|
|
277
745
|
if (useDigest) tags.push("digest");
|
|
278
746
|
if (isContinue) tags.push("continue");
|
|
279
747
|
if (args.includeContext) tags.push("context=full");
|
|
748
|
+
if (args.background) tags.push("bg");
|
|
280
749
|
|
|
281
750
|
let text = theme.fg("mdLink", theme.bold("AskAntigravity "));
|
|
282
751
|
text += `${theme.fg("accent", `[${tags.join(", ")}]`)} `;
|
|
@@ -299,7 +768,7 @@ export async function registerAskAntigravityTool(
|
|
|
299
768
|
|
|
300
769
|
const body = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
301
770
|
const errored =
|
|
302
|
-
|
|
771
|
+
!!d?.refused || d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
|
|
303
772
|
|
|
304
773
|
let text = errored
|
|
305
774
|
? theme.fg("error", "✗ AskAntigravity error")
|
|
@@ -356,22 +825,6 @@ export async function registerAskAntigravityTool(
|
|
|
356
825
|
details: emptyDetails(),
|
|
357
826
|
};
|
|
358
827
|
}
|
|
359
|
-
// Background refusal (fail-closed): a wake from this tool would land in
|
|
360
|
-
// the pi session even when the CALLER is agy (nested delegation via the
|
|
361
|
-
// bridge catalog), and the bridge already owns an async delegation flow
|
|
362
|
-
// (early-ack + bridge_poll_result). The standalone pi-ask-antigravity
|
|
363
|
-
// extension is the background-capable tool.
|
|
364
|
-
if (params.background) {
|
|
365
|
-
return {
|
|
366
|
-
content: [
|
|
367
|
-
{
|
|
368
|
-
type: "text",
|
|
369
|
-
text: 'background is not supported by the bridge\'s AskAntigravity: the answer must return synchronously to the caller. Long delegations are already covered by the bridge\'s own async flow (early-ack + bridge_poll_result); for an unattended peer run, launch agy directly in a terminal instead. Call again without background.',
|
|
370
|
-
},
|
|
371
|
-
],
|
|
372
|
-
details: emptyDetails(),
|
|
373
|
-
};
|
|
374
|
-
}
|
|
375
828
|
const requestedModel = (params.model as string | undefined) ?? config.defaultModel;
|
|
376
829
|
if (typeof params.model === "string" && params.model.trim().startsWith("-")) {
|
|
377
830
|
return {
|
|
@@ -496,7 +949,7 @@ export async function registerAskAntigravityTool(
|
|
|
496
949
|
|
|
497
950
|
log?.(
|
|
498
951
|
"ask-start",
|
|
499
|
-
{ model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin },
|
|
952
|
+
{ model: resolved.model, thinking: resolved.effort ?? config.defaultThinking, mode, digest: useDigest, continue: isContinuation, timeoutMin, background: false },
|
|
500
953
|
"info",
|
|
501
954
|
);
|
|
502
955
|
// Build argv via the shared peer adapter (see buildAgyArgs there for
|
|
@@ -532,246 +985,126 @@ export async function registerAskAntigravityTool(
|
|
|
532
985
|
};
|
|
533
986
|
|
|
534
987
|
const binary = process.env.AGY_BIN || "agy";
|
|
535
|
-
let out = "";
|
|
536
|
-
|
|
537
|
-
// Delegation isolation: any agy on this machine discovers MCP servers
|
|
538
|
-
// from the global mcp_config.json, so this spawned `agy -p` would find
|
|
539
|
-
// live pi-bridge-* entries and call tools the host bridge cannot serve
|
|
540
|
-
// outside a live provider turn ("no active antigravity turn"). agy also
|
|
541
|
-
// WATCHES that file (ReloadMcpConfig): re-enabling mid-run pokes the live
|
|
542
|
-
// delegation to reconnect. The old 5s grace timer did exactly that -
|
|
543
|
-
// observed live 2026-09-09 as a call-tool-fail ~50s into a delegation -
|
|
544
|
-
// so the entries now stay hidden for the WHOLE delegated run. The release
|
|
545
|
-
// fires only on process close/error (cleanup + finally below); if pi
|
|
546
|
-
// itself dies first, session start heals the file
|
|
547
|
-
// (healBridgeSuppression in extensions/index.ts, marker-aware). Cross-
|
|
548
|
-
// process coordination rides the suppression marker in mcp-registration.
|
|
549
|
-
// Refcounted, so overlapping delegations in this process cannot release
|
|
550
|
-
// each other's window early. A refused config fail-opens to the status quo.
|
|
551
|
-
const restoreBridge = acquireBridgeSuppression();
|
|
552
|
-
|
|
553
|
-
const statusInterval = onUpdate
|
|
554
|
-
? setInterval(() => {
|
|
555
|
-
const elapsed = Math.floor((Date.now() - start) / 1000);
|
|
556
|
-
const tail = out.slice(-STATUS_TAIL_CHARS);
|
|
557
|
-
const text = tail ? `(running ${elapsed}s)\n…${tail}` : `(running ${elapsed}s)`;
|
|
558
|
-
onUpdate({
|
|
559
|
-
content: [{ type: "text", text }],
|
|
560
|
-
details: { ...details, durationMs: Date.now() - start },
|
|
561
|
-
});
|
|
562
|
-
}, STATUS_INTERVAL_MS)
|
|
563
|
-
: null;
|
|
564
988
|
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
let bindDuringRun: Promise<void> = Promise.resolve();
|
|
571
|
-
const outcome = await new Promise<{
|
|
572
|
-
exitCode: number;
|
|
573
|
-
aborted: boolean;
|
|
574
|
-
timedOut: boolean;
|
|
575
|
-
}>((resolveP, rejectP) => {
|
|
576
|
-
const proc = spawn(binary, args, {
|
|
577
|
-
cwd,
|
|
578
|
-
stdio: ["ignore", "pipe", "pipe"],
|
|
579
|
-
shell: false,
|
|
580
|
-
detached: true,
|
|
581
|
-
});
|
|
582
|
-
proc.stdout?.setEncoding("utf8");
|
|
583
|
-
proc.stderr?.setEncoding("utf8");
|
|
584
|
-
proc.stdout?.on("data", (d: string) => (out += d));
|
|
585
|
-
proc.stderr?.on("data", (d: string) => (details.stderr += d));
|
|
586
|
-
|
|
587
|
-
// Concurrent bind: poll for the new id while agy is alive. The FD
|
|
588
|
-
// resolver needs a live process tree, so this stops (and the post-
|
|
589
|
-
// exit fallback below takes over) once agy has exited.
|
|
590
|
-
if (!isContinuation && snapshot && proc.pid) {
|
|
591
|
-
bindDuringRun = (async () => {
|
|
592
|
-
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
593
|
-
if (details.conversationId) return;
|
|
594
|
-
if (proc.exitCode !== null) return; // agy gone: scan useless now
|
|
595
|
-
const found = newConversationId(CONVERSATIONS_DIR, snapshot, {
|
|
596
|
-
pid: proc.pid,
|
|
597
|
-
});
|
|
598
|
-
if (found) {
|
|
599
|
-
details.conversationId = found;
|
|
600
|
-
return;
|
|
601
|
-
}
|
|
602
|
-
await sleep(DISCOVERY_POLL_MS);
|
|
603
|
-
}
|
|
604
|
-
})().catch(() => {
|
|
605
|
-
/* best-effort: a bind error must never fail an otherwise-OK turn */
|
|
606
|
-
});
|
|
607
|
-
}
|
|
608
|
-
|
|
609
|
-
let sigkillTimer: ReturnType<typeof setTimeout> | undefined;
|
|
610
|
-
let watchdog: ReturnType<typeof setTimeout> | undefined;
|
|
611
|
-
let settled = false;
|
|
612
|
-
let timedOut = false;
|
|
613
|
-
|
|
614
|
-
const killTree = () => {
|
|
615
|
-
try {
|
|
616
|
-
if (proc.pid) process.kill(-proc.pid, "SIGTERM");
|
|
617
|
-
} catch {
|
|
618
|
-
/* process group already gone */
|
|
619
|
-
}
|
|
620
|
-
if (!sigkillTimer) {
|
|
621
|
-
sigkillTimer = setTimeout(() => {
|
|
622
|
-
try {
|
|
623
|
-
if (proc.pid) process.kill(-proc.pid, "SIGKILL");
|
|
624
|
-
} catch {
|
|
625
|
-
/* give up */
|
|
626
|
-
}
|
|
627
|
-
}, GRACE_AFTER_TIMEOUT_MS);
|
|
628
|
-
}
|
|
629
|
-
};
|
|
630
|
-
|
|
631
|
-
const cleanup = () => {
|
|
632
|
-
if (watchdog) clearTimeout(watchdog);
|
|
633
|
-
if (sigkillTimer) clearTimeout(sigkillTimer);
|
|
634
|
-
if (signal) signal.removeEventListener("abort", onAbort);
|
|
635
|
-
restoreBridge();
|
|
636
|
-
};
|
|
637
|
-
const onAbort = () => killTree();
|
|
638
|
-
|
|
639
|
-
watchdog = setTimeout(() => {
|
|
640
|
-
timedOut = true;
|
|
641
|
-
killTree();
|
|
642
|
-
}, timeoutMin * 60_000);
|
|
643
|
-
|
|
644
|
-
if (signal) {
|
|
645
|
-
if (signal.aborted) killTree();
|
|
646
|
-
else signal.addEventListener("abort", onAbort, { once: true });
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
const finish = (code: number | null) => {
|
|
650
|
-
if (settled) return;
|
|
651
|
-
settled = true;
|
|
652
|
-
cleanup();
|
|
653
|
-
resolveP({
|
|
654
|
-
exitCode: code ?? 0,
|
|
655
|
-
aborted: !!signal?.aborted,
|
|
656
|
-
timedOut,
|
|
657
|
-
});
|
|
658
|
-
};
|
|
659
|
-
|
|
660
|
-
proc.on("error", (err) => {
|
|
661
|
-
cleanup();
|
|
662
|
-
rejectP(err);
|
|
663
|
-
});
|
|
664
|
-
proc.on("close", finish);
|
|
665
|
-
proc.on("exit", finish);
|
|
666
|
-
});
|
|
667
|
-
|
|
668
|
-
if (statusInterval) clearInterval(statusInterval);
|
|
669
|
-
|
|
670
|
-
// Let the during-run bind poll finish (it bails immediately once agy
|
|
671
|
-
// has exited, so this rarely blocks).
|
|
672
|
-
await bindDuringRun;
|
|
673
|
-
|
|
674
|
-
details.exitCode = outcome.exitCode;
|
|
675
|
-
details.aborted = outcome.aborted;
|
|
676
|
-
details.timedOut = outcome.timedOut;
|
|
677
|
-
details.durationMs = Date.now() - start;
|
|
678
|
-
const text = out.trim();
|
|
679
|
-
log?.(
|
|
680
|
-
"ask-end",
|
|
681
|
-
{ exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !text, durationMs: details.durationMs, conversationId: details.conversationId },
|
|
682
|
-
outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !text ? "warn" : "info",
|
|
683
|
-
);
|
|
684
|
-
|
|
685
|
-
if (!isContinuation && !details.conversationId && snapshot) {
|
|
686
|
-
for (let attempt = 0; attempt < DISCOVERY_POLL_ATTEMPTS; attempt++) {
|
|
687
|
-
const found = newConversationId(CONVERSATIONS_DIR, snapshot);
|
|
688
|
-
if (found) {
|
|
689
|
-
details.conversationId = found;
|
|
690
|
-
break;
|
|
691
|
-
}
|
|
692
|
-
await sleep(DISCOVERY_POLL_MS);
|
|
693
|
-
}
|
|
989
|
+
const cleanupStaging = () => {
|
|
990
|
+
if (contextFile) {
|
|
991
|
+
try {
|
|
992
|
+
fs.unlinkSync(contextFile);
|
|
993
|
+
} catch {}
|
|
694
994
|
}
|
|
995
|
+
if (reviewerAgent) {
|
|
996
|
+
try {
|
|
997
|
+
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
998
|
+
} catch {}
|
|
999
|
+
}
|
|
1000
|
+
};
|
|
695
1001
|
|
|
696
|
-
|
|
1002
|
+
// Background: hand the run to the registry and return at once. The
|
|
1003
|
+
// result travels back through sendMessage (wake), not this call.
|
|
1004
|
+
if (params.background) {
|
|
1005
|
+
if (ctx.mode === "print" || ctx.mode === "json") {
|
|
1006
|
+
cleanupStaging();
|
|
697
1007
|
return {
|
|
698
1008
|
content: [
|
|
699
1009
|
{
|
|
700
1010
|
type: "text",
|
|
701
|
-
text:
|
|
702
|
-
? `agy was aborted. Partial output:\n\n${text}`
|
|
703
|
-
: "agy was aborted before producing output.",
|
|
1011
|
+
text: "background is not available in print/json mode: the process exits before the result message can arrive. Call again without background.",
|
|
704
1012
|
},
|
|
705
1013
|
],
|
|
706
|
-
details,
|
|
1014
|
+
details: { ...details, refused: true },
|
|
707
1015
|
};
|
|
708
1016
|
}
|
|
1017
|
+
return startBackgroundRun({
|
|
1018
|
+
summary: params.prompt,
|
|
1019
|
+
binary,
|
|
1020
|
+
args,
|
|
1021
|
+
workdir: cwd,
|
|
1022
|
+
timeoutMin,
|
|
1023
|
+
startAt: start,
|
|
1024
|
+
details,
|
|
1025
|
+
contextFile,
|
|
1026
|
+
reviewerAgent,
|
|
1027
|
+
modelLabel: resolved.model ?? requestedModel,
|
|
1028
|
+
isContinuation,
|
|
1029
|
+
snapshot,
|
|
1030
|
+
conversationId: isContinuation ? (rawConvId as string) : undefined,
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
709
1033
|
|
|
710
|
-
|
|
711
|
-
|
|
1034
|
+
// One run per conversationId at a time, blocking included: a blocking
|
|
1035
|
+
// resume and a background resume of the same conversation would
|
|
1036
|
+
// interleave turns inside agy's session state. Declared out here so
|
|
1037
|
+
// the finally can see it; set only when THIS call takes the lock.
|
|
1038
|
+
let lockAcquired = false;
|
|
1039
|
+
try {
|
|
1040
|
+
if (isContinuation && busyHandles.has(rawConvId as string)) {
|
|
1041
|
+
cleanupStaging();
|
|
712
1042
|
return {
|
|
713
|
-
content: [
|
|
714
|
-
|
|
1043
|
+
content: [
|
|
1044
|
+
{
|
|
1045
|
+
type: "text",
|
|
1046
|
+
text: `conversation ${rawConvId} already has a run in flight. Wait for its result; if it is a background run, /agy-stop can stop it.`,
|
|
1047
|
+
},
|
|
1048
|
+
],
|
|
1049
|
+
details: emptyDetails(requestedModel, resolved.model),
|
|
715
1050
|
};
|
|
716
1051
|
}
|
|
1052
|
+
lockAcquired = isContinuation;
|
|
1053
|
+
if (lockAcquired) busyHandles.add(rawConvId as string);
|
|
1054
|
+
|
|
1055
|
+
const outcome = await runAgyProcess({
|
|
1056
|
+
binary,
|
|
1057
|
+
args,
|
|
1058
|
+
cwd,
|
|
1059
|
+
timeoutMin,
|
|
1060
|
+
startAt: start,
|
|
1061
|
+
details,
|
|
1062
|
+
isContinuation,
|
|
1063
|
+
snapshot,
|
|
1064
|
+
background: false,
|
|
1065
|
+
signal,
|
|
1066
|
+
onPartial: onUpdate
|
|
1067
|
+
? (text) => {
|
|
1068
|
+
onUpdate({
|
|
1069
|
+
content: [{ type: "text", text }],
|
|
1070
|
+
details: { ...details, durationMs: Date.now() - start },
|
|
1071
|
+
});
|
|
1072
|
+
}
|
|
1073
|
+
: undefined,
|
|
1074
|
+
log,
|
|
1075
|
+
});
|
|
717
1076
|
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
? `agy exited with status ${outcome.exitCode}: ${stderr}`
|
|
723
|
-
: `agy exited with status ${outcome.exitCode}`;
|
|
724
|
-
return {
|
|
725
|
-
content: [{ type: "text", text: text ? `${text}\n\n[${note}]` : note }],
|
|
726
|
-
details,
|
|
727
|
-
};
|
|
1077
|
+
const finalText = shapeFinalText(details, outcome, timeoutMin);
|
|
1078
|
+
|
|
1079
|
+
if (outcome.aborted || outcome.timedOut || outcome.exitCode !== 0) {
|
|
1080
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
728
1081
|
}
|
|
729
1082
|
|
|
730
|
-
// Exit 0 with
|
|
731
|
-
//
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
// returned just the conversation footer, which read as an empty
|
|
735
|
-
// success (silent-failure bug found 2026-09-25).
|
|
736
|
-
if (!text) {
|
|
737
|
-
details.empty = true;
|
|
738
|
-
const stderr = redactText(details.stderr.trim());
|
|
739
|
-
const note = [
|
|
740
|
-
"agy exited cleanly but produced no output.",
|
|
741
|
-
stderr ? `stderr: ${stderr}` : null,
|
|
742
|
-
"Common cause: a tool call needed a permission that headless mode cannot prompt for (typically the command gate in plan mode), so it was auto-denied and the turn ended with no answer. Recovery: retry in plan mode with all needed content inlined in the prompt - plan runs cannot fetch it, commands are denied. Or rerun outside plan mode with skipPermissions, or add your own permissions.allow rules in ~/.gemini/antigravity-cli/settings.json.",
|
|
743
|
-
]
|
|
744
|
-
.filter(Boolean)
|
|
745
|
-
.join(" ");
|
|
746
|
-
return { content: [{ type: "text", text: note }], details };
|
|
1083
|
+
// Exit 0 with no output keeps its dedicated failure result
|
|
1084
|
+
// (headless auto-deny); see shapeFinalText for the note.
|
|
1085
|
+
if (!outcome.answerText) {
|
|
1086
|
+
return { content: [{ type: "text", text: finalText }], details };
|
|
747
1087
|
}
|
|
748
1088
|
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
: ""
|
|
753
|
-
|
|
1089
|
+
// Clear the last partial status line (claude-bridge idiom) so the
|
|
1090
|
+
// running-tail preview doesn't linger under the final answer.
|
|
1091
|
+
onUpdate?.({
|
|
1092
|
+
content: [{ type: "text", text: "" }],
|
|
1093
|
+
details: { ...details },
|
|
1094
|
+
});
|
|
1095
|
+
|
|
1096
|
+
return {
|
|
1097
|
+
content: [{ type: "text", text: finalText }],
|
|
1098
|
+
details,
|
|
1099
|
+
};
|
|
754
1100
|
} catch (err) {
|
|
755
|
-
if (statusInterval) clearInterval(statusInterval);
|
|
756
1101
|
details.durationMs = Date.now() - start;
|
|
757
1102
|
const msg = err instanceof Error ? err.message : String(err);
|
|
758
1103
|
log?.("ask-fail", { error: msg, durationMs: details.durationMs }, "error");
|
|
759
1104
|
return { content: [{ type: "text", text: `failed to run agy: ${msg}` }], details };
|
|
760
|
-
}
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
// covers paths that never reached the process (sync spawn throw).
|
|
764
|
-
restoreBridge();
|
|
765
|
-
if (contextFile) {
|
|
766
|
-
try {
|
|
767
|
-
fs.unlinkSync(contextFile);
|
|
768
|
-
} catch {}
|
|
769
|
-
}
|
|
770
|
-
if (reviewerAgent) {
|
|
771
|
-
try {
|
|
772
|
-
fs.rmSync(reviewerAgent.dir, { recursive: true, force: true });
|
|
773
|
-
} catch {}
|
|
774
|
-
}
|
|
1105
|
+
} finally {
|
|
1106
|
+
if (lockAcquired) busyHandles.delete(rawConvId as string);
|
|
1107
|
+
cleanupStaging();
|
|
775
1108
|
}
|
|
776
1109
|
},
|
|
777
1110
|
});
|
|
@@ -857,6 +1190,9 @@ interface AgyDetails {
|
|
|
857
1190
|
// Exit 0 with no answer on stdout (the empty-output failure branch).
|
|
858
1191
|
// renderResult flips to the error glyph on it.
|
|
859
1192
|
empty?: boolean;
|
|
1193
|
+
// The call was refused before any spawn (validation, lock, cap, kill
|
|
1194
|
+
// switch). renderResult flips to the error glyph on it.
|
|
1195
|
+
refused?: boolean;
|
|
860
1196
|
durationMs: number;
|
|
861
1197
|
stderr: string;
|
|
862
1198
|
}
|
|
@@ -880,5 +1216,7 @@ function emptyDetails(
|
|
|
880
1216
|
timedOut: false,
|
|
881
1217
|
durationMs: 0,
|
|
882
1218
|
stderr: "",
|
|
1219
|
+
// Every emptyDetails caller is a refusal path.
|
|
1220
|
+
refused: true,
|
|
883
1221
|
};
|
|
884
1222
|
}
|