Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion bridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,9 @@ into the prompt. A judge is never borrowed, and `judgeCapable` is exactly "has a
non-sealed entry". The field's own docstring carries the rules a new entry must
satisfy (no writes, and how it keeps the run out of the user's `--resume`); add
one only after running it. A naming spawn runs in a throwaway cwd, NEVER the
session's checkout — see `headlessScratchCwd`.
session's checkout — see `headlessScratchCwd`. History a CLI offers no switch to
skip is redirected per spawn instead (`HeadlessCommand.scratchEnv`, a fresh dir
the runner deletes after), so never list a var that also carries credentials.

The `agent:tools` advert (and the loopback `tools:list` reply) carries TWO
arrays, and the split is load-bearing. `tools[]` is the PATH probe — what this
Expand Down
103 changes: 47 additions & 56 deletions bridge/src/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { antigravityCliHome } from "./agents/antigravity/title";
import { AntigravityTitleWatcher } from "./agents/antigravity/title-watcher";
import { resolveStructuredTitle } from "./agents/title-dispatch";
import { buildTitleContext, generateTitleFromContext } from "./agents/title-generate";
import { TitleAttempts, type TitleOutcome } from "./agents/title-attempts";
import { agentSpec, BY_HOOK_NAME, handlerObservable } from "./agents/registry";
import { HandlerEngine, type HandlerEvent } from "./handler/engine";
import { createEntitlementReader, type TierClaimSource } from "./entitlement";
Expand Down Expand Up @@ -548,39 +549,18 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
let sessions: SessionManager | null = null;
let namer: SessionNamer | null = null;
let antigravityTitleWatcher: AntigravityTitleWatcher | null = null;
// Conversations we've already spent a title-generation spawn on, per terminal.
// The /session-title post repeats every turn, so without this a session whose
// agent never names itself would pay a model call per turn, forever. Keyed by
// agent session where there is one, not by slot, so a fresh thread in the same
// slot gets one more attempt — see TitleTarget for why a chat slot keys on
// itself instead.
//
// Nested rather than a flat `<terminalId>:<conversation>` key: terminal ids
// contain colons of their own (`<checkoutId>:setup`), so in a flat key space
// one terminal's release reaches another terminal's entries by prefix.
const titleGenAttempted = new Map<string, Set<string>>();
// Title-generation budget per conversation, per terminal. The /session-title
// post repeats every turn, so without this a session whose agent never names
// itself would pay a model call per turn, forever. Keyed by agent session
// where there is one, not by slot, so a fresh thread in the same slot gets a
// fresh budget — see TitleTarget for why a chat slot keys on itself instead.
const titleAttempts = new TitleAttempts();
/** Conversation id for the attempt gate — see TitleTarget for why a chat slot
* keys on itself rather than on an agent session id. */
const titleAttemptKey = (target: { terminalId: string; agentSessionId?: string }) =>
target.agentSessionId ?? target.terminalId;
function titleAttemptSpent(terminalId: string, conversationId: string): boolean {
return titleGenAttempted.get(terminalId)?.has(conversationId) ?? false;
}
function claimTitleAttempt(terminalId: string, conversationId: string): void {
const spent = titleGenAttempted.get(terminalId) ?? new Set<string>();
spent.add(conversationId);
titleGenAttempted.set(terminalId, spent);
}
/**
* Released with the namer's buffered title, never separately. The two halves
* answer the same question — has this slot been named — and a `forget` that
* dropped only the rank left a session whose generated name the next
* first-message read overwrote, with generation refused forever after. A
* resume reuses the agent session id, so the key alone cannot tell the runs
* apart.
*/
function forgetTitleAttempts(terminalId: string): void {
titleGenAttempted.delete(terminalId);
titleAttempts.forget(terminalId);
}
/** Which agent each LIVE chat slot runs — see the driverFactory that fills it. */
const chatTools = new Map<string, string>();
Expand Down Expand Up @@ -2934,14 +2914,14 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
// Tested twice, and the two tests answer different questions. This one is a
// pure early-out: every turn of the session posts here, and without it each
// one pays the transcript read below only to be refused after it. It claims
// nothing, so it cannot burn the attempt.
if (titleAttemptSpent(target.terminalId, key)) return;
// nothing, so it cannot burn an attempt.
if (titleAttempts.refused(target.terminalId, key)) return;

// Read the conversation BEFORE claiming the attempt. The claim is one per
// Read the conversation BEFORE claiming the attempt. The budget is per
// agent session, and the first post of a session arrives from SessionStart
// — before the user has typed — so claiming first spent every Claude
// session's only attempt on an empty transcript, and the turn that finally
// had something to name from was refused.
// — before the user has typed — so claiming first spent a Claude session's
// whole budget on an empty transcript, and the turn that finally had
// something to name from was refused.
//
// A post carrying `prompt` needs no read at all: the hook handed us the
// message the user just submitted, which is both the context and the reason
Expand All @@ -2955,28 +2935,39 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
});
if (!context) return;

