@miraland-labs/conduit-bridge 0.16.30 → 0.16.37

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.
@@ -7,6 +7,7 @@ import { constants } from "node:fs";
7
7
  import { join, resolve } from "node:path";
8
8
  import { execFile } from "node:child_process";
9
9
  import { promisify } from "node:util";
10
+ import { headCommitOrNull } from "./git.js";
10
11
  const execFileAsync = promisify(execFile);
11
12
  export function attemptWorktreePath(sourceWorkspace, attemptId) {
12
13
  return join(sourceWorkspace, ".conduit", "attempts", attemptId);
@@ -82,14 +83,18 @@ export async function ensureWorktreeGitIdentity(worktree, sourceWorkspace) {
82
83
  await git("config", key, inherited || fallback);
83
84
  }
84
85
  }
86
+ /** Removes the worktree; answers whether one was there to remove. */
85
87
  export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
86
- // Settled repair releases are returned on every assignment poll until the local tree is gone.
87
- // Make that at-least-once signal cheap and idempotent instead of invoking Git on a missing path.
88
+ // The control plane names every settled repair on every assignment poll, because it holds no
89
+ // record of what this computer still has on disk. That is a statement of fact to reconcile
90
+ // against, not a queue to drain, so the caller asks whether anything actually went -- a release
91
+ // nobody performed is not an event, and logging one made the same eleven paths scroll past every
92
+ // fifteen seconds. It also keeps Git off a path that is already gone.
88
93
  try {
89
94
  await access(worktreePath, constants.F_OK);
90
95
  }
91
96
  catch {
92
- return;
97
+ return false;
93
98
  }
94
99
  try {
95
100
  await execFileAsync("git", ["-C", sourceWorkspace, "worktree", "remove", "--force", worktreePath], { timeout: 60_000, maxBuffer: 2_000_000 });
@@ -101,6 +106,7 @@ export async function removeAttemptWorktree(sourceWorkspace, worktreePath) {
101
106
  maxBuffer: 1_000_000,
102
107
  }).catch(() => undefined);
103
108
  }
109
+ return true;
104
110
  }
