@estebanforge/pi-antigravity-bridge 1.7.3 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +21 -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 +11 -2
- 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.
|
|
@@ -536,7 +536,16 @@ export async function registerAskAntigravityTool(
|
|
|
536
536
|
// Honor the shared permissions setting (same knob as the provider). Non-
|
|
537
537
|
// interactive -p can't answer a permission prompt, so when this is off
|
|
538
538
|
// any run_command will hang - but the setting must mean what it says.
|
|
539
|
-
|
|
539
|
+
// Plan mode never gets the flag even when the knob is on: the flag
|
|
540
|
+
// auto-approves ALL permission requests including plan mode's own
|
|
541
|
+
// approval gate, which would silently turn "review-only" into full
|
|
542
|
+
// write access inside --add-dir (probed 2026-09-25: with the flag a
|
|
543
|
+
// plan run wrote files; without it, file and command attempts end
|
|
544
|
+
// exit 0 in ~20-30s with a "confirm plan" message - fail-visible,
|
|
545
|
+
// never a hang). Omitting it is what makes "plan = review-only" true.
|
|
546
|
+
if (mode !== "plan" && config.skipPermissions !== false) {
|
|
547
|
+
args.push("--dangerously-skip-permissions");
|
|
548
|
+
}
|
|
540
549
|
if (isContinuation) args.push("--conversation", rawConvId as string);
|
|
541
550
|
args.push("--print-timeout", `${timeoutMin}m`);
|
|
542
551
|
if (contextFile) args.push("--add-dir", askContextDir());
|
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
|
package/src/driver.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
//
|
|
8
8
|
// Recycle semantics: the child is killed and respawned when the next turn's
|
|
9
9
|
// process profile (model / effort / mode / cwd / conversation) drifts from the
|
|
10
|
-
// running one
|
|
10
|
+
// running one. Stats and a bounded
|
|
11
11
|
// lifecycle log feed /agy doctor.
|
|
12
12
|
//
|
|
13
13
|
// The driver never talks to the MCP bridge directly: the provider owns the
|
|
@@ -20,6 +20,7 @@ import { parseAgyLine } from "./stream-events.js";
|
|
|
20
20
|
import { isKnownNoiseLine, MAX_FRAME_BYTES, stripGluedNoise } from "./frame-guard.js";
|
|
21
21
|
import { bridgeMcpConfigDir, bridgeMcpConfigExists } from "./mcp-server.js";
|
|
22
22
|
import { gateHooksStaged } from "./approval-hook.js";
|
|
23
|
+
import { parkedTurnAnswer } from "./parked-turn.js";
|
|
23
24
|
import { redactText } from "./redact.js";
|
|
24
25
|
import type {
|
|
25
26
|
AgyUsage,
|
|
@@ -62,6 +63,9 @@ interface ActiveTurn {
|
|
|
62
63
|
response: string;
|
|
63
64
|
usage?: AgyUsage;
|
|
64
65
|
conversationId?: string;
|
|
66
|
+
/** Wall clock at turn creation; the parked-turn probe filters
|
|
67
|
+
* transcript steps to those written after this. */
|
|
68
|
+
startedAt: number;
|
|
65
69
|
sawResult: boolean;
|
|
66
70
|
/** Text-dedupe guard state (delta vs cumulative response_text). */
|
|
67
71
|
cumulativeText: boolean | undefined;
|
|
@@ -71,6 +75,15 @@ interface ActiveTurn {
|
|
|
71
75
|
parks: number;
|
|
72
76
|
}
|
|
73
77
|
|
|
78
|
+
/** The skip-permissions flag actually passed to agy. Plan mode never
|
|
79
|
+
* carries it: the flag auto-approves every permission request including
|
|
80
|
+
* plan mode's own approval gate, silently turning review-only into full
|
|
81
|
+
* write access. #start (argv + profile) and #recycleCause (comparison)
|
|
82
|
+
* must both go through this, or plan turns recycle on every turn. */
|
|
83
|
+
function effectiveSkipPermissions(mode: DriverTurnRequest["mode"], skipPermissions: boolean): boolean {
|
|
84
|
+
return mode !== "plan" && skipPermissions;
|
|
85
|
+
}
|
|
86
|
+
|
|
74
87
|
function emit(turn: ActiveTurn, activity: DriverActivity): void {
|
|
75
88
|
if (turn.closed) return;
|
|
76
89
|
if (turn.wake.length > 0) turn.wake.shift()!();
|
|
@@ -171,10 +184,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
171
184
|
// 0 disables the stall guard (config inactivityTimeoutMin: 0).
|
|
172
185
|
if (idleMin > 0) {
|
|
173
186
|
turn.idleTimer = setTimeout(() => {
|
|
174
|
-
|
|
175
|
-
this.#log(`stall:${turn.id}`);
|
|
176
|
-
this.#killChild();
|
|
177
|
-
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
187
|
+
void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
|
|
178
188
|
}, idleMin * 60_000);
|
|
179
189
|
}
|
|
180
190
|
}
|
|
@@ -279,6 +289,7 @@ export class StreamDriver implements TurnDriver {
|
|
|
279
289
|
sawResult: false,
|
|
280
290
|
cumulativeText: undefined,
|
|
281
291
|
parks: 0,
|
|
292
|
+
startedAt: Date.now(),
|
|
282
293
|
};
|
|
283
294
|
if (request.signal) {
|
|
284
295
|
turn.onAbort = () => {
|
|
@@ -306,19 +317,13 @@ export class StreamDriver implements TurnDriver {
|
|
|
306
317
|
const totalMin = turn.request.timeoutMin ?? 10;
|
|
307
318
|
if (totalMin > 0) {
|
|
308
319
|
turn.overallTimer = setTimeout(() => {
|
|
309
|
-
|
|
310
|
-
this.#log(`timeout:${turn.id}`);
|
|
311
|
-
this.#killChild();
|
|
312
|
-
this.#failTurn(turn, `agy exceeded the ${totalMin}m turn timeout`);
|
|
320
|
+
void this.#turnDeadlineGuard(turn, "timeout", `agy exceeded the ${totalMin}m turn timeout`);
|
|
313
321
|
}, totalMin * 60_000);
|
|
314
322
|
}
|
|
315
323
|
const idleMin = turn.request.inactivityMin ?? 5;
|
|
316
324
|
if (idleMin > 0) {
|
|
317
325
|
turn.idleTimer = setTimeout(() => {
|
|
318
|
-
|
|
319
|
-
this.#log(`stall:${turn.id}`);
|
|
320
|
-
this.#killChild();
|
|
321
|
-
this.#failTurn(turn, `agy stalled for ${idleMin}m with no output`);
|
|
326
|
+
void this.#turnDeadlineGuard(turn, "stall", `agy stalled for ${idleMin}m with no output`);
|
|
322
327
|
}, idleMin * 60_000);
|
|
323
328
|
}
|
|
324
329
|
}
|
|
@@ -327,12 +332,21 @@ export class StreamDriver implements TurnDriver {
|
|
|
327
332
|
this.#state = "starting";
|
|
328
333
|
this.#generation += 1;
|
|
329
334
|
const generation = this.#generation;
|
|
335
|
+
// Plan mode never carries the skip flag: the flag auto-approves ALL
|
|
336
|
+
// permission requests including plan mode's own approval gate, which
|
|
337
|
+
// would silently turn "review-only" into full write access (probed
|
|
338
|
+
// 2026-09-25: with the flag a plan session wrote files; without it,
|
|
339
|
+
// file and command attempts end exit 0 in ~20-30s with a "confirm
|
|
340
|
+
// plan" message). The profile stores the EFFECTIVE value so the
|
|
341
|
+
// recycle comparison stays consistent with the argv.
|
|
342
|
+
const skipPermissions = effectiveSkipPermissions(request.mode, request.skipPermissions);
|
|
330
343
|
this.#profile = {
|
|
331
344
|
cwd: request.cwd,
|
|
332
345
|
model: request.model,
|
|
333
346
|
effort: request.effort,
|
|
334
347
|
mode: request.mode,
|
|
335
|
-
skipPermissions
|
|
348
|
+
skipPermissions,
|
|
349
|
+
agent: request.agent,
|
|
336
350
|
};
|
|
337
351
|
this.#boundConversation = request.conversationId ?? undefined;
|
|
338
352
|
this.#stderrTail = "";
|
|
@@ -346,7 +360,8 @@ export class StreamDriver implements TurnDriver {
|
|
|
346
360
|
args.push("--model", request.model);
|
|
347
361
|
if (request.effort) args.push("--effort", request.effort);
|
|
348
362
|
args.push("--mode", request.mode);
|
|
349
|
-
if (request.
|
|
363
|
+
if (request.agent) args.push("--agent", request.agent);
|
|
364
|
+
if (skipPermissions) args.push("--dangerously-skip-permissions");
|
|
350
365
|
if (request.conversationId) args.push("--conversation", request.conversationId);
|
|
351
366
|
args.push(
|
|
352
367
|
"--input-format",
|
|
@@ -607,6 +622,40 @@ export class StreamDriver implements TurnDriver {
|
|
|
607
622
|
}
|
|
608
623
|
}
|
|
609
624
|
|
|
625
|
+
/** Stall / total-timeout guard shared by both timer sites. Kills the
|
|
626
|
+
* child FIRST (bumping the generation so a racing exit handler cannot
|
|
627
|
+
* settle the turn ERROR mid-probe), then probes the conversation's
|
|
628
|
+
* brain transcript for a clean final-response step written during the
|
|
629
|
+
* turn (parked turn: agy finished its answer but never streamed it).
|
|
630
|
+
* One found, the turn settles OK with the withheld answer instead of
|
|
631
|
+
* discarding finished work; either way the next turn respawns into a
|
|
632
|
+
* known state. */
|
|
633
|
+
async #turnDeadlineGuard(turn: ActiveTurn, label: string, message: string): Promise<void> {
|
|
634
|
+
if (turn.closed) return;
|
|
635
|
+
this.#log(`${label}:${turn.id}`);
|
|
636
|
+
this.#killChild();
|
|
637
|
+
let parked: string | undefined;
|
|
638
|
+
try {
|
|
639
|
+
parked = turn.conversationId
|
|
640
|
+
? await parkedTurnAnswer(turn.conversationId, turn.startedAt)
|
|
641
|
+
: undefined;
|
|
642
|
+
} catch {
|
|
643
|
+
parked = undefined; // the guard must never throw
|
|
644
|
+
}
|
|
645
|
+
if (parked !== undefined) {
|
|
646
|
+
this.#settle(turn, {
|
|
647
|
+
conversationId: turn.conversationId,
|
|
648
|
+
status: "OK",
|
|
649
|
+
response: parked,
|
|
650
|
+
usage: turn.usage,
|
|
651
|
+
finished: true,
|
|
652
|
+
aborted: false,
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
this.#failTurn(turn, message);
|
|
657
|
+
}
|
|
658
|
+
|
|
610
659
|
#failTurn(turn: ActiveTurn, message: string): void {
|
|
611
660
|
this.#settle(turn, {
|
|
612
661
|
conversationId: turn.conversationId,
|
|
@@ -626,7 +675,10 @@ export class StreamDriver implements TurnDriver {
|
|
|
626
675
|
if (cur.model !== next.model) return "model";
|
|
627
676
|
if (cur.effort !== next.effort) return "effort";
|
|
628
677
|
if (cur.mode !== next.mode) return "mode";
|
|
629
|
-
if (cur.skipPermissions !== next.skipPermissions)
|
|
678
|
+
if (cur.skipPermissions !== effectiveSkipPermissions(next.mode, next.skipPermissions)) {
|
|
679
|
+
return "permissions";
|
|
680
|
+
}
|
|
681
|
+
if (cur.agent !== next.agent) return "agent";
|
|
630
682
|
if (!next.conversationId) return this.#boundConversation ? "conversation-reset" : undefined;
|
|
631
683
|
return next.conversationId === this.#boundConversation ? undefined : "conversation";
|
|
632
684
|
}
|