// Claim the slot BEFORE awaiting the spawn: two turns can end while the
// first is still running, and both would otherwise pass the check. The read
// above already awaited, but this check-and-set does not, so only one caller
// can get past it.
if (titleAttemptSpent(target.terminalId, key)) return;
claimTitleAttempt(target.terminalId, key);

// No cwd: a naming spawn runs in a throwaway directory of its own
// (headlessScratchCwd), never this session's checkout.
const title = await generateTitleFromContext(context, { tool });
// Re-check: the spawn takes tens of seconds, and the user may have renamed
// the session in that window.
if (!title || (sessions && !sessions.isAutoNameable(target.terminalId))) return;
// …or started a NEW conversation in the same slot (`/clear`), whose own title
// this would outrank at `self` and whose attempt is already spent. The title
// describes the conversation it was generated from, not the slot. Only
// answerable when we were naming a conversation the agent had identified;
// see TitleTarget.
const live = sessions?.get(target.terminalId)?.agentSessionId;
if (target.agentSessionId && live && live !== target.agentSessionId) return;
log.info("generated a session title for %s (%s)", target.terminalId, tool);
namer?.onStructuredTitle(target.terminalId, title, "self");
// Claimed BEFORE awaiting the spawn: two turns can end while the first is
// still running, and both would otherwise pass the early-out above. The
// transcript read already awaited, but this check-and-set does not, so only
// one caller gets past it.
if (!titleAttempts.begin(target.terminalId, key)) return;
// Only the paths that reach a verdict overwrite this. Every other exit is a
// title thrown away for reasons unrelated to generating it, which releases
// the claim without spending the budget.
let outcome: TitleOutcome = "abandoned";
try {
// No cwd: a naming spawn runs in a throwaway directory of its own
// (headlessScratchCwd), never this session's checkout.
const result = await generateTitleFromContext(context, { tool });
if (!result.ok) {
outcome = result.reason;
return;
}
// Re-check: the spawn takes tens of seconds, and the user may have renamed
// the session in that window.
if (sessions && !sessions.isAutoNameable(target.terminalId)) return;
// …or started a NEW conversation in the same slot (`/clear`), whose own
// title this would outrank at `self` and which carries a budget of its
// own. The title describes the conversation it was generated from, not
// the slot. Only answerable when we were naming a conversation the agent
// had identified; see TitleTarget.
const live = sessions?.get(target.terminalId)?.agentSessionId;
if (target.agentSessionId && live && live !== target.agentSessionId) return;
outcome = "named";
log.info("generated a session title for %s (%s)", target.terminalId, tool);
namer?.onStructuredTitle(target.terminalId, result.title, "self");
} finally {
titleAttempts.settle(target.terminalId, key, outcome);
}
}