105
111
  /**
106
112
  * F-07 safe resume: prove the interrupted attempt still owns this worktree path.
@@ -114,11 +120,7 @@ export async function proveResumeWorktree(sourceWorkspace, attemptId, worktreePa
114
120
  return false;
115
121
  try {
116
122
  await access(worktreePath, constants.F_OK);
117
- const { stdout } = await execFileAsync("git", ["-C", worktreePath, "rev-parse", "HEAD"], {
118
- timeout: 15_000,
119
- maxBuffer: 1_000_000,
120
- });
121
- return /^[0-9a-f]{40,64}$/i.test(stdout.trim());
123
+ return (await headCommitOrNull(worktreePath)) !== null;
122
124
  }
123
125
  catch {
124
126
  return false;
package/dist/brief.js CHANGED
@@ -1,8 +1,9 @@
1
- import { readdir, readFile, stat } from "node:fs/promises";
2
- import { join, resolve } from "node:path";
1
+ import { readdir, readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
3
  import { execFile } from "node:child_process";
4
4
  import { promisify } from "node:util";
5
5
  import { isBoundedVerificationCommand } from "./execution-class.js";
6
+ import { gitOriginUrl, headCommitOrNull } from "./git.js";
6
7
  const execFileAsync = promisify(execFile);
7
8
  const MANIFESTS = [
8
9
  "package.json", "wrangler.jsonc", "wrangler.toml", "tsconfig.json",
@@ -20,14 +21,22 @@ export async function buildWorkspaceBrief(workspace) {
20
21
  .map((entry) => entry.name).sort().slice(0, 30);
21
22
  const files = new Set(entries.filter((entry) => entry.isFile()).map((entry) => entry.name));
22
23
  const manifests = MANIFESTS.filter((name) => files.has(name));
24
+ const declared = await readDeclaredVerification(workspace);
23
25
  return {
24
- repository: await gitRemoteUrl(workspace),
25
- base_commit: await gitHeadCommit(workspace),
26
+ repository: await gitOriginUrl(workspace),
27
+ base_commit: await headCommitOrNull(workspace),
26
28
  modules,
27
29
  manifests,
28
30
  verification: await discoverVerificationCommands(workspace, files),
31
+ declared_verification: declared,
29
32
  };
30
33
  }
34
+ /**
35
+ * Mirror of `src/conductor/repository.ts`. The server fingerprints the initiative URL and the Bridge
36
+ * fingerprints the workspace remote; dispatch requires the two to be equal, so a divergence stops
37
+ * matching silently. The Bridge is a published package and cannot import server code, so
38
+ * `test/matcher.test.ts` ("repository fingerprint agreement") holds the two spellings together.
39
+ */
31
40
  export function normalizeRepositoryUrl(url) {
32
41
  let value = url.trim().replace(/\.git$/, "").replace(/\/+$/, "").toLowerCase();
33
42
  const ssh = value.match(/^git@([^:]+):(.+)$/);
@@ -143,61 +152,6 @@ export async function resolveAttemptStartCommit(workspace, requestedBase, claime
143
152
  return base;
144
153
  return null;
145
154
  }
146
- async function gitRemoteUrl(workspace) {
147
- try {
148
- const { common } = await gitDirectories(workspace);
149
- const config = await readFile(join(common, "config"), "utf8");
150
- const remote = config.match(/\[remote "origin"\][^[]*?url\s*=\s*(\S+)/);
151
- return remote?.[1] ?? null;
152
- }
153
- catch {
154
- return null;
155
- }
156
- }
157
- async function gitHeadCommit(workspace) {
158
- try {
159
- const { worktree, common } = await gitDirectories(workspace);
160
- let head = String(await readFile(join(worktree, "HEAD"), "utf8")).trim();
161
- // Follow one level of symbolic refs (HEAD → refs/heads/main, or a nested ref file).
162
- for (let hop = 0; hop < 3; hop++) {
163
- if (/^[0-9a-f]{40,64}$/i.test(head))
164
- return head.toLowerCase();
165
- const ref = head.match(/^ref:\s*(\S+)$/)?.[1];
166
- if (!ref)
167
- return null;
168
- try {
169
- head = String(await readFile(join(common, ref), "utf8")).trim();
170
- }
171
- catch {
172
- const packed = String(await readFile(join(common, "packed-refs"), "utf8"));
173
- const line = packed.split("\n").find((entry) => {
174
- const trimmed = entry.trim();
175
- return trimmed.length > 0 && !trimmed.startsWith("#") && !trimmed.startsWith("^") && trimmed.endsWith(` ${ref}`);
176
- });
177
- const sha = line?.split(/\s+/)[0]?.trim() ?? null;
178
- return sha && /^[0-9a-f]{40,64}$/i.test(sha) ? sha.toLowerCase() : null;
179
- }
180
- }
181
- return null;
182
- }
183
- catch {
184
- return null;
185
- }
186
- }
187
- async function gitDirectories(workspace) {
188
- const dotGit = join(workspace, ".git");
189
- const info = await stat(dotGit);
190
- const pointer = info.isDirectory() ? null : (await readFile(dotGit, "utf8")).trim().match(/^gitdir:\s*(.+)$/)?.[1];
191
- if (!info.isDirectory() && !pointer)
192
- throw new Error("Invalid Git worktree metadata");
193
- const worktree = info.isDirectory() ? dotGit : resolve(workspace, pointer);
194
- try {
195
- return { worktree, common: resolve(worktree, (await readFile(join(worktree, "commondir"), "utf8")).trim()) };
196
- }
197
- catch {
198
- return { worktree, common: worktree };
199
- }
200
- }
201
155
  /** Exported for tests — discovers bounded verification commands from workspace manifests. */
202
156
  /**
203
157
  * Commands a project declares for itself, in `.conduit/verification` — one per line, `#` comments
@@ -227,12 +181,17 @@ export function parseDeclaredVerification(text) {
227
181
  }
228
182
  return declared;
229
183
  }
230
- export async function discoverVerificationCommands(workspace, files) {
231
- const commands = [];
184
+ export async function readDeclaredVerification(workspace) {
232
185
  try {
233
- commands.push(...parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8")));
186
+ return parseDeclaredVerification(await readFile(join(workspace, ".conduit", "verification"), "utf8"));
234
187
  }
235
- catch { /* no declaration, or unreadable: discovery below still applies */ }
188
+ catch {
189
+ return []; // no declaration, or unreadable: discovery still applies
190
+ }
191
+ }
192
+ export async function discoverVerificationCommands(workspace, files) {
193
+ // Declared first: the order is a preference order for every reader that takes one command.
194
+ const commands = await readDeclaredVerification(workspace);
236
195
  if (files.has("package.json")) {
237
196
  try {
238
197
  const manifest = JSON.parse(await readFile(join(workspace, "package.json"), "utf8"));
package/dist/checkout.js CHANGED
@@ -11,6 +11,7 @@ import { dirname } from "node:path";
11
11
  import { execFile } from "node:child_process";
12
12
  import { promisify } from "node:util";
13
13
  import { normalizeRepositoryUrl } from "./brief.js";
14
+ import { gitOriginUrl } from "./git.js";
14
15
  const execFileAsync = promisify(execFile);
15
16
  async function pathExists(path, accessFn) {
16
17
  try {
@@ -21,19 +22,6 @@ async function pathExists(path, accessFn) {
21
22
  return false;
22
23
  }
23
24
  }
24
- async function gitOriginUrl(workspace, exec) {
25
- try {
26
- const { stdout } = await exec("git", ["-C", workspace, "config", "--get", "remote.origin.url"], {
27
- timeout: 15_000,
28
- maxBuffer: 1_000_000,
29
- });
30
- const value = stdout.trim();
31
- return value.length > 0 ? value : null;
32
- }
33
- catch {
34
- return null;
35
- }
36
- }
37
25
  /** A control-plane repository fingerprint needs a transport before Git can clone it. */
38
26
  function cloneRepositoryUrls(repositoryUrl) {
39
27
  const value = repositoryUrl.trim();
package/dist/cli.js CHANGED
@@ -612,19 +612,22 @@ async function runner() {
612
612
  progressed = await executeNextInvestigation(client, config, workspace, brief, timeoutMs) || progressed;
613
613
  }
614
614
  if (workspace && onlineDriverIds(config).length) {
615
+ // Read the workspace again, then publish it. The last brief stands if the read fails.
616
+ const publishWorkspaceState = async (options = {}) => {
617
+ const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
618
+ const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, options);
619
+ const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
620
+ return { preflight, hb };
621
+ };
615
622
  progressed = await pumpExecutionSlots(client, config, workspace, brief, timeoutMs, {
616
623
  heartbeat: async () => {
617
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
618
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined });
619
- await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
624
+ await publishWorkspaceState();
620
625
  },
621
626
  beforeClaim: async (driverId) => {
622
627
  // Never let the five-minute heartbeat cache span a CLI upgrade into a certified claim.
623
628
  // Publish the fresh probe first; the Control Plane then remains the authority for the
624
629
  // exact version frozen onto the claim response and event.
625
- const currentBrief = await buildWorkspaceBrief(workspace).catch(() => brief);
626
- const preflight = await cachedBridgePreflight({ config, workspace, brief: currentBrief ?? undefined }, { force: true });
627
- const hb = await heartbeat(client, config, workspace, currentBrief, preflight, undefined, bootstrapResult);
630
+ const { preflight, hb } = await publishWorkspaceState({ force: true });
628
631
  if (hb.staleBridge)
629
632
  return false;
630
633
  const lane = preflight.drivers.find((candidate) => candidate.id === driverId);
package/dist/config.js CHANGED
@@ -262,6 +262,23 @@ export async function saveConfigPrefs(config) {
262
262
  * nothing without one.
263
263
  */
264
264
  export async function saveDriverQuota(driverId, quota) {
265
+ await updateDriverLane(driverId, (lane) => {
266
+ if (quota)
267
+ lane.quota = quota;
268
+ else
269
+ delete lane.quota;
270
+ });
271
+ }
272
+ /** Persist one lane's execution outcome without overwriting operator preferences or quota state. */
273
+ export async function saveDriverOutcome(driverId, outcome) {
274
+ await updateDriverLane(driverId, (lane) => {
275
+ if (outcome)
276
+ lane.outcome = outcome;
277
+ else
278
+ delete lane.outcome;
279
+ });
280
+ }
281
+ async function updateDriverLane(driverId, update) {
265
282
  const run = async () => {
266
283
  await withConfigLock(async () => {
267
284
  let prefs;
@@ -269,15 +286,12 @@ export async function saveDriverQuota(driverId, quota) {
269
286
  prefs = JSON.parse(await readFile(path, "utf8"));
270
287
  }
271
288
  catch {
272
- return; // No prefs on disk yet: nothing to attach a quota to.
289
+ return; // No prefs on disk yet: nothing to attach lane state to.
273
290
  }
274
291
  const lane = prefs.drivers?.[driverId];
275
292
  if (!lane || typeof lane !== "object")
276
293
  return;
277
- if (quota)
278
- lane.quota = quota;
279
- else
280
- delete lane.quota;
294
+ update(lane);
281
295
  await writeJsonAtomic(path, { ...prefs, activeAttempts: {}, sessions: undefined });
282
296
  });
283
297
  };
package/dist/driver.js CHANGED
@@ -3,9 +3,9 @@ import { boundedTail } from "./ensure-test-evidence.js";
3
3
  import { resolveAgentTimeout } from "./execution-budget.js";
4
4
  import { existsSync } from "node:fs";
5
5
  import { mkdir, readFile, rm, rmdir, writeFile } from "node:fs/promises";
6
- import { join } from "node:path";
6
+ import { join, resolve } from "node:path";
7
7
  import { z } from "zod";
8
- import { deniedCommands, executionClassPromptRules, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
8
+ import { allowsExternalFetch, deniedCommands, executionClassPromptRules, isRunnableVerificationCommand, parsePiJsonl, projectClaude, projectCoarseMode, projectCodex, projectCursor, projectGrok, projectKiroTools, projectPi, requireStampedExecutionClass, } from "./execution-class.js";
9
9
  /**
10
10
  * The bound for this turn.
11
11
  *
@@ -995,10 +995,16 @@ export const kiroDriver = {
995
995
  },
996
996
  };
997
997
  /**
998
- * Antigravity's `agy` CLI (verified against agy 1.1.3): non-interactive runs use
999
- * `-p`/`--print` with `--mode plan` (read-only) or `--mode accept-edits`. Its
1000
- * enforcement is mode-level, not per-tool coarser than Claude, the Cursor tier.
1001
- * Write mode adds `--sandbox` for terminal restrictions. Output is plain text.
998
+ * Antigravity's `agy` CLI (verified against agy 1.1.24): non-interactive runs use
999
+ * `--mode plan` (read-only) or `--mode accept-edits`. `-p`/`--print` is a Go-style
1000
+ * flag that consumes its value from the next argument, so the prompt must be the
1001
+ * last argument attached to `-p` putting other flags after it (as earlier builds
1002
+ * of this driver did) makes agy swallow one of them as the prompt and silently
1003
+ * drop the real prompt. `--print-timeout` is set from the turn budget so agy's
1004
+ * 5-minute default cannot cut a longer turn short. Its enforcement is mode-level,
1005
+ * not per-tool — coarser than Claude, the Cursor tier. Write mode adds `--sandbox`
1006
+ * for terminal restrictions. `--output-format json` returns one envelope, parsed by
1007
+ * parseAntigravityOutput.
1002
1008
  */
1003
1009
  export function antigravityModeForGrants(grants, executionClass) {
1004
1010
  if (!hasMappedRepoAccess(grants))
@@ -1011,15 +1017,206 @@ export function antigravityModeForGrants(grants, executionClass) {
1011
1017
  return "plan";
1012
1018
  return null;
1013
1019
  }
1014
- export function antigravityRunArgs(input, mode) {
1015
- const args = ["-p", "--mode", mode];
1020
+ /** agy's `--print-timeout` takes a Go duration; ceil to whole minutes (agy's own default floor is 5m). */
1021
+ export function antigravityPrintTimeout(timeoutMs) {
1022
+ return `${Math.max(5, Math.ceil(timeoutMs / 60_000))}m0s`;
1023
+ }
1024
+ /**
1025
+ * Class + grants → agy permission allow-rules (probed against agy 1.1.24).
1026
+ *
1027
+ * Headless `agy -p` soft-denies every tool it was not granted: the run returns an empty response
1028
+ * with a stderr notice and nothing is written. Only wildcard rules ever matched in the probes —
1029
+ * bounded ones (`command(git add)`, `command(git *)`, and in the field `unsandboxed(git push)`)
1030
+ * were auto-denied, because the agent picks its own shell spellings and stops at the first denial.
1031
+ * So the enforceable unit is the coarse rule plus `--sandbox`, the same boundary the Codex driver
1032
+ * leans on with `--sandbox workspace-write`. The grammar agy accepts is `command(<cmd>)`,
1033
+ * `unsandboxed(<cmd>)`, `write_file(<glob>)`, `read_file(<glob>)`, `read_url(<target>)`.
1034
+ *
1035
+ * `deniedCommands` cannot be expressed here: agy has no deny grammar. Antigravity is the coarse
1036
+ * tier the product spec already assigns it — enforcement is the sandbox, the prompt rule that names
1037
+ * the hard-denied commands (executionClassPromptRules), and the delivery gate.
1038
+ */
1039
+ export function antigravityPermissionProjection(executionClass, grants, options = {}) {
1040
+ const allow = ["read_file(*)"];
1041
+ // Live HTTP is the assignment's capability, read the one way every driver reads it. agy aborts the
1042
+ // whole turn on a denial rather than skipping the tool, so an assignment that may fetch must carry
1043
+ // the rule; one that may not never gets it.
1044
+ if (allowsExternalFetch(executionClass, options.capabilities))
1045
+ allow.push("read_url(*)");
1046
+ const runnable = (options.verificationCommands ?? []).some(isRunnableVerificationCommand);
1047
+ if (executionClass === "observe" || executionClass === "observe_network" || options.diagnosis) {
1048
+ // Diagnosis has to run its bounded checks; a plain observe class gets no shell at all.
1049
+ if (runnable && (options.diagnosis || grants.includes("test_run")))
1050
+ allow.push("command(*)");
1051
+ return allow;
1052
+ }
1053
+ if (executionClass === "verify") {
1054
+ allow.push("command(*)");
1055
+ return allow;
1056
+ }
1057
+ allow.push("write_file(*)", "command(*)");
1058
+ // Land work needs the sandbox escape hatch, and only a wildcard reaches it: a real mutate_repo
1059
+ // assignment carrying `pr_create` was auto-denied on exact `unsandboxed(git push)` with
1060
+ // "a tool required the \"unsandboxed\" permission that headless mode cannot prompt for". So for
1061
+ // the land class the sandbox boundary no longer covers the escape hatch, and enforcement there is
1062
+ // the prompt's hard-deny list, the delivery gate (one PR, a scoped diff, Bridge-witnessed
1063
+ // commands) and the project's grants — the coarse tier. Without `pr_create` no `unsandboxed(...)`
1064
+ // rule is emitted at all, and `--sandbox` stays on either way.
1065
+ if (grants.includes("pr_create"))
1066
+ allow.push("unsandboxed(*)");
1067
+ return allow;
1068
+ }
1069
+ /**
1070
+ * The one driver note agy needs beside the class rules. It aborts the whole turn on any denied tool
1071
+ * — Claude Code merely skips one — and a run that had already committed died opening its own PR URL
1072
+ * with `read_url`, which no assignment without external_network may hold.
1073
+ */
1074
+ export const antigravityPromptRule = "Antigravity: do not use read_url, browser, or web tools unless this assignment lists external_network; do not open the pull request URL after creating it — report the URL from the `gh pr create` output. A denied tool ends the whole run, so use only the tools this assignment allows.";
1075
+ export function antigravityRunArgs(input, mode, timeoutMs) {
1076
+ const args = ["--mode", mode];
1016
1077
  if (mode === "accept-edits")
1017
1078
  args.push("--sandbox");
1018
- args.push(input.prompt);
1079
+ args.push("--output-format", "json");
1080
+ args.push("--print-timeout", antigravityPrintTimeout(timeoutMs));
1081
+ // agy's CLI default model is one account lane among several; `--model` selects the lane this
1082
+ // machine's tier mapping names, which is how a spent Gemini window stops the whole driver.
1083
+ if (input.model)
1084
+ args.push("--model", input.model);
1085
+ if (input.resumeSessionId)
1086
+ args.push("--conversation", input.resumeSessionId);
1087
+ args.push("-p", input.prompt);
1019
1088
  return args;
1020
1089
  }
1090
+ /**
1091
+ * Parse `agy models`: one model per line, the id first and the display name after it
1092
+ * ("claude-sonnet-4-6 Claude Sonnet 4.6 (Thinking)"). Two or more spaces separate the two
1093
+ * columns, which is what tells a model line from a banner line ("Available models:").
1094
+ */
1095
+ export function parseAntigravityModelList(stdout) {
1096
+ const models = new Set();
1097
+ for (const line of stdout.split("\n")) {
1098
+ const match = /^\s*([A-Za-z0-9][A-Za-z0-9._/-]*)\s{2,}\S/.exec(line);
1099
+ if (match)
1100
+ models.add(match[1]);
1101
+ }
1102
+ return models;
1103
+ }
1104
+ /**
1105
+ * `agy --output-format json` prints one envelope:
1106
+ * `{"conversation_id","status","response","duration_seconds","num_turns","usage"}`.
1107
+ * Tolerant like the Cursor parser: a build that answers in plain text still delivers its report.
1108
+ *
1109
+ * A refused run keeps the same envelope with `status: "ERROR"`, an empty `response`, and the reason
1110
+ * in `error` ("Individual quota reached ... Resets in 165h48m43s."), and prints **nothing** on
1111
+ * stderr. Reading only `response` reported that run as "agy exited with code 1", which says nothing
1112
+ * the owner can act on — so `status` and `error` are parsed here and named by the driver.
1113
+ */
1114
+ export function parseAntigravityOutput(stdout) {
1115
+ try {
1116
+ const parsed = JSON.parse(stdout.trim());
1117
+ return {
1118
+ resultText: typeof parsed.response === "string" ? parsed.response : null,
1119
+ sessionId: typeof parsed.conversation_id === "string" ? parsed.conversation_id : null,
1120
+ status: typeof parsed.status === "string" ? parsed.status : null,
1121
+ error: typeof parsed.error === "string" && parsed.error.trim() ? parsed.error : null,
1122
+ };
1123
+ }
1124
+ catch {
1125
+ return { resultText: stdout || null, sessionId: null, status: null, error: null };
1126
+ }
1127
+ }
1128
+ /**
1129
+ * The stderr line agy prints when a permission it was not granted ended the turn. An empty response
1130
+ * beside such a line is a denial, not an agent with nothing to say, and the control plane has to see
1131
+ * the difference.
1132
+ */
1133
+ export function antigravityDenialNotice(stderr) {
1134
+ for (const line of stderr.split("\n")) {
1135
+ const trimmed = line.trim();
1136
+ if (trimmed && /no output produced|permission/i.test(trimmed))
1137
+ return trimmed;
1138
+ }
1139
+ return null;
1140
+ }
1141
+ /** agy reads one global settings file, so concurrent runs are serialized rather than racing on it. */
1142
+ let antigravitySettingsLock = Promise.resolve();
1143
+ /**
1144
+ * Write the run's permission contract to agy's global settings file, restoring it byte-exactly after.
1145
+ *
1146
+ * Probed against agy 1.1.24: this is the only placement a headless run honours — a workspace-local
1147
+ * `.antigravity/settings.json` and `~/.gemini/config/projects/<uuid>.json` `permissionGrants` were
1148
+ * both ignored, and without `trustedWorkspaces` naming the workspace every tool is soft-denied. The
1149
+ * file belongs to the machine owner and is global, so the original bytes are held in memory and
1150
+ * beside it as `settings.json.conduit-backup` for the length of the run, unparsable JSON fails the
1151
+ * run instead of being overwritten, and every other key is preserved.
1152
+ */
1153
+ async function withAntigravityPermissions(workspace, allow, run) {
1154
+ const previousRun = antigravitySettingsLock;
1155
+ let release = () => undefined;
1156
+ antigravitySettingsLock = new Promise((settle) => { release = settle; });
1157
+ await previousRun.catch(() => undefined);
1158
+ try {
1159
+ const home = process.env.HOME;
1160
+ if (!home)
1161
+ throw new Error("antigravity needs HOME to write ~/.gemini/antigravity-cli/settings.json");
1162
+ const settingsDir = join(home, ".gemini", "antigravity-cli");
1163
+ const settingsPath = join(settingsDir, "settings.json");
1164
+ const backupPath = `${settingsPath}.conduit-backup`;
1165
+ const settingsDirExisted = existsSync(settingsDir);
1166
+ let previous = null;
1167
+ try {
1168
+ previous = await readFile(settingsPath, "utf8");
1169
+ }
1170
+ catch {
1171
+ previous = null;
1172
+ }
1173
+ let settings = {};
1174
+ if (previous !== null) {
1175
+ try {
1176
+ settings = { ...JSON.parse(previous) };
1177
+ }
1178
+ catch {
1179
+ throw new Error(`antigravity settings at ${settingsPath} are not valid JSON; refusing to overwrite them`);
1180
+ }
1181
+ await writeFile(backupPath, previous, "utf8");
1182
+ }
1183
+ const existingPermissions = (settings.permissions && typeof settings.permissions === "object" && !Array.isArray(settings.permissions))
1184
+ ? { ...settings.permissions }
1185
+ : {};
1186
+ const trusted = Array.isArray(settings.trustedWorkspaces)
1187
+ ? settings.trustedWorkspaces.filter((entry) => typeof entry === "string")
1188
+ : [];
1189
+ const absolute = resolve(workspace);
1190
+ settings.permissions = { ...existingPermissions, allow };
1191
+ settings.trustedWorkspaces = trusted.includes(absolute) ? trusted : [...trusted, absolute];
1192
+ await mkdir(settingsDir, { recursive: true });
1193
+ await writeFile(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, "utf8");
1194
+ try {
1195
+ return await run();
1196
+ }
1197
+ finally {
1198
+ if (previous === null)
1199
+ await rm(settingsPath, { force: true });
1200
+ else
1201
+ await writeFile(settingsPath, previous, "utf8");
1202
+ await rm(backupPath, { force: true });
1203
+ if (!settingsDirExisted)
1204
+ await rmdir(settingsDir).catch(() => undefined);
1205
+ }
1206
+ }
1207
+ finally {
1208
+ release();
1209
+ }
1210
+ }
1021
1211
  export const antigravityDriver = {
1022
1212
  name: "antigravity",
1213
+ async listModels(executable = "agy", workspace = process.cwd()) {
1214
+ const result = await execute(executable, ["models"], workspace, 30_000, undefined, "local");
1215
+ if (result.code !== 0)
1216
+ return null;
1217
+ const models = parseAntigravityModelList(result.stdout);
1218
+ return models.size ? models : null;
1219
+ },
1023
1220
  async run(input) {
1024
1221
  const fuelSource = input.fuelSource === "local" ? "local" : "conduit";
1025
1222
  // Antigravity authenticates with a Google login / GEMINI_API_KEY — not Conduit /v1 shims.
@@ -1034,12 +1231,33 @@ export const antigravityDriver = {
1034
1231
  if (!mode) {
1035
1232
  return { status: "failed", resultText: null, sessionId: null, error: "No Bridge-mapped Antigravity mode for active grants; refusing to start agent" };
1036
1233
  }
1037
- // agy print mode emits plain text and does not surface a resumable id, so rework resume is not wired.
1038
- const { code, stdout, stderr } = await execute(input.executable ?? "agy", antigravityRunArgs({ prompt: input.prompt, grants: input.grants }, mode), input.workspace, agentTurnTimeoutMs(input), undefined, "local");
1039
- if (code !== 0) {
1040
- return { status: "failed", resultText: stdout || null, sessionId: null, error: boundedTail(stderr || stdout || `agy exited with code ${code}`, 20_000) };
1234
+ const allow = antigravityPermissionProjection(executionClass, input.grants, {
1235
+ verificationCommands: input.verificationCommands,
1236
+ capabilities: input.capabilities ?? [],
1237
+ diagnosis: input.workRole === "diagnose",
1238
+ });
1239
+ const timeoutMs = agentTurnTimeoutMs(input);
1240
+ let executed;
1241
+ try {
1242
+ executed = await withAntigravityPermissions(input.workspace, allow, () => execute(input.executable ?? "agy", antigravityRunArgs({ prompt: `${input.prompt}\n\n${antigravityPromptRule}`, grants: input.grants, resumeSessionId: input.resumeSessionId, model: input.model }, mode, timeoutMs), input.workspace, timeoutMs, undefined, "local"));
1041
1243
  }
1042
- return { status: "completed", resultText: stdout, sessionId: null };
1244
+ catch (error) {
1245
+ return { status: "failed", resultText: null, sessionId: null, error: error instanceof Error ? error.message : String(error) };
1246
+ }
1247
+ const { code, stdout, stderr } = executed;
1248
+ const parsed = parseAntigravityOutput(stdout);
1249
+ // A refused run says so in the envelope, not on stderr. Take the envelope's own reason first —
1250
+ // it is the only text that names the cause (a spent allowance and when it refills).
1251
+ const refused = code !== 0 || (parsed.status !== null && parsed.status.toUpperCase() !== "SUCCESS");
1252
+ if (refused) {
1253
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(parsed.error || stderr || parsed.resultText || `agy exited with code ${code}`, 20_000) };
1254
+ }
1255
+ // An empty response with a denial on stderr is a refused run: report it instead of an empty reply.
1256
+ const denial = parsed.resultText?.trim() ? null : antigravityDenialNotice(stderr);
1257
+ if (denial) {
1258
+ return { status: "failed", resultText: parsed.resultText, sessionId: parsed.sessionId, error: boundedTail(`agy returned no response: ${denial}`, 20_000) };
1259
+ }
1260
+ return { status: "completed", resultText: parsed.resultText, sessionId: parsed.sessionId };
1043
1261
  },
1044
1262
  };
1045
1263
  /**
package/dist/drivers.js CHANGED
@@ -67,6 +67,8 @@ function normalizeOutcome(raw) {
67
67
  const value = raw;
68
68
  if (!Number.isFinite(value.consecutive_timeouts) || typeof value.last_timeout_at !== "string")
69
69
  return undefined;
70
+ if (!Number.isFinite(Date.parse(value.last_timeout_at)))
71
+ return undefined;
70
72
  const count = Math.max(0, Math.floor(value.consecutive_timeouts));
71
73
  return { consecutive_timeouts: count, last_timeout_at: value.last_timeout_at };
72
74
  }
@@ -237,7 +239,7 @@ export function laneTimedOutRecently(config, driverId, now = Date.now()) {
237
239
  if (!outcome || outcome.consecutive_timeouts < TIMEOUT_SUPPRESSION_THRESHOLD)
238
240
  return false;
239
241
  const seen = Date.parse(outcome.last_timeout_at);
240
- return !Number.isFinite(seen) || now - seen <= TIMEOUT_SUPPRESSION_MS;
242
+ return now >= seen && now - seen <= TIMEOUT_SUPPRESSION_MS;
241
243
  }
242
244
  export function onlineDriverIds(config) {
243
245
  const drivers = normalizeDrivers(config.drivers);
@@ -4,18 +4,9 @@
4
4
  import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { commitsAheadOfBase } from "./ensure-pull-request.js";
7
+ import { readHeadCommit } from "./git.js";
7
8
  import { AgentNoLandCommitError, filterPathsInScope, requiresLandCommit, } from "./land-contract.js";
8
9
  const execFileAsync = promisify(execFile);
9
- async function workspaceHeadCommit(workspace) {
10
- const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
11
- timeout: 30_000,
12
- maxBuffer: 1_000_000,
13
- });
14
- const head = stdout.trim();
15
- if (!head)
16
- throw new Error("Could not read workspace HEAD");
17
- return head;
18
- }
19
10
  async function uncommittedPaths(workspace) {
20
11
  const { stdout } = await execFileAsync("git", ["-C", workspace, "status", "--porcelain"], {
21
12
  timeout: 30_000,
@@ -64,7 +55,7 @@ export async function ensureLandCommit(input) {
64
55
  if (!base)
65
56
  return { kind: "skipped" };
66
57
  const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
67
- const readHead = input.readHeadCommit ?? workspaceHeadCommit;
58
+ const readHead = input.readHeadCommit ?? readHeadCommit;
68
59
  const listPending = input.listUncommittedPaths ?? uncommittedPaths;
69
60
  const commitPaths = input.commitPaths ?? commitScopedPaths;
70
61
  const ahead = await countAhead(input.workspace, base);
@@ -6,10 +6,10 @@
6
6
  import { execFile } from "node:child_process";
7
7
  import { promisify } from "node:util";
8
8
  import { normalizeRepositoryUrl } from "./brief.js";
9
+ import { readHeadCommit } from "./git.js";
10
+ import { forgeTransportFailure } from "./transport-fault.js";
9
11
  import { agentNoLandCommitMessage } from "./land-contract.js";
10
12
  const execFileAsync = promisify(execFile);
11
- /** Mirror execution.ts FORGE_TRANSPORT_PATTERN — keep local to avoid import cycles. */
12
- const FORGE_TRANSPORT_PATTERN = /unable to access '?https?:\/\/|error in the http2 framing layer|could not resolve host|connection (?:reset|timed out|refused)|\bcurl\b.*\b(?:52|55|56|92)\b|remote end hung up unexpectedly|\brpc failed\b|tls handshake|network is unreachable|operation timed out/i;
13
13
  /**
14
14
  * Commits the attempt branch has and the base does not.
15
15
  * Returns null when this checkout does not know the base. An unverifiable precondition must not
@@ -103,16 +103,6 @@ export function adoptPullRequestUrlFromEvidence(report, repositoryFingerprint) {
103
103
  }
104
104
  return report;
105
105
  }
106
- async function workspaceHeadCommit(workspace) {
107
- const { stdout } = await execFileAsync("git", ["-C", workspace, "rev-parse", "HEAD"], {
108
- timeout: 30_000,
109
- maxBuffer: 1_000_000,
110
- });
111
- const head = stdout.trim().toLowerCase();
112
- if (!/^[0-9a-f]{7,64}$/.test(head))
113
- throw new Error("Could not determine workspace HEAD commit");
114
- return head;
115
- }
116
106
  function execErrorMessage(error) {
117
107
  if (!(error instanceof Error))
118
108
  return String(error);
@@ -157,7 +147,7 @@ export async function pushOriginHead(workspace, options = {}) {
157
147
  catch {
158
148
  // fall through to retry / throw
159
149
  }
160
- if (attempt < attempts && FORGE_TRANSPORT_PATTERN.test(lastMessage)) {
150
+ if (attempt < attempts && forgeTransportFailure(lastMessage)) {
161
151
  await sleep(750 * attempt);
162
152
  continue;
163
153
  }
@@ -179,7 +169,7 @@ export async function pushOriginHead(workspace, options = {}) {
179
169
  * Agent-supplied PR URLs skip creation but still require HEAD identity.
180
170
  */
181
171
  export async function ensureDeliveryPullRequest(input) {
182
- const readHead = input.readHeadCommit ?? workspaceHeadCommit;
172
+ const readHead = input.readHeadCommit ?? readHeadCommit;
183
173
  const push = input.pushOriginHead ?? pushOriginHead;
184
174
  const countAhead = input.countCommitsAhead ?? commitsAheadOfBase;
185
175
  let report = adoptPullRequestUrlFromEvidence(input.report, input.repositoryFingerprint);