@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.5
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 +27 -0
- package/README.md +6 -8
- package/extensions/index.ts +279 -14
- package/package.json +1 -1
- package/src/acp/driver.ts +3 -3
- package/src/agents.ts +64 -0
- package/src/agy-paths.ts +33 -0
- package/src/agy-version.ts +137 -0
- package/src/artifacts-ui.ts +86 -0
- package/src/artifacts.ts +144 -0
- package/src/ask-tool.ts +38 -7
- package/src/config.ts +10 -0
- package/src/driver-types.ts +8 -2
- package/src/driver.ts +68 -16
- package/src/models.ts +44 -9
- package/src/native-tools.ts +1 -1
- package/src/parked-turn.ts +125 -0
- package/src/provider.ts +14 -1
- package/src/skills.ts +0 -1
- package/src/stream-events.ts +1 -2
- package/src/subagent-roster.ts +245 -0
- package/src/tasks-ui.ts +88 -0
- package/src/tasks.ts +174 -0
- package/src/usage.ts +174 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
// agy CLI version gate.
|
|
2
|
+
//
|
|
3
|
+
// An agy older than MIN_AGY_VERSION fails stream-json turns with raw stream
|
|
4
|
+
// errors that look like bridge bugs, so we detect it once per process and
|
|
5
|
+
// say so plainly (session-start warning + /agy doctor) instead of letting
|
|
6
|
+
// users debug parse noise. Pure functions here; the only subprocess is
|
|
7
|
+
// `agy --version`, memoized per binary because discovery already spawns agy
|
|
8
|
+
// once per load and the version will not change mid-process.
|
|
9
|
+
//
|
|
10
|
+
// The ACP server reports its own version at initialize (agentInfo.version,
|
|
11
|
+
// e.g. "agy_acp_server_1.1.1") and /agy doctor already prints it. ACP build
|
|
12
|
+
// numbering is a different lineage from the CLI, so no floor is enforced
|
|
13
|
+
// there: the one known-good build must not be locked out by a CLI number.
|
|
14
|
+
|
|
15
|
+
import { spawn } from "node:child_process";
|
|
16
|
+
|
|
17
|
+
/** Floor for the stream-json CLI. Matches the protocol grammar our driver
|
|
18
|
+
* and the event mapping rely on; older agy predates verified fields. */
|
|
19
|
+
export const MIN_AGY_VERSION = "1.1.22";
|
|
20
|
+
|
|
21
|
+
const VERSION_TIMEOUT_MS = 5_000;
|
|
22
|
+
|
|
23
|
+
export type AgyVersionStatus =
|
|
24
|
+
| "ok"
|
|
25
|
+
| "unsupported"
|
|
26
|
+
| "development"
|
|
27
|
+
| "invalid"
|
|
28
|
+
| "unavailable";
|
|
29
|
+
|
|
30
|
+
export interface AgyVersionCheck {
|
|
31
|
+
status: AgyVersionStatus;
|
|
32
|
+
/** Extracted triple when status is ok/unsupported; raw dev token for development. */
|
|
33
|
+
version?: string;
|
|
34
|
+
/** Full --version output, for doctor detail. */
|
|
35
|
+
raw: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Pull the first strict triple (`1.2.10`) out of arbitrary version output.
|
|
39
|
+
* Prefixed forms like "agy_acp_server_1.1.1" and banner text both match;
|
|
40
|
+
* anything without a triple is not a version we can gate on. */
|
|
41
|
+
export function parseAgyVersionTriple(text: string): string | undefined {
|
|
42
|
+
return /\d+\.\d+\.\d+/.exec(text)?.[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Numeric triple compare; 1.10.0 > 1.9.9, unlike string compare. */
|
|
46
|
+
export function compareVersionTuples(a: string, b: string): number {
|
|
47
|
+
const pa = a.split(".").map(Number);
|
|
48
|
+
const pb = b.split(".").map(Number);
|
|
49
|
+
for (let i = 0; i < 3; i++) {
|
|
50
|
+
const d = (pa[i] ?? 0) - (pb[i] ?? 0);
|
|
51
|
+
if (d !== 0) return d;
|
|
52
|
+
}
|
|
53
|
+
return 0;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Classify raw `--version` output against a floor. Pure: unit-tested for
|
|
57
|
+
* every status; checkAgyCliVersion wraps this with the subprocess. */
|
|
58
|
+
export function agyVersionVerdict(
|
|
59
|
+
raw: string,
|
|
60
|
+
floor: string = MIN_AGY_VERSION,
|
|
61
|
+
): AgyVersionCheck {
|
|
62
|
+
// Development builds (agy from source) self-identify as dev, HEAD, or the
|
|
63
|
+
// spelled-out word; never gate any of them.
|
|
64
|
+
if (/\b(dev|head|development)\b/i.test(raw))
|
|
65
|
+
return { status: "development", version: "dev", raw };
|
|
66
|
+
const triple = parseAgyVersionTriple(raw);
|
|
67
|
+
if (!triple) return { status: "invalid", raw };
|
|
68
|
+
if (compareVersionTuples(triple, floor) < 0) return { status: "unsupported", version: triple, raw };
|
|
69
|
+
return { status: "ok", version: triple, raw };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const checkCache = new Map<string, Promise<AgyVersionCheck>>();
|
|
73
|
+
|
|
74
|
+
/** Run `binary --version` and classify the result. Memoized per binary for
|
|
75
|
+
* the process lifetime (resetAgyVersionCache clears it for tests). Fail
|
|
76
|
+
* paths: spawn error (ENOENT etc.) and watchdog timeout are "unavailable",
|
|
77
|
+
* a distinct status so doctor can say "could not run" instead of blaming
|
|
78
|
+
* the version string. */
|
|
79
|
+
export function checkAgyCliVersion(binary: string = "agy"): Promise<AgyVersionCheck> {
|
|
80
|
+
const cached = checkCache.get(binary);
|
|
81
|
+
if (cached) return cached;
|
|
82
|
+
const run = new Promise<AgyVersionCheck>((resolve) => {
|
|
83
|
+
let out = "";
|
|
84
|
+
let done = false;
|
|
85
|
+
const finish = (check: AgyVersionCheck) => {
|
|
86
|
+
if (done) return;
|
|
87
|
+
done = true;
|
|
88
|
+
clearTimeout(watchdog);
|
|
89
|
+
resolve(check);
|
|
90
|
+
};
|
|
91
|
+
let proc: ReturnType<typeof spawn>;
|
|
92
|
+
try {
|
|
93
|
+
proc = spawn(binary, ["--version"], { stdio: ["ignore", "pipe", "ignore"], shell: false });
|
|
94
|
+
} catch {
|
|
95
|
+
finish({ status: "unavailable", raw: "" });
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
proc.stdout?.setEncoding("utf8");
|
|
99
|
+
proc.stdout?.on("data", (d: string) => (out = (out + d).slice(0, 4096)));
|
|
100
|
+
proc.on("error", () => finish({ status: "unavailable", raw: "" }));
|
|
101
|
+
// Exit code is ignored on purpose: `agy --version` failures print to
|
|
102
|
+
// stderr (ignored) and leave junk or empty stdout, which classifies as
|
|
103
|
+
// invalid — close enough in effect to unavailable for a warn-only gate.
|
|
104
|
+
proc.on("close", () => finish(agyVersionVerdict(out)));
|
|
105
|
+
const watchdog = setTimeout(() => {
|
|
106
|
+
try {
|
|
107
|
+
proc.kill("SIGKILL");
|
|
108
|
+
} catch {
|
|
109
|
+
/* already gone */
|
|
110
|
+
}
|
|
111
|
+
finish({ status: "unavailable", raw: out });
|
|
112
|
+
}, VERSION_TIMEOUT_MS);
|
|
113
|
+
});
|
|
114
|
+
checkCache.set(binary, run);
|
|
115
|
+
return run;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Test seam: drop all memoized checks. */
|
|
119
|
+
export function resetAgyVersionCache(): void {
|
|
120
|
+
checkCache.clear();
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** One-line doctor rendering. */
|
|
124
|
+
export function describeAgyVersionCheck(check: AgyVersionCheck): string {
|
|
125
|
+
switch (check.status) {
|
|
126
|
+
case "ok":
|
|
127
|
+
return check.version ?? "ok";
|
|
128
|
+
case "unsupported":
|
|
129
|
+
return `${check.version} TOO OLD`;
|
|
130
|
+
case "development":
|
|
131
|
+
return "development build";
|
|
132
|
+
case "invalid":
|
|
133
|
+
return "unreadable version output";
|
|
134
|
+
case "unavailable":
|
|
135
|
+
return "could not run --version";
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// /agy artifacts overlay: SelectList over one conversation's artifacts.
|
|
2
|
+
//
|
|
3
|
+
// UI mirrors the engine picker (tui.md "Pattern 1"): SelectList framed by
|
|
4
|
+
// DynamicBorder inside pi's native overlay. Read-only on purpose: enter
|
|
5
|
+
// opens the selected file with the OS default handler, r rescans (the list
|
|
6
|
+
// is always a fresh scan), esc closes. No delete keys — agy's files are
|
|
7
|
+
// agy's to manage.
|
|
8
|
+
|
|
9
|
+
import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
|
|
10
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
11
|
+
import { Container, type SelectItem, SelectList, Spacer, Text } from "@earendil-works/pi-tui";
|
|
12
|
+
import type { AgyArtifact } from "./artifacts.js";
|
|
13
|
+
|
|
14
|
+
export type ArtifactsBrowserAction =
|
|
15
|
+
| { type: "open"; artifact: AgyArtifact }
|
|
16
|
+
| { type: "rescan" }
|
|
17
|
+
| { type: "close" };
|
|
18
|
+
|
|
19
|
+
function humanBytes(n: number): string {
|
|
20
|
+
if (n < 1024) return `${n}B`;
|
|
21
|
+
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)}KB`;
|
|
22
|
+
return `${(n / (1024 * 1024)).toFixed(1)}MB`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function artifactItems(artifacts: AgyArtifact[]): SelectItem[] {
|
|
26
|
+
return artifacts.map((a, i) => {
|
|
27
|
+
const when = new Date(a.modifiedMs);
|
|
28
|
+
const stamp = `${when.getFullYear()}-${String(when.getMonth() + 1).padStart(2, "0")}-${String(when.getDate()).padStart(2, "0")} ${String(when.getHours()).padStart(2, "0")}:${String(when.getMinutes()).padStart(2, "0")}`;
|
|
29
|
+
return {
|
|
30
|
+
value: String(i),
|
|
31
|
+
label: a.name,
|
|
32
|
+
description: `${a.kind} · ${a.mediaType} · ${humanBytes(a.bytes)} · ${stamp}`,
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Render the overlay. Resolves with what the user asked for: open (with the
|
|
38
|
+
* selected artifact), rescan, or close. */
|
|
39
|
+
export async function showArtifactsBrowser(
|
|
40
|
+
ctx: ExtensionUIContext,
|
|
41
|
+
artifacts: AgyArtifact[],
|
|
42
|
+
): Promise<ArtifactsBrowserAction> {
|
|
43
|
+
const items = artifactItems(artifacts);
|
|
44
|
+
return ctx.custom<ArtifactsBrowserAction>((tui, theme, _keybindings, done) => {
|
|
45
|
+
const container = new Container();
|
|
46
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
47
|
+
container.addChild(
|
|
48
|
+
new Text(theme.fg("accent", theme.bold(`Antigravity artifacts (${artifacts.length})`)), 1, 0),
|
|
49
|
+
);
|
|
50
|
+
container.addChild(new Spacer(1));
|
|
51
|
+
|
|
52
|
+
const list = new SelectList(items, Math.min(items.length, 12), {
|
|
53
|
+
selectedPrefix: (t: string) => theme.fg("accent", t),
|
|
54
|
+
selectedText: (t: string) => theme.fg("accent", t),
|
|
55
|
+
description: (t: string) => theme.fg("muted", t),
|
|
56
|
+
scrollInfo: (t: string) => theme.fg("dim", t),
|
|
57
|
+
noMatch: (t: string) => theme.fg("warning", t),
|
|
58
|
+
});
|
|
59
|
+
list.onSelect = (item) => done({ type: "open", artifact: artifacts[Number(item.value)] });
|
|
60
|
+
list.onCancel = () => done({ type: "close" });
|
|
61
|
+
container.addChild(list);
|
|
62
|
+
|
|
63
|
+
container.addChild(new Spacer(1));
|
|
64
|
+
container.addChild(
|
|
65
|
+
new Text(theme.fg("dim", "↑↓ navigate · enter open · r rescan · esc close"), 1, 0),
|
|
66
|
+
);
|
|
67
|
+
container.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
render: (width: number) => container.render(width),
|
|
71
|
+
invalidate: () => container.invalidate(),
|
|
72
|
+
handleInput: (data: string) => {
|
|
73
|
+
// r rescan; enter opens (via onSelect), esc cancels (via onCancel).
|
|
74
|
+
if (data.trim().toLowerCase() === "r") {
|
|
75
|
+
done({ type: "rescan" });
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
list.handleInput(data);
|
|
79
|
+
tui.requestRender();
|
|
80
|
+
},
|
|
81
|
+
};
|
|
82
|
+
}, {
|
|
83
|
+
overlay: true,
|
|
84
|
+
overlayOptions: { anchor: "center" as const, width: 84, maxHeight: "85%" as const, margin: 1 },
|
|
85
|
+
});
|
|
86
|
+
}
|
package/src/artifacts.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Read-only artifact scan over one agy conversation's brain dir.
|
|
2
|
+
//
|
|
3
|
+
// agy drops files in three places under brain/<conversationId>/: the
|
|
4
|
+
// conversation root itself, .tempmediaStorage/ (media agy generated), and
|
|
5
|
+
// .user_uploaded/ (files the user handed it). Nothing else surfaces them,
|
|
6
|
+
// so this module lists them for pi (live-probed 2026-09-24: both trees
|
|
7
|
+
// exist per engine; media dirs are created on demand and may be absent).
|
|
8
|
+
//
|
|
9
|
+
// Read-only by design: list and open, never delete. Each entry is
|
|
10
|
+
// realpath'd and containment-checked canonical-to-canonical, which defeats
|
|
11
|
+
// symlinks pointing outside the conversation root and stays correct when
|
|
12
|
+
// the conversation dir itself is reached through a symlinked ancestor
|
|
13
|
+
// (macOS /var -> /private/var, symlinked homes). A file could still be
|
|
14
|
+
// swapped for a symlink between listing and a later `open` — the OS opener
|
|
15
|
+
// would follow it; that is outside this module's guarantee and acceptable
|
|
16
|
+
// for a single-user, own-home tool.
|
|
17
|
+
|
|
18
|
+
import { readdir, realpath, stat } from "node:fs/promises";
|
|
19
|
+
import path from "node:path";
|
|
20
|
+
|
|
21
|
+
export type AgyArtifactKind = "conversation" | "generated" | "uploaded";
|
|
22
|
+
export type AgyArtifactMediaType =
|
|
23
|
+
| "image"
|
|
24
|
+
| "audio"
|
|
25
|
+
| "video"
|
|
26
|
+
| "pdf"
|
|
27
|
+
| "markdown"
|
|
28
|
+
| "text"
|
|
29
|
+
| "other";
|
|
30
|
+
|
|
31
|
+
export interface AgyArtifact {
|
|
32
|
+
name: string;
|
|
33
|
+
absolutePath: string;
|
|
34
|
+
kind: AgyArtifactKind;
|
|
35
|
+
mediaType: AgyArtifactMediaType;
|
|
36
|
+
bytes: number;
|
|
37
|
+
modifiedMs: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const EXT_TO_MEDIA: Record<string, AgyArtifactMediaType> = {
|
|
41
|
+
png: "image",
|
|
42
|
+
jpg: "image",
|
|
43
|
+
jpeg: "image",
|
|
44
|
+
gif: "image",
|
|
45
|
+
webp: "image",
|
|
46
|
+
svg: "image",
|
|
47
|
+
bmp: "image",
|
|
48
|
+
mp3: "audio",
|
|
49
|
+
wav: "audio",
|
|
50
|
+
ogg: "audio",
|
|
51
|
+
m4a: "audio",
|
|
52
|
+
flac: "audio",
|
|
53
|
+
mp4: "video",
|
|
54
|
+
mov: "video",
|
|
55
|
+
webm: "video",
|
|
56
|
+
mkv: "video",
|
|
57
|
+
pdf: "pdf",
|
|
58
|
+
md: "markdown",
|
|
59
|
+
markdown: "markdown",
|
|
60
|
+
txt: "text",
|
|
61
|
+
csv: "text",
|
|
62
|
+
json: "text",
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function mediaTypeFor(name: string): AgyArtifactMediaType {
|
|
66
|
+
return EXT_TO_MEDIA[path.extname(name).slice(1).toLowerCase()] ?? "other";
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Files that are agy bookkeeping, not user-facing artifacts. */
|
|
70
|
+
function isSkippedName(name: string): boolean {
|
|
71
|
+
return name.startsWith(".") || name.endsWith(".metadata.json");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const SCAN_ROOTS: Array<{ sub: string | null; kind: AgyArtifactKind }> = [
|
|
75
|
+
{ sub: null, kind: "conversation" },
|
|
76
|
+
{ sub: ".tempmediaStorage", kind: "generated" },
|
|
77
|
+
{ sub: ".user_uploaded", kind: "uploaded" },
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
/** List artifacts under one conversation dir. Missing subdirs are normal
|
|
81
|
+
* (created on demand) and skip silently; a missing conversation dir
|
|
82
|
+
* returns []. Newest first. */
|
|
83
|
+
export async function listAgyArtifacts(conversationDir: string): Promise<AgyArtifact[]> {
|
|
84
|
+
// Canonicalize ONCE and derive everything from the canonical root: a
|
|
85
|
+
// raw-vs-canonical containment check silently rejects every entry when
|
|
86
|
+
// any ancestor is a symlink (the relative prefix comparison crosses
|
|
87
|
+
// resolved/unresolved segments and yields a bogus "..").
|
|
88
|
+
let root: string;
|
|
89
|
+
try {
|
|
90
|
+
root = await realpath(conversationDir);
|
|
91
|
+
} catch {
|
|
92
|
+
return [];
|
|
93
|
+
}
|
|
94
|
+
const byPath = new Map<string, AgyArtifact>();
|
|
95
|
+
for (const { sub, kind } of SCAN_ROOTS) {
|
|
96
|
+
const dir = sub === null ? root : path.join(root, sub);
|
|
97
|
+
let entries;
|
|
98
|
+
try {
|
|
99
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
100
|
+
} catch {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.isFile()) continue; // Dirent type: symlinks excluded here
|
|
105
|
+
if (isSkippedName(entry.name)) continue;
|
|
106
|
+
// Re-resolve per entry and re-check containment on the canonical
|
|
107
|
+
// path: catches anything swapped in between readdir and use.
|
|
108
|
+
let canonical: string;
|
|
109
|
+
let stats;
|
|
110
|
+
try {
|
|
111
|
+
canonical = await realpath(path.join(dir, entry.name));
|
|
112
|
+
if (!containedIn(root, canonical)) continue;
|
|
113
|
+
stats = await stat(canonical);
|
|
114
|
+
} catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!stats.isFile()) continue;
|
|
118
|
+
const key = canonical;
|
|
119
|
+
if (byPath.has(key)) continue;
|
|
120
|
+
byPath.set(key, {
|
|
121
|
+
name: entry.name,
|
|
122
|
+
absolutePath: canonical,
|
|
123
|
+
kind,
|
|
124
|
+
mediaType: mediaTypeFor(entry.name),
|
|
125
|
+
bytes: stats.size,
|
|
126
|
+
modifiedMs: stats.mtimeMs,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return [...byPath.values()].sort((a, b) => b.modifiedMs - a.modifiedMs);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function containedIn(root: string, candidate: string): boolean {
|
|
134
|
+
const rel = path.relative(root, candidate);
|
|
135
|
+
return rel === "" || (!rel.startsWith("..") && !path.isAbsolute(rel));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** OS open command for the user's default handler. Undefined on unknown
|
|
139
|
+
* platforms (caller renders a message instead of spawning blind). */
|
|
140
|
+
export function artifactOpenCommand(): { cmd: string } | undefined {
|
|
141
|
+
if (process.platform === "darwin") return { cmd: "open" };
|
|
142
|
+
if (process.platform === "linux") return { cmd: "xdg-open" };
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
package/src/ask-tool.ts
CHANGED
|
@@ -78,7 +78,7 @@ TWO MODES (you choose):
|
|
|
78
78
|
- **Continued conversation**: pass the conversationId returned in the PREVIOUS call's details (details.conversationId). agy resumes that conversation with full context intact.
|
|
79
79
|
|
|
80
80
|
EXECUTION MODES (param: mode):
|
|
81
|
-
- **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks.
|
|
81
|
+
- **plan**: agy reviews and plans without writing. Use for cross-review and read-only tasks. Enforced: plan runs never receive the skip-permissions flag, so a write or command attempt ends the run quickly with a "confirm plan" message instead of executing.
|
|
82
82
|
- **accept-edits** (default): agy applies edits directly inside the workspace.
|
|
83
83
|
|
|
84
84
|
COMPACT OUTPUT (param: digest): when true, the prompt is prefixed to request compact digests instead of full file contents. Defaults on for plan, off for accept-edits.
|
|
@@ -390,7 +390,8 @@ export async function registerAskAntigravityTool(
|
|
|
390
390
|
}
|
|
391
391
|
|
|
392
392
|
const body = result.content[0]?.type === "text" ? result.content[0].text : "";
|
|
393
|
-
const errored =
|
|
393
|
+
const errored =
|
|
394
|
+
d?.exitCode !== 0 || !!d?.aborted || !!d?.timedOut || !!d?.empty;
|
|
394
395
|
|
|
395
396
|
let text = errored
|
|
396
397
|
? theme.fg("error", "✗ AskAntigravity error")
|
|
@@ -536,7 +537,16 @@ export async function registerAskAntigravityTool(
|
|
|
536
537
|
// Honor the shared permissions setting (same knob as the provider). Non-
|
|
537
538
|
// interactive -p can't answer a permission prompt, so when this is off
|
|
538
539
|
// any run_command will hang - but the setting must mean what it says.
|
|
539
|
-
|
|
540
|
+
// Plan mode never gets the flag even when the knob is on: the flag
|
|
541
|
+
// auto-approves ALL permission requests including plan mode's own
|
|
542
|
+
// approval gate, which would silently turn "review-only" into full
|
|
543
|
+
// write access inside --add-dir (probed 2026-09-25: with the flag a
|
|
544
|
+
// plan run wrote files; without it, file and command attempts end
|
|
545
|
+
// exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
|
|
546
|
+
// never a hang). Omitting it is what makes "plan = review-only" true.
|
|
547
|
+
if (mode !== "plan" && config.skipPermissions !== false) {
|
|
548
|
+
args.push("--dangerously-skip-permissions");
|
|
549
|
+
}
|
|
540
550
|
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
541
551
|
args.push("--print-timeout", `${timeoutMin}m`);
|
|
542
552
|
if (contextFile) args.push("--add-dir", askContextDir());
|
|
@@ -701,10 +711,11 @@ export async function registerAskAntigravityTool(
|
|
|
701
711
|
details.aborted = outcome.aborted;
|
|
702
712
|
details.timedOut = outcome.timedOut;
|
|
703
713
|
details.durationMs = Date.now() - start;
|
|
714
|
+
const text = out.trim();
|
|
704
715
|
log?.(
|
|
705
716
|
"ask-end",
|
|
706
|
-
{ exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, durationMs: details.durationMs, conversationId: details.conversationId },
|
|
707
|
-
outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut ? "warn" : "info",
|
|
717
|
+
{ exitCode: outcome.exitCode, aborted: outcome.aborted, timedOut: outcome.timedOut, empty: !text, durationMs: details.durationMs, conversationId: details.conversationId },
|
|
718
|
+
outcome.exitCode !== 0 || outcome.aborted || outcome.timedOut || !text ? "warn" : "info",
|
|
708
719
|
);
|
|
709
720
|
|
|
710
721
|
if (!isContinuation && !details.conversationId && snapshot) {
|
|
@@ -718,8 +729,6 @@ export async function registerAskAntigravityTool(
|
|
|
718
729
|
}
|
|
719
730
|
}
|
|
720
731
|
|
|
721
|
-
const text = out.trim();
|
|
722
|
-
|
|
723
732
|
if (outcome.aborted) {
|
|
724
733
|
return {
|
|
725
734
|
content: [
|
|
@@ -754,6 +763,25 @@ export async function registerAskAntigravityTool(
|
|
|
754
763
|
};
|
|
755
764
|
}
|
|
756
765
|
|
|
766
|
+
// Exit 0 with nothing on stdout is still a failure for the
|
|
767
|
+
// delegator: headless agy auto-denies a permission-gated tool call
|
|
768
|
+
// (e.g. the command gate in plan mode), prints the reason only to
|
|
769
|
+
// stderr, and ends cleanly. Falling through to the success path here
|
|
770
|
+
// returned just the conversation footer, which read as an empty
|
|
771
|
+
// success (silent-failure bug found 2026-09-25).
|
|
772
|
+
if (!text) {
|
|
773
|
+
details.empty = true;
|
|
774
|
+
const stderr = redactText(details.stderr.trim());
|
|
775
|
+
const note = [
|
|
776
|
+
"agy exited cleanly but produced no output.",
|
|
777
|
+
stderr ? `stderr: ${stderr}` : null,
|
|
778
|
+
"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. Allow-list it under permissions.allow in ~/.gemini/antigravity-cli/settings.json, or rerun outside plan mode with skipPermissions.",
|
|
779
|
+
]
|
|
780
|
+
.filter(Boolean)
|
|
781
|
+
.join(" ");
|
|
782
|
+
return { content: [{ type: "text", text: note }], details };
|
|
783
|
+
}
|
|
784
|
+
|
|
757
785
|
onUpdate?.({ content: [{ type: "text", text: "" }], details: { ...details } });
|
|
758
786
|
const footer = details.conversationId
|
|
759
787
|
? `\n\n[agy conversationId: ${details.conversationId} - pass as conversationId to continue this conversation]`
|
|
@@ -857,6 +885,9 @@ interface AgyDetails {
|
|
|
857
885
|
exitCode: number;
|
|
858
886
|
aborted: boolean;
|
|
859
887
|
timedOut: boolean;
|
|
888
|
+
// Exit 0 with no answer on stdout (the empty-output failure branch).
|
|
889
|
+
// renderResult flips to the error glyph on it.
|
|
890
|
+
empty?: boolean;
|
|
860
891
|
durationMs: number;
|
|
861
892
|
stderr: string;
|
|
862
893
|
}
|
package/src/config.ts
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import fs from "node:fs";
|
|
14
14
|
import os from "node:os";
|
|
15
15
|
import path from "node:path";
|
|
16
|
+
import { normalizeAgyAgentName } from "./agents.js";
|
|
16
17
|
|
|
17
18
|
const CONFIG_PATH = path.join(
|
|
18
19
|
os.homedir(),
|
|
@@ -112,6 +113,9 @@ export interface AgyConfig {
|
|
|
112
113
|
* on the installed pi. The notice never repeats; /agy patch-cleanup is
|
|
113
114
|
* always available. */
|
|
114
115
|
patchCleanupNotified?: boolean;
|
|
116
|
+
/** Custom agy agent for stream-json turns (`--agent`, /agy agent).
|
|
117
|
+
* Absent = agy's default agent. ACP turns ignore it (no protocol slot). */
|
|
118
|
+
agent?: string;
|
|
115
119
|
/** Which pi tools the MCP bridge exposes to agy: "none" (bridge off),
|
|
116
120
|
* "all" (every registered non-builtin tool incl. other Ask* delegations;
|
|
117
121
|
* default - users expect the bridge working out of the box, and the
|
|
@@ -316,6 +320,11 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
316
320
|
usageEstimate,
|
|
317
321
|
};
|
|
318
322
|
|
|
323
|
+
// Custom agent for stream-json turns (--agent). Env wins over the file
|
|
324
|
+
// (same pattern as mode). Normalized (trim/cap/control-strip); an empty
|
|
325
|
+
// result means agy's default agent.
|
|
326
|
+
const agent = normalizeAgyAgentName(process.env.AGY_AGENT ?? file.agent);
|
|
327
|
+
|
|
319
328
|
return {
|
|
320
329
|
engine,
|
|
321
330
|
acp,
|
|
@@ -330,6 +339,7 @@ export function loadConfig(configPath: string = CONFIG_PATH): AgyConfig {
|
|
|
330
339
|
systemPrompt,
|
|
331
340
|
turnTimeoutMin,
|
|
332
341
|
inactivityTimeoutMin,
|
|
342
|
+
agent,
|
|
333
343
|
approvals: { gateMode, mode: gateAskMode },
|
|
334
344
|
patchCleanupNotified: file.patchCleanupNotified === true,
|
|
335
345
|
};
|
package/src/driver-types.ts
CHANGED
|
@@ -18,6 +18,10 @@ export interface DriverProfile {
|
|
|
18
18
|
effort?: string;
|
|
19
19
|
mode: string;
|
|
20
20
|
skipPermissions: boolean;
|
|
21
|
+
/** Custom agy agent (stream-json `--agent`). ACP has no agent slot in the
|
|
22
|
+
* protocol (RC01) and ignores it; the /agy agent command refuses to set
|
|
23
|
+
* one while the ACP engine is active. */
|
|
24
|
+
agent?: string;
|
|
21
25
|
}
|
|
22
26
|
|
|
23
27
|
export interface DriverTurnRequest extends DriverProfile {
|
|
@@ -55,13 +59,15 @@ export type DriverActivity =
|
|
|
55
59
|
/** Stream-json emits a token count only; ACP carries the actual thought text in
|
|
56
60
|
* `delta`. The provider renders whichever is present. */
|
|
57
61
|
| { type: "thought"; tokens?: number; delta?: string }
|
|
58
|
-
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown>; mcpServer?: string }
|
|
62
|
+
| { type: "tool_start"; stepId?: number; name: string; args: Record<string, unknown>; mcpServer?: string; /** ACP only: the protocol toolCallId (stream-json carries numeric stepId instead). */ toolCallId?: string }
|
|
59
63
|
| {
|
|
60
64
|
type: "tool_done";
|
|
61
65
|
stepId?: number;
|
|
62
66
|
name: string;
|
|
63
67
|
args: Record<string, unknown>;
|
|
64
68
|
mcpServer?: string;
|
|
69
|
+
/** ACP only: the protocol toolCallId; pairs done/error with their start. */
|
|
70
|
+
toolCallId?: string;
|
|
65
71
|
output?: string;
|
|
66
72
|
durationSeconds?: number;
|
|
67
73
|
/** ACP only: the server's native edit diff from `tool_call`
|
|
@@ -69,7 +75,7 @@ export type DriverActivity =
|
|
|
69
75
|
* sets it; the provider renders it without any git subprocess. */
|
|
70
76
|
diff?: { path: string; oldText?: string; newText: string };
|
|
71
77
|
}
|
|
72
|
-
| { type: "tool_error"; stepId?: number; name: string; message: string; mcpServer?: string }
|
|
78
|
+
| { type: "tool_error"; stepId?: number; name: string; message: string; mcpServer?: string; toolCallId?: string }
|
|
73
79
|
| { type: "usage"; usage: AgyUsage }
|
|
74
80
|
/** Synthetic: injected by the provider when the MCP bridge receives a call
|
|
75
81
|
* (G9). Parks the turn: output is expected to stall while pi executes the
|