// Start local API server for MCP/hook integration (works in both modes)
Expand Down
42 changes: 40 additions & 2 deletions bridge/src/agents/headless.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { mkdirSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";

Expand Down Expand Up @@ -108,14 +108,19 @@ export async function runHeadless(
timeoutMs: number;
spawn?: typeof Bun.spawn;
env?: Record<string, string>;
/** Env vars to point at a directory created for this spawn and deleted
* after it — see HeadlessCommand.scratchEnv. */
scratchEnv?: string[];
},
): Promise<HeadlessResult | null> {
const spawn = opts.spawn ?? Bun.spawn;
let timer: ReturnType<typeof setTimeout> | undefined;
let abandonTimer: ReturnType<typeof setTimeout> | undefined;
const scratch = makeScratchHome(opts.scratchEnv);
try {
const proc = spawn(cmd, {
cwd: opts.cwd, stdout: "pipe", stderr: "ignore", env: headlessEnv(opts.env),
cwd: opts.cwd, stdout: "pipe", stderr: "ignore",
env: headlessEnv({ ...opts.env, ...scratch?.env }),
});
let timedOut = false;
// Resolves only if the timeout fires AND the tree kill fails to end the
Expand Down Expand Up @@ -151,9 +156,42 @@ export async function runHeadless(
// budget holding the dead process alive.
clearTimeout(timer);
clearTimeout(abandonTimer);
scratch?.dispose();
}
}

/**
* A private directory for one spawn's redirected state, or null when the
* command asked for none.
*
* Deleted on the way out, which is the whole point: these vars exist because
* the CLI has no ephemeral switch, and a fixed path would accumulate a session
* per call in a temp dir the OS does not reclaim on Windows.
*/
function makeScratchHome(vars?: string[]):
{ env: Record<string, string>; dispose: () => void } | null {
if (!vars?.length) return null;
let dir: string;
try {
dir = mkdtempSync(join(tmpdir(), "antgrid-headless-"));
} catch (err) {
// Falling through to the inherited value would run the spawn against the
// user's REAL agent home and write a session into their history, which is
// the one outcome these entries exist to prevent.
log.warn("no scratch home for a headless spawn: %s", err);
return null;
}
return {
env: Object.fromEntries(vars.map((name) => [name, dir])),
dispose: () => {
// A spawn that outlived its kill still holds its store open, and Windows
// refuses to unlink an open file. One leaked directory on the timeout
// path beats throwing from a `finally` that owes the caller a result.
try { rmSync(dir, { recursive: true, force: true }); } catch { /* leaked */ }
},
};
}

/**
* The environment a headless spawn runs under.
*
Expand Down
78 changes: 26 additions & 52 deletions bridge/src/agents/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
// compiler now demands.

import { existsSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
import { homedir } from "node:os";
import { join } from "node:path";
import { readCodexVersionJson, codexHomeDir } from "./codex/home";
import { antigravityCliHome, resolveAntigravityTitle } from "./antigravity/title";
Expand All @@ -34,20 +34,6 @@ import { terminalForkHandoff } from "./fork-handoff";

import { pickHeadlessFrom, type AgentKey, type AgentSpec } from "./types";

// A throwaway store for a headless spawn whose CLI offers no ephemeral flag.
// It sits under the OS temp dir rather than the agent's own home so that
// nothing a one-shot call writes can surface in that agent's --resume picker,
// and so the OS reclaims it. Stable per tool because HeadlessCommand.env is
// static: what is disposable is the directory, never a particular session.
//
// A PATH ONLY: this runs while the AGENTS literal is built, so anything done
// here happens on every import of this file — including each short-lived
// `bridge hook` invocation the agent is blocking on, and on machines that run
// neither of these agents.
function headlessScratchDir(tool: string): string {
return join(tmpdir(), "antgrid-headless", tool);
}

export const AGENTS: Record<AgentKey, AgentSpec> = {
"claude-code": {
bin: "claude",
Expand Down Expand Up @@ -243,25 +229,13 @@ export const AGENTS: Record<AgentKey, AgentSpec> = {
fork: terminalForkHandoff("Cursor"),
hooks: cursorHooks,
augmentsDefaultSpec: true,
headless: {
// --mode ask is Cursor's own read-only Q&A mode; -p alone would keep the
// write and shell tools its help advertises for print mode.
readonly: {
cmd: (prompt, model) => [
"cursor-agent", "-p", "--mode", "ask", "--output-format", "text",
...(model ? ["--model", model] : []), prompt,
],
// No ephemeral flag exists, so the conversation store is redirected
// instead. CURSOR_DATA_DIR is the right half of Cursor's two-directory
// split — it holds conversations, while credentials live under
// CURSOR_CONFIG_DIR (cli-config.json), which is left alone. Redirecting
// the config dir instead would sign the spawn out, the same way
// claude's --bare forces API-key auth and fails closed on a
// subscription.
env: { CURSOR_DATA_DIR: headlessScratchDir("cursor-agent") },
noHistory: "ephemeral-store",
},
},
// No headless entry. `-p --mode ask` reads like the right argv and has
// never been run: cursor-agent exits 1 on every invocation without an
// `agent login` or CURSOR_API_KEY, so nothing about that argv's reach has
// been observed. Absence is the honest answer, and it is not only about a
// wrong label — ANY non-sealed reach makes the agent judge-capable, which
// would arm a supervisor over the user's working tree on an argv nobody
// has run. Naming is unaffected: a "none" call borrows an installed agent.
},
"github-copilot": {
bin: "copilot",
Expand Down Expand Up @@ -303,13 +277,14 @@ export const AGENTS: Record<AgentKey, AgentSpec> = {
...(model ? ["--model", model] : []),
],
// Copilot has no ephemeral flag — a -p run writes session-store.db and a
// whole session-state/<uuid>/ tree — so the home is redirected instead.
// whole session-state/<uuid>/ tree — so the home is redirected to a
// directory that lives only as long as the spawn.
// Safe for auth, and that is NOT the generalization it looks like:
// credentials do not live under COPILOT_HOME at all (no GH_TOKEN or
// GITHUB_TOKEN path either), so a run against an EMPTY scratch home
// still authenticates. Measured, because the opposite is true of vibe,
// where the same move would take the credentials with it.
env: { COPILOT_HOME: headlessScratchDir("github-copilot") },
scratchEnv: ["COPILOT_HOME"],
noHistory: "ephemeral-store",
},
},
Expand Down Expand Up @@ -399,22 +374,21 @@ export const AGENTS: Record<AgentKey, AgentSpec> = {
titleSource: "osc",
resume: () => [],
initialPrompt: () => [],
headless: {
// Vibe selects a model through --agent profiles, never a --model flag, so
// the model argument is dropped rather than passed: `ask` is its built-in
// read-only profile. --trust is required, not a widening — without it a
// programmatic run stops on the interactive trust prompt, and it is scoped
// to this invocation (never written to trusted_folders.toml).
readonly: {
cmd: (prompt) => ["vibe", "-p", prompt, "--agent", "ask", "--trust", "--output", "text"],
// Vibe's home holds the credentials (.env) as well as the session log,
// so VIBE_HOME must NOT be redirected. Its config layer reads VIBE_*
// with `__` for nesting, which reaches session_logging.enabled directly
// — the same switch that makes SessionLogger write nothing at all.
env: { VIBE_SESSION_LOGGING__ENABLED: "false" },
noHistory: "flag",
},
},
// No headless entry, and `-p --agent ask` must not come back as one. Read
// against mistralai/mistral-vibe v2.24.5: `ask` is the APPROVAL-gated
// profile ("Requires approval for tool executions"), not a read-only one —
// that is `plan`, the only builtin pinning write_file and edit to
// permission "never". What makes ask LOOK read-only is that programmatic
// mode denies every callback it is handed (cli/programmatic.py), so a write
// fails closed on an approval it cannot answer.
//
// That is config-level, never argv-level, which is the whole distinction
// HeadlessReach draws: an agent profile is just another config layer, `ask`
// contributes no bypass_tool_permissions key, and the loop returns EXECUTE
// before consulting any permission the moment a user's own config sets one
// — no approval is raised, so nothing is denied. Even `--agent plan` falls
// to the same switch, so the best reach available here is "transcript", and
// it stays unrun besides (no MISTRAL_API_KEY on any machine measured).
fork: terminalForkHandoff("Mistral Vibe"),
},
};
Expand Down
Loading