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: 2 additions & 2 deletions bridge/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -143,9 +143,9 @@ already has them.
- `/turn-start` hook (Claude only) → `turnStart`: clears the block AND opens a turn.
- chat resolve (`agent:permission-resolve`/`-question-resolve`) → `answerRequest`: same, but ONLY if something was actually pending — a resolve racing a retraction would otherwise open a turn no turn-end closes.
- bare PTY keystroke → `userReply`: clears the block only. Typing in an idle session is not work.
- PTY keystroke that SUBMITTED (`isSubmitKeystroke` in `agent-core.ts`: a trailing CR, but not `\x1b\r` — alt+enter inserts a newline and may never be sent) → `userReply({submitted:true})`: also opens a turn, but only for a session in `keystrokeTurnSessions` — an agent with turn-END hooks and no turn-start (codex/cursor/copilot; see `needsKeystrokeTurnStart` in `agents/registry.ts`, which reads it off each agent's own `hooks.turnBoundaryEvents`). Never for Claude (it has a real signal) nor for the hookless agents (opencode/antigravity/kilo/kimi/mistral-vibe — nothing would close the inferred turn).
- PTY keystroke that SUBMITTED (`isSubmitKeystroke` in `keystrokes.ts`: a trailing CR, but not `\x1b\r` — alt+enter inserts a newline and may never be sent) → `userReply({submitted:true})`: also opens a turn, but only for a session in `keystrokeTurnSessions` — an agent with turn-END hooks and no turn-start (codex/cursor/copilot; see `needsKeystrokeTurnStart` in `agents/registry.ts`, which reads it off each agent's own `hooks.turnBoundaryEvents`). Never for Claude (it has a real signal) nor for the hookless agents (opencode/antigravity/kilo/kimi/mistral-vibe — nothing would close the inferred turn).

The submit gate has **two** halves and both are required. A PTY delivers one keystroke per frame, so the submitting CR normally arrives alone and `isSubmitKeystroke` alone cannot tell a prompt from enter on an empty line or on a TUI menu — which start no turn, so the stop hook the inference depends on never fires. `hasTypedContent` (also `agent-core.ts`) marks the session in `typedSessions`, and only a submit with that marker opens a turn; opening consumes it. Which agent a session runs is `s.tool ?? defaultTool`, where `defaultTool` is folded from `agent:hello` — a `SessionEntry` carries `tool` only when it OVERRODE the project's `agent.tool`, so reading the entry alone silently opted every default-spec session out of the inference.
The submit gate has **two** halves and both are required. A PTY delivers one keystroke per frame, so the submitting CR normally arrives alone and `isSubmitKeystroke` alone cannot tell a prompt from enter on an empty line or on a TUI menu — which start no turn, so the stop hook the inference depends on never fires. `hasTypedContent` (also `keystrokes.ts`) marks the session in `typedSessions`, and only a submit with that marker opens a turn; opening consumes it. Which agent a session runs is `s.tool ?? defaultTool`, where `defaultTool` is folded from `agent:hello` — a `SessionEntry` carries `tool` only when it OVERRODE the project's `agent.tool`, so reading the entry alone silently opted every default-spec session out of the inference.

Two ordering rules fall out of the fold being keyed by session id: an attributed turn-start that beats its session's first `session:updated` is HELD in `pendingTurns` for exactly one session list, and a notification whose `terminalId` is not a running session (config-`terminals:` slots stamp one too) falls back to the project-wide key rather than being filed where nothing can read it. That fallback FANS OUT — `statusFor` reads it for every running session — and a turn-start clears it on the word of one session; both are accepted (losing the signal is worse than over-reporting it), and both are the reason a config-`terminals:` error dots every session on the project.
- `port-scanner.ts` — platform-specific dev-port detection (polling).
Expand Down
73 changes: 21 additions & 52 deletions bridge/src/agent-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,12 @@ import { logger } from "./logger";
const log = logger.child({ component: "agent-core" });
import { TerminalManager } from "./terminal-manager";
import { createKeyedLock } from "./keyed-lock";
import {
hasTypedContent,
isInterruptKeystroke,
isSubmitKeystroke,
submittedLine,
} from "./keystrokes";
import { AGENT_GRACE_MS, killChildTree, processGroupSpawn } from "./terminal-session";
import { createConnState, type ConnState } from "./conn-state";
import { FileWatcher } from "./file-watcher";
Expand Down Expand Up @@ -154,53 +160,6 @@ export function buildChatSpawnAugment(
};
}

/**
* Whether a `terminal:input` payload submitted a prompt, for the work-status
* turn inference agents without a pre-turn hook depend on (see work-status.ts).
*
* A TUI submits on CR, so that's the signal — but only as the FINAL byte, and
* never behind ESC: `\x1b\r` is alt+enter, which inserts a newline into a
* multi-line prompt rather than sending it. Treating that as a submit would open
* a turn nothing is going to close, which is exactly the stale "working" dot the
* turn model exists to avoid. Shift+enter under the kitty protocol
* (`\x1b[13;2u`) carries no CR at all and needs no special case.
*/
export function isSubmitKeystroke(data: string): boolean {
return data.endsWith("\r") && !data.endsWith("\x1b\r");
}

/**
* Whether a `terminal:input` payload carried anything BESIDES the submitting CR.
*
* A PTY delivers one keystroke per frame, so the CR that submits a prompt almost
* always arrives alone — which makes {@link isSubmitKeystroke} on its own unable
* to tell "the user sent a prompt" from "the user pressed enter on an empty
* prompt, or to dismiss a TUI menu". The latter starts no turn, so nothing will
* ever close the one it opens. work-status.ts pairs the two: a keystroke-inferred
* turn needs typed content since the last one (see `typedSessions`).
*
* Escape sequences count as content on purpose — arrow-key history recall then
* enter IS a submit, and the alternative (dropping it) loses a real turn.
*/
export function hasTypedContent(data: string): boolean {
return data.replace(/\r$/, "").length > 0;
}

/**
* Whether a `terminal:input` payload was a bare Escape keypress — the
* interactive interrupt shortcut every agent CLI honors, and the only signal
* a hook-based session gets that the user meant to abort a running turn.
*
* Exactly `\x1b` and nothing else: any longer sequence starting with ESC
* (arrow keys, function keys, alt+key, kitty-protocol chunks, alt+enter's
* `\x1b\r`) is content, not an interrupt, and must not be misread as one — a
* PTY assembles a full escape sequence before writing it, so a lone ESC byte
* in one frame unambiguously means the user pressed just that key.
*/
export function isInterruptKeystroke(data: string): boolean {
return data === "\x1b";
}

export interface AgentCore {
/** Wire up an outbound transport. The bus's inbound handler is set so the
* transport can dispatch incoming messages back into core. */
Expand Down Expand Up @@ -813,13 +772,22 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
if (!manager) return;
const runtime = runtimeFor(msg);
switch (msg.type) {
case "terminal:input":
manager.write(internalTerminalId(runtime, msg.terminalId), msg.data);
case "terminal:input": {
// The app sends a handler reply, an escalation chip and a composer send
// as one `line + CR` frame, so the split has to happen here: two
// relay-routed frames arrive back to back and the bridge writes them as
// they arrive, which gives the phone no way to time the gap itself.
const id = internalTerminalId(runtime, msg.terminalId);
const line = submittedLine(msg.data);
if (line === null) manager.write(id, msg.data);
else manager.submit(id, line);
// Typing into a session counts as activity — float it up the drawer.
// No-ops for non-session terminals (service PTYs).
sessions?.touch(msg.terminalId);
// A user reply resets the handler's runaway guard; a submitted line
// (data carrying CR/LF) also clears the pending escalations.
// A submitted line — isSubmitKeystroke, so a trailing CR but never
// alt+enter's `\x1b\r` nor a paste's embedded ones — resets the
// handler's runaway guard and clears the pending escalations; a bare
// keystroke reaches it and returns early.
handlerEngine.onUserReply(msg.terminalId, msg.data);
// ...and answers whatever the hook reported this session as blocked on.
// A terminal-mode session has no resolve frame — the keystroke IS the
Expand All @@ -832,6 +800,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
});
if (isInterruptKeystroke(msg.data)) opts.onInterrupt?.(msg.terminalId);
break;
}
case "handler:configure": {
// parseMessageFast (the encrypted/local hot path) validates only the
// message type — every field below is still untrusted, so every field
Expand Down Expand Up @@ -1551,7 +1520,7 @@ export async function buildAgentCore(opts: BuildAgentCoreOptions): Promise<Agent
adapter: createDispatchAdapter({
isChat: (id) => sessions?.get(id)?.mode === "chat",
pty: createPtyAdapter({
write: (terminalId, data) => manager?.write(terminalId, data),
submit: (terminalId, line) => manager?.submit(terminalId, line),
getRecentOutput: (terminalId) => manager?.getScrollback(terminalId)?.text ?? "",
getTranscriptPath: (terminalId) => sessions?.getAgentTranscriptPath(terminalId),
}),
Expand Down
66 changes: 62 additions & 4 deletions bridge/src/handler/authorization.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,13 @@ interface Alias {
// The floor's patterns are COMMAND-shaped and an instruction is natural language, so
// scanning "force push branch" with the floor regexes matches nothing — a lift derived
// from that scan alone would be dead code that still passes its own tests. This table
// closes the gap for the four operations §5.2 can actually prepare a snapshot for,
// which are also the ones a user routinely names in prose.
// closes the gap for the floor operations a user routinely names in prose.
//
// That deliberately reaches past the operations §5.2 can prepare a snapshot for, to the
// outward ones (a merge, a publish, a force branch delete) that nothing can. Without a
// row here their advisory recurs every pass and buildDecidePrompt feeds it back as a
// reason to escalate, so the merge the backlog exists to land never lands. A lift there
// buys silence on the advisory and nothing else — never an undo, because none exists.
//
// It stays narrow and demands specific phrasing, because the two failure directions are
// not symmetric: a MISSING lift costs one advisory row in the activity feed, while a
Expand All @@ -104,6 +109,14 @@ const REPO_ANCHOR = String.raw`\b(?:git|repo|repository|branch|commit|HEAD|origi
// No "cache" — "force remove the row from the cache" is in-memory prose, and the
// filesystem sense always spells itself "cache dir"/"cache directory" anyway.
const FS_ANCHOR = String.raw`\b(?:dirs?|directory|directories|folders?|files?|node_modules|build|dist|out|target|coverage|vendor|artifacts?)\b`;
// A bare `#\d+` is NOT an arm: GitHub numbers issues and pull requests in one series
// and "closes #42" is the standard idiom for an ISSUE, so anchoring on the number alone
// grants `gh pr close`/`gh pr merge` from the single most common line in a backlog.
// `PR #42` still anchors — on the `PR`.
const PR_ANCHOR = String.raw`(?:\bPRs?\b|\bpull\s+requests?\b)`;
// Its own anchor rather than REPO_ANCHOR, which also accepts git/repo/commit — "delete
// the old git config files" would otherwise grant a force branch delete.
const BRANCH_ANCHOR = String.raw`\bbranch(?:es)?\b`;

/** An anchored phrase, in either order — prose puts the anchor on either side. */
function anchored(phrase: string, anchor: string, gap = 40): RegExp[] {
Expand Down Expand Up @@ -148,6 +161,48 @@ const ALIASES: Alias[] = [
],
command: "git clean -fd",
},
{
phrases: [
// Not before `conflict`: "fix the merge conflicts on PR #12" is the most common
// sentence in a backlog carrying both the verb and the anchor, and it asks for the
// opposite of a merge — the PR is not ready to land.
...anchored(
String.raw`\b(?:squash[\s-]?|rebase[\s-]?)?merg(?:e|es|ed|ing)\b(?!\s*conflicts?\b)`,
PR_ANCHOR,
),
/\bgh\s+pr\s+merge\b/i,
],
command: "gh pr merge 1",
},
{
phrases: [
...anchored(String.raw`\bclos(?:e|es|ed|ing)\b`, PR_ANCHOR),
/\bgh\s+pr\s+close\b/i,
],
command: "gh pr close 1",
},
{
// FORCE phrasing only. Plain "delete the branch after merging" is the prose for
// `git branch -d`, which the floor does not flag at all, so lifting the forced
// spelling from it would be exactly the spurious grant this table cannot afford.
// The literal arm is case-sensitive for the same reason the floor pattern is.
phrases: [
...anchored(String.raw`\bforce[\s-]?delet(?:e|es|ed|ing)\b`, BRANCH_ANCHOR),
/\bgit\s+branch\s+-D\b/,
],
command: "git branch -D topic",
},
{
phrases: [
/\bnpm\s+publish\b/i,
/\bpublish(?:es|ed|ing)?\b[^\n]{0,24}\b(?:to\s+)?npm\b/i,
],
command: "npm publish",
},
// No prose alias for `gh release delete`, `gh repo delete` or `git tag -d`: the
// English for each is arguable ("delete the release notes", "drop the old tags"),
// and a user who types the literal command in the PA bar is already lifted by the
// floor-scan half of authorizeInstruction.
];

// ABS_PATH is excluded rather than incidentally absent: an alias grants an operation,
Expand Down Expand Up @@ -274,8 +329,11 @@ export function authorizeInstruction(
const operations: LiftedOperation[] = [];
const seen = new Set<string>();
const note = (tier: LiftedTier, matched: string) => {
if (seen.has(`${tier}${matched}`)) return;
seen.add(`${tier}${matched}`);
// Injective without a separator byte either field could contain — the same rule
// destructive-floor.ts states for its own warning key.
const key = JSON.stringify([tier, matched]);
if (seen.has(key)) return;
seen.add(key);
operations.push({ tier, matched });
};
for (const w of floor.warnings) {
Expand Down
35 changes: 33 additions & 2 deletions bridge/src/handler/config.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
// bridge/src/handler/config.ts
import { z } from "zod";
import { existsSync, mkdirSync, readFileSync, appendFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, appendFileSync, statSync, renameSync } from "node:fs";
import { join } from "node:path";

export const HandlerConfigSchema = z.object({
Expand Down Expand Up @@ -42,10 +42,39 @@ export interface ActivityRecord {
detail?: string;
}

const ACTIVITY_FILE = "handler-activity.jsonl";
const ACTIVITY_ROLLED_FILE = "handler-activity.1.jsonl";
// Exported so a test can build a file at exactly the cap rather than guess at one.
export const ACTIVITY_LOG_MAX_BYTES = 5_000_000;

function projectDir(abDir: string, projectId: string): string {
return join(abDir, "agents", projectId);
}

/**
* Bound the audit log by RENAME, never by rewriting a trailing window in place.
* This runs on every judge decision, so "keep the last N records" would turn an
* O(1) append into a read of the whole file each time — strictly worse than the
* growth it fixes.
*
* One rolled generation is kept rather than dropped: this file is the only durable
* copy of the rows a wrap-up push describes, and it is the only place a session
* that ended can still be reconstructed from.
*
* It must never throw. `HandlerEngine.record` writes here BEFORE it emits the
* `handler:activity` frame, so an error escaping this would cost the connected app
* its live row as well as the audit line.
*/
function rotateIfLarge(dir: string, path: string): void {
try {
if (statSync(path).size < ACTIVITY_LOG_MAX_BYTES) return;
renameSync(path, join(dir, ACTIVITY_ROLLED_FILE));
} catch {
// No log yet, or a rename Windows refused while something still holds the
// rolled file — a skipped rotation, retried by the next record.
}
}

export function loadHandlerConfig(abDir: string, projectId: string): HandlerConfig {
const path = join(projectDir(abDir, projectId), "handler-config.json");
if (!existsSync(path)) return DEFAULT_HANDLER_CONFIG;
Expand All @@ -63,6 +92,8 @@ export function loadHandlerConfig(abDir: string, projectId: string): HandlerConf

export function appendActivity(abDir: string, projectId: string, rec: ActivityRecord): void {
const dir = projectDir(abDir, projectId);
const path = join(dir, ACTIVITY_FILE);
mkdirSync(dir, { recursive: true });
appendFileSync(join(dir, "handler-activity.jsonl"), `${JSON.stringify(rec)}\n`, "utf8");
rotateIfLarge(dir, path);
appendFileSync(path, `${JSON.stringify(rec)}\n`, "utf8");
}
Loading