UNPKG

@phren/cli

Version:

Knowledge layer for AI agents - CLI, MCP server, and data layer

1,821 lines • 107 kB
import { approvalRuleContext, approvalRuleEffect } from "./approval-rules.js";
import { recordedConductor } from "./conductor-role.js";
import { conductorBrief, conductorContext } from "./conductor-context.js";
import { disabledHint } from "../modules/registry.js";
import { logger } from "../logger.js";
import { createServer } from "node:http";
import { mkdir, readFile, chmod, unlink, lstat } from "node:fs/promises";
import { watch } from "node:fs";
import path from "node:path";
import { randomUUID } from "node:crypto";
import { hostname } from "node:os";
import { atomicInPrivateDir, BridgeError, bridgeRoot, object, objects, targetSchema } from "./protocol.js";
import { findPane, knownPanes, paneIdentity, paneForCodexSession, servers, snapshot, trustedDirectory, validateTarget } from "./herdr.js";
import { terminalProvider } from "./terminal.js";
import { readPaneText } from "./pane-text.js";
import { capturesChanges, ToolChanges } from "./changes.js";
import { phrenStoreRoot } from "./transcripts.js";
import { archiveFinishedFanouts, blockedFanouts, fanoutAsking } from "./fanouts.js";
import { ensureGrant, findGrant } from "./grants.js";
import { ApprovalPushService } from "./push.js";
import { approvalSummary, paneProject } from "./approval-summary.js";
import { computerDisplayName } from "./pair.js";
import { intervalFromEnv } from "./limits.js";
import { answerClaudeQuestionDialog, claudeQuestionDialog } from "./claude-question-dialog.js";
import { answeredQuestionInput, numberedDialog, opencodePermissionDialog, passwordLine, permissionPrompt, questionChoice, terminalChoice, terminalQuestions, visibleTerminalChoice } from "./terminal-choice.js";
import { directoryNames, opencodeApprovalFile, opencodeRequest, readOpencodeRequest } from "./opencode-approvals.js";
import { allowOpencodeToolEverywhere } from "./opencode-permissions.js";
import { ApprovalWatchLeases, bindingPath, localSocket, PushBindingStore } from "./agent-hook-stores.js";
import { notePaneTranscript, paneAccountKey } from "./pane-accounts.js";
import { eventStatus, notePaneStatus, settleBlockedPane } from "./pane-status.js";
import { noteTurn as recordTurn } from "./turn-records.js";
import { PromptDeliveries } from "./prompt-deliveries.js";
import { countTick } from "./metrics.js";
import { briefId, briefIdInPrompt, briefLabel, recordBriefArrival } from "./launch-brief.js";
import { appServerApproval, appServerDecision, conductorCall, OPENCODE_APPROVAL_OPTIONS, opencodePermissionReply } from "./agent-hook-asks.js";
import { SudoBroker } from "./sudo.js";
import { codexServers } from "./codex-servers.js";
import { paneClient, paneKey, PaneServerWatcher, rootSession, servedPane } from "./opencode-panes.js";
export { permissionPrompt, terminalChoice, visibleTerminalChoice } from "./terminal-choice.js";
export { appServerApproval, appServerDecision, conductorCall, OPENCODE_APPROVAL_OPTIONS, opencodePermissionReply } from "./agent-hook-asks.js";
export { agentHook, stopFacts, withinToolBudget } from "./agent-hook-callback.js";
export { ApprovalWatchLeases, PushBindingStore, recordedSession } from "./agent-hook-stores.js";
const APPROVAL_SWEEP_MS = 2_000;
/** How recent the Herdr snapshots must be for the poll to trust "no opencode pane". */
const OPENCODE_PANES_FRESH_MS = 15_000;
const APPROVAL_DEBOUNCE_MS = 100;
/** How long a permission ask is held for the phone before it falls back to
 * the terminal. Claude's own hook window is 60 s; a test shortens it. */
const APPROVAL_HOLD_MS = (() => {
    const value = Number(process.env.PHREN_APPROVAL_HOLD_MS);
    return Number.isFinite(value) && value >= 50 && value <= 60_000 ? Math.floor(value) : 55_000;
})();
/** A pane's terminal dialog is read at most once per this window. */
/** Lines read to find Claude's question dialog: its tab bar sits above the whole question. */
const QUESTION_READ_LINES = 120;
const DIALOG_READ_MS = intervalFromEnv("PHREN_DIALOG_THROTTLE_MS", 3_000);
/** How long a pushed terminal dialog can be answered from its notification. */
const DIALOG_PUSH_MS = 10 * 60_000;
const FANOUT_SWEEP_MS = 5_000;
/** How far ahead a served OpenCode pane's ask expires. OpenCode waits for as
 * long as it takes, so this is only a horizon: every listing that still
 * returns the ask (each ask event, reconnect and the Hook's 5 s tick) moves it
 * forward, and the card goes only when the ask is gone or the pane is dead. */
const SERVED_ASK_MS = 60 * 60_000;
const FANOUT_ARCHIVE_MS = 60 * 60 * 1000;
/** Where a held request's answer goes: the callback's HTTP response, or the
 * reply to a Codex app-server request (codex-servers.ts). Either takes the
 * PermissionRequest-shaped JSON; "{}" gives the request back to the terminal. */
/** Whether the caller hung up on `res` before it was answered. Not
 * `req.destroyed`: a request body read to its end is destroyed on every
 * Node this supports, so that read as a hang-up on each normal callback. */
export function watchHangUp(res) {
    let closed = res.destroyed || res.socket?.destroyed === true;
    const onClose = () => { if (!res.writableEnded)
        closed = true; };
    res.once("close", onClose);
    return () => { res.off("close", onClose); return closed || res.destroyed; };
}
/** How long a dispatching Hook's poll keeps a worker pane's permission asks held. */
const DISPATCH_LEASE_MS = 45_000;
/** This socket is deliberately separate from the phone's HTTP pipe. Only local
 * agent callbacks can register identities or create an approval request. */
/** The project a notification names for a pane's folder: none for the
 * phren store, whose path is never shown on the phone. */
function projectField(cwd) {
    const project = paneProject(cwd, phrenStoreRoot());
    return project ? { project } : {};
}
export class AgentHooks {
    push;
    modules;
    changes = new ToolChanges();
    pending = new Map();
    /** Phone messages Phren typed into a pane, until a conversation there
     * submits them through UserPromptSubmit: see prompt-deliveries.ts. */
    deliveries;
    /** The permission request a conversation is drawing in its own terminal
     * because nobody was there to hold it: what the phone shows above its
     * answer keys until the pane stops waiting. `dialog` marks a choice parsed
     * from the pane's own numbered lines, whose answers gain an Enter, and
     * `questions` carries a released AskUserQuestion's normalized question set
     * with the index currently being answered. */
    terminalPrompts = new Map();
    /** The last time each pane's terminal lines were read for a dialog, so a
     * status tick reads them at most once per three seconds per pane. */
    dialogReads = new Map();
    /** Panes whose last read terminal line is a password prompt, so the status
     * frame can offer the phone's secret sheet only when one is really asking. */
    passwords = new Map();
    /** Conversations Claude Code is compacting, by target, until the new context
     * starts. The phone shows the state instead of the summary row's text. */
    compactingSince = new Map();
    /** Panes where Phren just typed a bare slash command: the agent is drawing
     * that command's menu, which Herdr reports as an idle agent, so keys are
     * allowed there for a short while to walk and confirm it. The command text
     * and a one-shot confirmation attempt ride with the window. */
    menus = new Map();
    watching = new Map();
    overview = new ApprovalWatchLeases();
    server;
    pushBindings = new PushBindingStore();
    /** Held asks that were pushed: action -> when the notification expires. */
    pushedHolds = new Map();
    /** Held asks whose hold ended with the request left in the terminal, by
     * pane: the action keeps answering the pane's dialog, so a phone card,
     * notification or dispatcher holding it is not left with a dead id. */
    releasedHolds = new Map();
    /** Terminal dialogs pushed to phones: by pane, the dialog last pushed; by
     * action, what an answer from the notification types. */
    dialogPushes = new Map();
    dialogActions = new Map();
    /** Terminal dialogs offered to a dispatching Hook, by pane: the `dialog-` action minted for the dialog's current title. */
    forwardedDialogs = new Map();
    /** Requests this Hook pushed to its own phone on behalf of a dispatched worker on another computer: action -> the answer to send back. */
    forwardedPushes = new Map();
    /** Panes a dispatching Hook polled lately (`server\npane\nsource` -> expiry). */
    dispatchLeases = new Map();
    /** opencode permission asks seen on disk or listed by a served pane, by request id. */
    opencode = new Map();
    /** Questions served OpenCode panes are asking, by question id. */
    servedQuestions = new Map();
    /** One event subscription per served OpenCode pane; ticked with the Hook. */
    paneServers = new PaneServerWatcher({ asks: (entry, client, asks) => this.servedAsks(entry, client, asks), gone: key => this.servedGone(key) });
    closed = false;
    opencodeSweep;
    opencodeLive = new Set();
    fanoutLive = new Set();
    fanoutSweeping = false;
    opencodeWatcher;
    opencodePoll;
    opencodeDebounce;
    /** Fan-out jobs already pushed as blocked, by job id and blocked timestamp. */
    fanoutSeen = new Map();
    fanoutTimer;
    fanoutArchiveTimer;
    /** The name the phone paired with (macOS Computer Name), for approval alerts;
     * the short host name until that lookup answers. */
    computerName = hostname().replace(/\.local$/i, "").split(".")[0] || "Computer";
    /** sudo -A requests from askpass, answered from the phone (sudo.ts). */
    sudo;
    constructor(push = new ApprovalPushService(), modules, deliveriesFile) {
        this.push = push;
        this.modules = modules;
        this.deliveries = new PromptDeliveries(deliveriesFile);
        // A push held while the owner was at the desktop is sent only if still unanswered.
        push.stillPending = binding => this.pushTarget(binding) !== undefined;
        void computerDisplayName().then(name => { this.computerName = name; }, () => { });
        this.sudo = new SudoBroker({ computer: () => this.computerName, push, label: briefLabel,
            describe: async (place) => {
                const s = await snapshot(place.server);
                const pane = findPane(s, place);
                if (!pane)
                    return undefined;
                const tab = objects(s.tabs).find(item => item.tab_id === place.tab && item.workspace_id === place.workspace);
                const workspace = objects(s.workspaces).find(item => item.workspace_id === place.workspace);
                const label = [tab?.label, workspace?.label].find(value => typeof value === "string" && value.trim());
                return { ...(typeof pane.agent === "string" ? { source: pane.agent } : {}), ...(label ? { label } : {}) };
            } });
    }
    approvalsDirectory() { return path.join(phrenStoreRoot(), ".runtime", "approvals"); }
    scheduleOpencodeSweep() {
        if (this.closed || this.opencodeDebounce)
            return;
        this.opencodeDebounce = setTimeout(() => { this.opencodeDebounce = undefined; void this.sweepOpencodeApprovals().catch(() => { }); }, APPROVAL_DEBOUNCE_MS);
        this.opencodeDebounce.unref?.();
    }
    /** Read every live opencode request file, map it to a target through the
     * recorded bindings or Herdr's explicit opencode session id, and register it
     * for a push and a push-binding answer. A file that vanished or expired is
     * forgotten. */
    async sweepOpencodeApprovals() {
        if (this.closed)
            return;
        if (this.opencodeSweep)
            return this.opencodeSweep;
        countTick("opencode-sweep");
        const sweep = this.readOpencodeApprovals();
        this.opencodeSweep = sweep;
        try {
            await sweep;
        }
        finally {
            this.opencodeSweep = undefined;
        }
    }
    async readOpencodeApprovals() {
        const directory = this.approvalsDirectory();
        const live = this.opencodeLive;
        live.clear();
        for await (const name of directoryNames(directory, 1024)) {
            if (this.closed)
                return;
            const match = /^opencode-(ses_[0-9A-Za-z]{1,64})\.request\.json$/.exec(name);
            if (!match)
                continue;
            const request = await readOpencodeRequest(match[1]);
            if (!request)
                continue;
            const id = String(request.id);
            live.add(id);
            if (this.opencode.has(id))
                continue;
            // The launcher names the fan-out job; its manifest, not the request,
            // says which worker session and parent conversation it belongs to.
            const asking = request.fanout === undefined ? undefined : await fanoutAsking(request.fanout, match[1]);
            if (request.fanout !== undefined && !asking)
                continue;
            const target = asking ? await this.resolveTarget(asking.parent.provider, asking.parent.session) : await this.resolveTarget("opencode", match[1]);
            if (this.closed)
                return;
            if (!target && !asking)
                continue;
            const expiresAt = typeof request.expiresAt === "string" ? Date.parse(request.expiresAt) : NaN;
            if (!Number.isFinite(expiresAt) || expiresAt <= Date.now())
                continue;
            const pane = target && !asking?.worktree ? findPane(await snapshot(target.server).catch(() => ({})), { workspace: target.workspace, tab: target.tab, pane: target.pane, source: target.source }) : undefined;
            const cwd = asking?.worktree ?? (pane ? await trustedDirectory(pane).catch(() => undefined) : undefined);
            const summary = approvalSummary({ tool: String(request.type ?? ""), input: request, message: typeof request.message === "string" ? request.message : undefined, cwd });
            // The answer route checks an action id by the parent's source: a UUID
            // for Claude and Codex, a plain token for opencode.
            const fanout = asking ? { session: match[1], actionId: asking.parent.provider === "opencode" ? randomUUID().replaceAll("-", "") : randomUUID() } : undefined;
            this.opencode.set(id, { ...(target ? { target } : {}), request, requestLine: summary.request, expiresAt, ...(fanout ? { fanout } : {}) });
            if (!this.push.available)
                continue;
            const binding = randomUUID();
            this.pushBindings.add(binding, { action: id, expiresAt });
            // The held request is what stops a later sweep from pushing again; a
            // failed delivery only drops the binding and leaves the card in place.
            void this.push.notify({ binding, provider: "opencode", question: false, expiresAt: String(request.expiresAt),
                ...projectField(cwd), computer: this.computerName, ...summary })
                .then(delivered => { if (!delivered)
                this.pushBindings.dropAction(id); })
                .catch(() => { });
        }
        for (const [id, held] of this.opencode) {
            if (held.served)
                continue;
            if (!live.has(id) || held.expiresAt <= Date.now()) {
                this.opencode.delete(id);
                this.pushBindings.dropAction(id);
            }
        }
    }
    /** The pane a served OpenCode ask belongs to, as the phone names it: the
     * pane's place from its terminal, and the root of the asking session (a
     * subagent's ask shows on the conversation the pane shows). */
    async servedTarget(entry, client, session) {
        if (typeof session !== "string")
            return undefined;
        const root = await rootSession(client, session);
        const state = await snapshot(entry.server).catch(() => undefined);
        const pane = objects(state?.panes).find(value => value.pane_id === entry.pane);
        if (!pane)
            return undefined;
        const parsed = targetSchema.safeParse({ server: entry.server, workspace: pane.workspace_id, tab: pane.tab_id, pane: entry.pane, source: "opencode", session: root });
        return parsed.success ? parsed.data : undefined;
    }
    /** Everything a served pane is asking right now. New asks become the same
     * cards (and pushes) as the plugin's file asks; asks no longer listed were
     * answered in the TUI or went away, and their cards go with them. */
    async servedAsks(entry, client, asks) {
        if (this.closed)
            return;
        const key = paneKey(entry), where = { key, server: entry.server, pane: entry.pane };
        const expiresAt = Date.now() + SERVED_ASK_MS;
        const permissions = new Set();
        for (const ask of asks.permissions) {
            if (typeof ask.id !== "string" || !/^[A-Za-z0-9_]{1,200}$/.test(ask.id))
                continue;
            permissions.add(ask.id);
            const known = this.opencode.get(ask.id);
            if (known) {
                if (known.served?.key === key) {
                    known.expiresAt = expiresAt;
                    known.request.expiresAt = new Date(expiresAt).toISOString();
                    this.pushBindings.extendAction(ask.id, expiresAt);
                }
                continue;
            }
            const target = await this.servedTarget(entry, client, ask.sessionID).catch(() => undefined);
            if (this.closed)
                return;
            const type = typeof ask.permission === "string" && ask.permission ? ask.permission.slice(0, 200) : "action";
            const patterns = (Array.isArray(ask.patterns) ? ask.patterns : []).filter((value) => typeof value === "string").slice(0, 32);
            const metadata = object(ask.metadata);
            const detail = [patterns.join(", "), metadata.command, metadata.description, metadata.filepath, metadata.path, metadata.url]
                .find((value) => typeof value === "string" && !!value);
            const message = (detail ? `${type}: ${detail}` : `opencode asks to use ${type}.`).slice(0, 2000);
            const request = { id: ask.id, sessionID: ask.sessionID, type, title: `Allow ${type}?`, message, pattern: patterns,
                ...(typeof metadata.command === "string" ? { metadata: { command: metadata.command } } : {}), expiresAt: new Date(expiresAt).toISOString() };
            const summary = approvalSummary({ tool: type, input: request, message, cwd: entry.directory });
            this.opencode.set(ask.id, { ...(target ? { target } : {}), request, requestLine: summary.request, expiresAt, served: where });
            if (!this.push.available)
                continue;
            const binding = randomUUID();
            this.pushBindings.add(binding, { action: ask.id, expiresAt });
            void this.push.notify({ binding, provider: "opencode", question: false, expiresAt: String(request.expiresAt),
                project: path.basename(entry.directory), computer: this.computerName, ...summary })
                .then(delivered => { if (!delivered)
                this.pushBindings.dropAction(ask.id); })
                .catch(() => { });
        }
        for (const [id, held] of this.opencode) {
            if (held.served?.key === key && !permissions.has(id)) {
                this.opencode.delete(id);
                this.pushBindings.dropAction(id);
            }
        }
        const questions = new Set();
        for (const ask of asks.questions) {
            if (typeof ask.id !== "string" || !/^[A-Za-z0-9_]{1,200}$/.test(ask.id))
                continue;
            questions.add(ask.id);
            if (this.servedQuestions.has(ask.id))
                continue;
            const raw = Array.isArray(ask.questions) ? ask.questions : [];
            // OpenCode's `multiple` is the phone's multiSelect. A set the phone
            // cannot show whole stays in the TUI.
            const mapped = terminalQuestions({ questions: raw.map(value => ({ ...object(value), multiSelect: object(value).multiple === true })) });
            if (!mapped || mapped.length !== raw.length)
                continue;
            const target = await this.servedTarget(entry, client, ask.sessionID).catch(() => undefined);
            if (this.closed)
                return;
            this.servedQuestions.set(ask.id, { ...where, ...(target ? { target } : {}), question: ask, questions: mapped, at: Date.now() });
            while (this.servedQuestions.size > 64)
                this.servedQuestions.delete(this.servedQuestions.keys().next().value);
        }
        for (const [id, held] of this.servedQuestions)
            if (held.key === key && !questions.has(id))
                this.servedQuestions.delete(id);
    }
    /** A served pane's process is gone: nothing it asked can be answered. */
    servedGone(key) {
        for (const [id, held] of this.opencode)
            if (held.served?.key === key) {
                this.opencode.delete(id);
                this.pushBindings.dropAction(id);
            }
        for (const [id, held] of this.servedQuestions)
            if (held.key === key)
                this.servedQuestions.delete(id);
    }
    /** The question a served OpenCode pane is asking, in the shape the phone
     * answers Claude's AskUserQuestion with (`/v1/questions/answer`). */
    servedQuestion(target) {
        if (target.source !== "opencode")
            return undefined;
        const key = JSON.stringify(target);
        const held = [...this.servedQuestions.values()].find(value => value.target && JSON.stringify(value.target) === key);
        if (!held)
            return undefined;
        const title = held.questions[0].question;
        return { toolName: "AskUserQuestion", actionId: held.question.id, message: title,
            request: approvalSummary({ tool: "Question", message: title, question: true }).request,
            choice: questionChoice(held.questions, 0), questions: held.questions, questionIndex: 0, at: new Date(held.at).toISOString() };
    }
    servedClient(server, pane) {
        const entry = servedPane(server, pane);
        if (!entry)
            throw new BridgeError(409, "This approval is no longer pending.");
        return paneClient(entry);
    }
    /** Answers a served pane's permission ask over its own API. */
    async answerServed(id, held, decision) {
        const reply = opencodePermissionReply(decision);
        if (!reply)
            throw new BridgeError(400, "The approval answer is not valid.");
        const served = held.served;
        const client = this.servedClient(served.server, served.pane);
        // Everywhere writes the user's config before the pane is unblocked, so a
        // failed write leaves the ask answerable rather than half-answered.
        if (decision === "allow-everywhere")
            await allowOpencodeToolEverywhere(held.request.type);
        try {
            await client.replyPermission(id, reply);
        }
        catch {
            throw new BridgeError(409, "This approval is no longer pending.");
        }
        this.opencode.delete(id);
        this.pushBindings.dropAction(id);
    }
    /** The phone's answers to a served pane's question: the labels it chose
     * (and a typed answer) per question, for exactly the questions it shows. */
    async answerServedQuestion(target, questions, answers) {
        const key = JSON.stringify(target);
        const found = [...this.servedQuestions.entries()].find(([, value]) => value.target && JSON.stringify(value.target) === key);
        if (!found)
            throw new BridgeError(409, "This question is no longer pending.");
        const [id, held] = found;
        const same = held.questions.length === questions.length && held.questions.every((question, index) => question.question === questions[index].question.trim()
            && question.options.length === questions[index].options.length
            && question.options.every((option, position) => option.label === questions[index].options[position].label.trim()));
        if (!same)
            throw new BridgeError(409, "That question has changed. Open phren to answer it.");
        await validateTarget(target);
        const labels = answers.map((answer, index) => [...answer.options.map(option => held.questions[index].options[option].label), ...(answer.text ? [answer.text] : [])]);
        try {
            await this.servedClient(held.server, held.pane).replyQuestion(id, labels);
        }
        catch {
            throw new BridgeError(409, "This question is no longer pending.");
        }
        this.servedQuestions.delete(id);
    }
    /** Keys the phone sends to a served OpenCode pane that its API answers
     * better than the terminal: Esc declines a pending question or stops a
     * working turn (`session.abort`), and a digit answers a pending
     * single-question, single-choice set. False leaves the keys to the pane. */
    async servedKeys(target, keys, status) {
        if (target.source !== "opencode")
            return false;
        const entry = servedPane(target.server, target.pane);
        if (!entry)
            return false;
        const client = paneClient(entry), key = JSON.stringify(target);
        const question = [...this.servedQuestions.entries()].find(([, value]) => value.target && JSON.stringify(value.target) === key);
        const escape = keys.length > 0 && keys.every(value => value === "Escape");
        if (question && escape) {
            await client.rejectQuestion(question[0]).catch(() => { throw new BridgeError(409, "This question is no longer pending."); });
            this.servedQuestions.delete(question[0]);
            return true;
        }
        if (question && keys.length === 1 && /^[1-9]$/.test(keys[0]) && question[1].questions.length === 1 && !question[1].questions[0].multiSelect) {
            const option = question[1].questions[0].options[Number(keys[0]) - 1];
            if (!option)
                throw new BridgeError(400, "Choose an available answer.");
            await client.replyQuestion(question[0], [[option.label]]).catch(() => { throw new BridgeError(409, "This question is no longer pending."); });
            this.servedQuestions.delete(question[0]);
            return true;
        }
        if (escape && status === "working") {
            // A failed abort leaves Esc to the terminal, as before.
            return client.abort(target.session).then(done => done === true, () => false);
        }
        return false;
    }
    /** A session's exact pane. A recorded binding carries the full target; when
     * there is none, Herdr's explicit session identity names the pane. */
    async resolveTarget(source, session) {
        const root = path.join(bridgeRoot(), "bindings");
        for await (const folder of directoryNames(root, 64)) {
            for await (const name of directoryNames(path.join(root, folder), 1024)) {
                if (!name.endsWith(".json"))
                    continue;
                let value;
                try {
                    const file = path.join(root, folder, name), info = await lstat(file);
                    if (!info.isFile() || info.size > 65_536)
                        continue;
                    value = object(JSON.parse(await readFile(file, "utf8")));
                }
                catch {
                    continue;
                }
                if (value.source !== source || value.session !== session)
                    continue;
                if (typeof value.workspace !== "string" || typeof value.tab !== "string")
                    continue;
                const parsed = targetSchema.safeParse({ server: decodeURIComponent(folder), workspace: value.workspace, tab: value.tab,
                    pane: decodeURIComponent(name.slice(0, -".json".length)), source, session });
                if (parsed.success)
                    return parsed.data;
            }
        }
        const live = await servers().catch(() => []);
        for (const entry of live) {
            const server = String(entry.session);
            const state = await snapshot(server).catch(() => undefined);
            if (!state)
                continue;
            for (const pane of objects(state.panes)) {
                if (pane.agent !== source)
                    continue;
                const reported = object(pane.agent_session);
                if (reported.kind !== "id" || reported.agent !== source || reported.value !== session)
                    continue;
                const parsed = targetSchema.safeParse({ server, workspace: pane.workspace_id, tab: pane.tab_id,
                    pane: pane.pane_id, source, session });
                if (parsed.success)
                    return parsed.data;
            }
        }
        return undefined;
    }
    /** Push once for each fan-out job whose blocked.json the plugin wrote. */
    async sweepBlockedFanouts() {
        if (this.closed || this.fanoutSweeping)
            return;
        this.fanoutSweeping = true;
        try {
            const jobs = await blockedFanouts().catch(() => []);
            if (this.closed)
                return;
            const live = this.fanoutLive;
            live.clear();
            for (const job of jobs) {
                live.add(job.id);
                const parsed = job.at ? Date.parse(job.at) : 0;
                const stamp = Number.isFinite(parsed) ? parsed : 0;
                if (this.fanoutSeen.get(job.id) === stamp)
                    continue;
                this.fanoutSeen.set(job.id, stamp);
                void this.push.notifyFanoutBlocked({ job: job.id, label: job.label, provider: job.provider, reason: job.reason }).catch(() => { });
            }
            for (const id of this.fanoutSeen.keys())
                if (!live.has(id))
                    this.fanoutSeen.delete(id);
        }
        finally {
            this.fanoutSweeping = false;
        }
    }
    /** Move finished fan-out folders older than a day into the archive; one log
     * line records a sweep that moved or deleted anything, and a sweep that
     * fails never takes the Hook down. */
    async sweepFanoutArchive() {
        try {
            const { moved, deleted } = await archiveFinishedFanouts();
            if (!moved.length && !deleted)
                return;
            logger.info("fanouts", `Archived ${moved.length} finished fan-out job(s); deleted ${deleted} past the archive cap.`);
        }
        catch { /* The archive sweep must never break the Hook. */ }
    }
    watch(target) {
        const key = JSON.stringify(target);
        this.watching.set(key, (this.watching.get(key) || 0) + 1);
        return () => { const n = (this.watching.get(key) || 1) - 1; if (n)
            this.watching.set(key, n);
        else
            this.watching.delete(key); };
    }
    /** The conversation's own turn events, for dispatch returns (turn-records.ts).
     * A failed write never fails the agent's callback. */
    async recordTurn(target, pane, body, dispatch) {
        if (typeof pane.terminal_id !== "string")
            return;
        await recordTurn(target.server, target.pane, { event: String(body.event), terminal: pane.terminal_id, source: target.source, session: target.session,
            ...(dispatch ? { dispatch } : {}),
            ...(typeof body.background === "number" ? { background: body.background } : {}),
            ...(typeof body.reply === "string" ? { reply: body.reply } : {}),
            ...(body.event === "Stop" && typeof body.cwd === "string" ? { cwd: body.cwd } : {}) }).catch(() => undefined);
    }
    rememberTerminalPrompt(target, body) {
        const tool = String(body.tool || "action").slice(0, 200);
        const questions = tool === "AskUserQuestion" ? terminalQuestions(body.input) : undefined;
        const choice = questions ? questionChoice(questions, 0) : terminalChoice(body.input);
        const summary = approvalSummary({ tool, input: body.input, question: tool === "AskUserQuestion" });
        this.terminalPrompts.set(JSON.stringify(target), { tool,
            message: JSON.stringify(body.input || {}, null, 2).slice(0, 32_768), ...(choice ? { choice } : {}),
            ...summary,
            ...(questions ? { questions, questionIndex: 0 } : {}), at: Date.now() });
        while (this.terminalPrompts.size > 64)
            this.terminalPrompts.delete(this.terminalPrompts.keys().next().value);
    }
    /** The request the agent is showing in its terminal, if one fell through
     * in the last fifteen minutes; the caller only asks while the pane waits. */
    terminalPrompt(target) {
        const key = JSON.stringify(target), entry = this.terminalPrompts.get(key);
        if (!entry)
            return undefined;
        if (Date.now() - entry.at > 900_000) {
            this.terminalPrompts.delete(key);
            return undefined;
        }
        return { toolName: entry.tool, message: entry.message, ...(entry.request ? { request: entry.request } : {}), ...(entry.choice ? { choice: entry.choice } : {}),
            ...(entry.questions?.length ? { questions: entry.questions, questionIndex: entry.questionIndex ?? 0 } : {}), at: new Date(entry.at).toISOString() };
    }
    clearTerminalPrompt(target) { this.terminalPrompts.delete(JSON.stringify(target)); }
    /** True while the pane's own terminal is reading a password. */
    passwordPrompt(target) { return this.passwords.get(JSON.stringify(target)) === true; }
    /** Claude Code's auto-mode fallback, opencode and Codex draw a numbered
     * dialog in the pane with no PermissionRequest hook behind it. While the pane
     * waits with nothing else to ask, read its last lines (at most once per
     * three seconds per pane) and publish the dialog as a terminal choice; drop
     * it when the pane leaves waiting or the dialog lines vanish. A remembered
     * permission request that is not a dialog keeps the slot untouched. The same
     * read notes whether the terminal is reading a password. */
    async syncTerminalDialog(target, active) {
        const key = JSON.stringify(target), entry = this.terminalPrompts.get(key);
        // A served OpenCode pane lists its asks over its API; its screen is not read.
        if (target.source === "opencode" && servedPane(target.server, target.pane)) {
            if (entry)
                this.terminalPrompts.delete(key);
            return;
        }
        const held = [...this.pending.values()].find(p => JSON.stringify(p.target) === key);
        // A held Codex permission can already have real choices in its pane.
        // Refresh those before publishing the approval, even while it is held.
        // One the Hook's own app-server asked is answered over RPC, not in the pane.
        if (held?.appServer)
            return;
        if (held && target.source === "codex") {
            const now = Date.now();
            if (now - (this.dialogReads.get(key) ?? 0) < DIALOG_READ_MS)
                return;
            this.dialogReads.set(key, now);
            const prompt = permissionPrompt(held.tool, held.input, await this.paneLines(target));
            if ([...this.pending.values()].includes(held)) {
                held.choice = prompt.choice;
                held.title = prompt.title;
                if (held.choice)
                    this.terminalPrompts.set(key, { tool: held.tool, message: held.message,
                        request: held.request, requestKind: held.requestKind, choice: held.choice, dialog: true, at: now });
                else
                    this.terminalPrompts.delete(key);
            }
            return;
        }
        // A released AskUserQuestion is answered by its own question card, never
        // by the pane's numbered lines. It is cleared when the pane stops waiting
        // or no longer draws a question (answered or cancelled in the terminal).
        const question = !!entry?.questions?.length;
        if (question && !active) {
            this.terminalPrompts.delete(key);
            this.passwords.delete(key);
            return;
        }
        // A released permission request that carried its own choices keeps them;
        // one without (a Bash or MCP call) reads the pane's rows below.
        if (!question && entry && !entry.dialog && entry.choice && !entry.released)
            return;
        if (!active) {
            if (entry)
                this.terminalPrompts.delete(key);
            this.passwords.delete(key);
            return;
        }
        const now = Date.now();
        if (now - (this.dialogReads.get(key) ?? 0) < DIALOG_READ_MS)
            return;
        this.dialogReads.set(key, now);
        while (this.dialogReads.size > 128)
            this.dialogReads.delete(this.dialogReads.keys().next().value);
        const text = await this.paneLines(target);
        // A tall question has its tab bar above the 40 lines: look further up, for it only.
        const tall = target.source === "claude" && /Chat about this/.test(text) && !claudeQuestionDialog(text)
            ? await this.paneLines(target, true, QUESTION_READ_LINES).catch(() => "") : "";
        const asking = target.source === "claude" && !!(claudeQuestionDialog(text) || (tall && claudeQuestionDialog(tall)));
        this.passwords.set(key, passwordLine(text));
        while (this.passwords.size > 128)
            this.passwords.delete(this.passwords.keys().next().value);
        // Claude's AskUserQuestion dialog is answered from the question itself
        // (the held request, the remembered one, or the transcript's), never as
        // a numbered dialog: its digits advance on their own and a trailing
        // Enter would land on the next tab.
        if (asking) {
            if (entry && !question)
                this.terminalPrompts.delete(key);
            return;
        }
        if (question) {
            this.terminalPrompts.delete(key);
            return;
        }
        const dialog = await this.screenDialog(target, text);
        if (entry && !entry.dialog) {
            // The permission the pane still shows after its hook let go: keep the
            // request's own details and answer it with the dialog's rows, so the
            // phone can approve it long after the hold ended. Not a
            // `dialog`: Claude takes a row's digit at once, and an Enter after it
            // could land on the next permission.
            if (dialog?.title) {
                entry.choice = dialog;
                entry.released = true;
            }
            else {
                if (entry.released) {
                    delete entry.choice;
                    delete entry.released;
                }
                settleBlockedPane(target.server, target.pane);
            }
            return;
        }
        // No dialog left: a request answered in the terminal itself.
        if (!dialog?.title) {
            if (entry)
                this.terminalPrompts.delete(key);
            settleBlockedPane(target.server, target.pane);
            return;
        }
        this.terminalPrompts.set(key, { tool: "Question", message: dialog.title,
            ...approvalSummary({ tool: "Question", message: dialog.title }),
            choice: dialog, dialog: true, at: now });
        while (this.terminalPrompts.size > 64)
            this.terminalPrompts.delete(this.terminalPrompts.keys().next().value);
    }
    /** Resolve a phone option identifier. Real shortcuts retain their key path;
     * keyless rows are reached and verified before returning Enter to the route. */
    async dialogAnswerKeys(target, keys) {
        const entry = this.terminalPrompts.get(JSON.stringify(target));
        const choice = entry?.choice;
        const option = choice?.options.find(option => keys.includes(option.key));
        if (choice && option?.hasKey === false) {
            if (keys.length !== 1)
                throw new BridgeError(409, "Choose one terminal option at a time.");
            await this.moveDialogHighlight(target, choice, option.key);
            return ["Enter"];
        }
        const digit = keys.some(key => key.length === 1 && key >= "1" && key <= "9");
        if (digit && !option && choice?.options.some(option => option.hasKey === false)) {
            throw new BridgeError(409, "That terminal option is no longer available. Open terminal to choose an option.");
        }
        return entry?.dialog && digit ? [...keys, "Enter"] : [...keys];
    }
    async moveDialogHighlight(target, expected, key, beforeKeys) {
        if (target.source === "opencode") {
            await this.selectOpencodeOnce(target, expected, beforeKeys);
            return;
        }
        const intended = expected.options.findIndex(option => option.key === key);
        let current = visibleTerminalChoice(await this.paneLines(target));
        // A held permission can expose just the asking sentence from the title.
        // Anchor subsequent reads to the entire live title, including its command.
        const title = current?.title;
        const matches = (choice) => !!choice
            && choice.highlightedIndex !== undefined && choice.title === title
            && (title === expected.title || !!expected.title && !!title?.split("\n").includes(expected.title))
            && JSON.stringify(choice.options) === JSON.stringify(expected.options);
        for (let attempt = 0; attempt < 2; attempt++) {
            if (!matches(current))
                break;
            await validateTarget(target, false, true);
            const difference = intended - current.highlightedIndex;
            if (!difference)
                return;
            await beforeKeys?.();
            await terminalProvider().sendKeys(target.server, target.pane, Array(Math.abs(difference)).fill(difference > 0 ? "down" : "up"));
            await new Promise(resolve => setTimeout(resolve, 150));
            current = visibleTerminalChoice(await this.paneLines(target));
            if (matches(current) && current.highlightedIndex === intended) {
                // Pane reads and cursor movement can outlive the session the phone
                // selected. Check its fresh identity before permitting confirmation.
                await validateTarget(target, false, true);
                return;
            }
        }
        throw new BridgeError(409, "Could not move and verify the terminal selection. Open terminal to choose this option.");
    }
    /** Put OpenCode's cursor on Allow once, the row's first option: ← as many
     * times as the cursor sits to its right, then read the colors again. The
     * same prompt must still be showing, or nothing more is sent. */
    async selectOpencodeOnce(target, expected, beforeKeys) {
        const read = async () => opencodePermissionDialog(await this.paneAnsi(target));
        const current = await read();
        if (current && current.choice.title === expected.title && current.selected !== undefined) {
            await validateTarget(target, false, true);
            if (current.selected > 0) {
                await beforeKeys?.();
                await terminalProvider().sendKeys(target.server, target.pane, Array(current.selected).fill("left"));
                await new Promise(resolve => setTimeout(resolve, 150));
            }
            const moved = current.selected > 0 ? await read() : current;
            if (moved && moved.choice.title === expected.title && moved.selected === 0) {
                await validateTarget(target, false, true);
                return;
            }
        }
        throw new BridgeError(409, "Could not move and verify the terminal selection. Open terminal to choose this option.");
    }
    /** Answer Claude's AskUserQuestion dialog in the pane: every question in
     * `answers` from `from` on, then the set's submission when `submit`. The
     * walk reads the pane before and after each key, so a dialog on another
     * tab, a changed question or a missed key stops it with nothing stray
     * typed. A finished set clears the remembered question. */
    async answerClaudeQuestions(target, questions, answers, options = {}) {
        const key = JSON.stringify(target);
        await answerClaudeQuestionDialog({
            // A tall question (long text, many options) pushes its tab bar out of 40 lines.
            read: () => this.paneLines(target, true, QUESTION_READ_LINES),
            keys: async (keys) => {
                if (!keys.length)
                    return;
                await validateTarget(target, false, true);
                await terminalProvider().sendKeys(target.server, target.pane, keys);
            },
        }, questions, answers, options);
        this.dialogReads.delete(key);
        if (options.submit ?? true)
            this.terminalPrompts.delete(key);
    }
    /** An older phone answers a released AskUserQuestion one question at a
     * time with the chosen digits through `/v1/keys`. Walk that question with
     * the same verified steps and submit after the last. False when the
     * prompt is not a remembered question, so the keys take the usual path. */
    async answerReleasedQuestion(target, keys) {
        const key = JSON.stringify(target), entry = this.terminalPrompts.get(key);
        if (target.source !== "claude" || !entry?.questions?.length)
            return false;
        const digits = keys.filter(value => /^[1-9]$/.test(value));
        if (!digits.length)
            return false;
        const index = Math.min(entry.questionIndex ?? 0, entry.questions.length - 1), last = index + 1 >= entry.questions.length;
        await this.answerClaudeQuestions(target, entry.questions, [{ options: digits.map(digit => Number(digit) - 1) }], { from: index, submit: last });
        if (!last) {
            entry.questionIndex = index + 1;
            entry.choice = questionChoice(entry.questions, index + 1);
        }
        return true;
    }
    startCompacting(target) {
        this.compactingSince.set(JSON.stringify(target), Date.now());
        while (this.compactingSince.size > 64)
            this.compactingSince.delete(this.compactingSince.keys().next().value);
    }
    stopCompacting(target) { this.compactingSince.delete(JSON.stringify(target)); }
    /** True while Claude Code is compacting `target`; a boundary older than ten
     * minutes is stale, so a missed SessionStart cannot pin the state forever. */
    compacting(target) {
        const key = JSON.stringify(target), at = this.compactingSince.get(key);
        if (at === undefined)
            return false;
        if (Date.now() - at > 600_000) {
            this.compactingSince.delete(key);
            return false;
        }
        return true;
    }
    menuOpened(target, command) {
        this.menus.set(JSON.stringify(target), { at: Date.now(), ...(command ? { command } : {}) });
        while (this.menus.size > 64)
            this.menus.delete(this.menus.keys().next().value);
    }
    menuOpen(target) {
        const entry = this.menus.get(JSON.stringify(target));
        return entry !== undefined && Date.now() - entry.at < 30_000;
    }
    /** The bare slash command that opened this pane's current menu window. */
    menuCommand(target) {
        const entry = this.menus.get(JSON.stringify(target));
        return entry && Date.now() - entry.at < 30_000 ? entry.command : undefined;
    }
    menuClosed(target) { this.menus.delete(JSON.stringify(target)); }
    /** The pane with its colors, for dialogs whose cursor is only a color. */
    paneAnsi(target) {
        return readPaneText(target.server, target.pane, { scope: "agent", source: "visible", lines: 40, stripAnsi: false, format: "ansi", timeoutMs: 2_000 });
    }
    /** Read what the pane draws, stripping ANSI unless placeholder styling is needed. */
    paneLines(target, stripAnsi = true, lines = 40) {
        return readPaneText(target.server, target.pane, { scope: "agent", source: "visible", lines, stripAnsi, timeoutMs: 2_000 });
    }
    /** After the phone walks Codex's /permissions menu onto Full Access, the
     * agent draws a second "Enable full access?" confirmation. Watch the pane's
     * terminal lines for it, answer with its own numbered choice, and only then
     * close the menu window. A confirmation that never arrives within three
     * seconds is left open as the visible prompt, published as the terminal
     * choice the phone draws as a question card. Runs at most once per window. */
    async walkMenuConfirmation(target) {
        const key = JSON.stringify(target), entry = this.menus.get(key);
        if (!entry || entry.confirmationAttempted)
            return entry ? { menuClosed: false } : { menuClosed: true };
        entry.confirmationAttempted = true;
        const deadline = Date.now() + 3_000;
        for (;;) {
            const lines = await this.paneLines(target);
            const choice = visibleTerminalChoice(lines);
            if (choice && /\benable full access\b/i.test(choice.title ?? "")) {
                const option = choice.options[0];
                if (option.hasKey === false)
                    await this.moveDialogHighlight(target, choice, option.key);
                await validateTarget(target, false, true);
                await terminalProvider().sendKeys(target.server, target.pane, option.hasKey === false ? ["enter"] : [option.key.toLowerCase(), "enter"]);
                this.clearTerminalPrompt(target);
                this.menuClosed(target);
                return { menuClosed: true };
            }
            if (Date.now() >= deadline) {
                const message = lines.trim().slice(0, 32_768) || "The terminal is still waiting for an answer.";
                this.terminalPrompts.set(key, { tool: "Permissions", message, ...(choice ? { choice } : {}), at: Date.now() });
                while (this.terminalPrompts.size > 64)
                    this.terminalPrompts.delete(this.terminalPrompts.keys().next().value);
                return { menuClosed: false, waiting: { message, ...(choice ? { choice } : {}) } };
            }
            await new Promise(resolve => setTimeout(resolve, 150));
        }
    }
    approval(target) {
        const pending = [...this.pending.entries()].find(([, p]) => JSON.stringify(p.target) === JSON.stringify(target));
        if (pending)
            return { actionId: pending[0], toolName: pending[1].tool, title: pending[1].choice?.title ?? pending[1].title, message: pending[1].message,
                request: pending[1].request,
                details: pending[1].message, terminalOnly: target.source === "codex" && !pending[1].choice && !pending[1].appServer,
                ...(pending[1].choice ? { choice: pending[1].choice } : {}),
                // An app-server request waits for as long as it takes: its card never runs out.
                expiresAt: pending[1].appServer ? new Date(Math.max(Date.parse(pending[1].expiresAt), Date.now() + DIALOG_PUSH_MS)).toISOString() : pending[1].expiresAt,
                ...(pending[1].conductor ? { conductor: pending[1].conductor } : {}) };
        const own = target.source === "opencode" ? this.opencodeApproval(target) : undefined;
        if (own)
            return own;
        // A fan-out worker this conversation started is waiting on the owner.
        const worker = this.fanoutHeld(target)[0];
        return worker?.fanout ? { actionId: worker.fanout.actionId, toolName: worker.request.type, title: worker.request.title,
            message: worker.request.message, request: worker.requestLine, expiresAt: worker.request.expiresAt, options: OPENCODE_APPROVAL_OPTIONS } : undefined;
    }
    opencodeApproval(target) {
        const held = [...this.opencode.values()].find(value => !value.fanout && JSON.stringify(value.target) === JSON.stringify(target));
        if (held)
            return { actionId: held.request.id, toolName: held.request.type, title: held.request.title, message: held.request.message, request: held.requestLine, expiresAt: held.request.expiresAt, options: OPENCODE_APPROVAL_OPTIONS };
        const request = opencodeRequest(target.session);
        return request ? { actionId: request.id, toolName: request.type, title: request.title, message: request.message,
            request: approvalSummary({ tool: String(request.type ?? ""), input: request, message: typeof request.message === "string" ? request.message : undefined }).request,
            expiresAt: request.expiresAt, options: OPENCODE_APPROVAL_OPTIONS } : undefined;
    }
    /** Live fan-out worker asks shown on this parent conversation, oldest first. */
    fanoutHeld(target) {
        const key = JSON.stringify(target);
        return [...this.opencode.values()].filter(value => value.fanout && value.target && JSON.stringify(value.target) === key
            && value.expiresAt > Date.now() && opencodeRequest(value.fanout.session)?.id === value.request.id);
    }
    pendingPanes(server, state) {
        const panes = objects(state.panes);
        const pending = new Set([...this.pending.values()].filter(p => p.target.server === server && panes.some(pane => {
            if (pane.pane_id !== p.target.pane || pane.workspace_id !== p.target.workspace || pane.tab_id !== p.target.tab || pane.agent !== p.target.source)
                return false;
            const reported = object(pane.agent_session);
            return reported.kind !== "id" || (reported.agent === p.target.source && reported.value === p.target.session);
        })).map(p => p.target.pane));
        // A permission or dialog the pane still draws after its hook let go.
        for (const [key, entry] of this.terminalPrompts) {
            if (!entry.choice || entry.questions?.length || Date.now() - entry.at > 900_000)
                continue;
            const target = JSON.parse(key);
            if (target.server !== server)
                continue;
            if (panes.some(pane => pane.pane_id === target.pane && pane.workspace_id === target.workspace && pane.tab_id === target.tab
                && pane.agent === target.source && ["waiting", "blocked"].includes(String(pane.agent_status))))
                pending.add(target.pane);
        }
        for (const pane of panes) {
            if (pane.agent !== "opencode")
                continue;
            const reported = object(pane.agent_session);
            if (reported.kind === "id" && reported.agent === "opencode" && typeof reported.value === "string" && opencodeRequest(reported.value)) {
                pending.add(String(pane.pane_id));
            }
        }
        // A served OpenCode pane's permission or question, listed by its server.
        const served = [...[...this.opencode.values()].filter(held => held.served).map(held => held.target), ...[...this.servedQuestions.values()].map(held => held.target)];
        for (const at of served) {
            if (!at || at.server !== server)
                continue;
            if (panes.some(pane => pane.pane_id === at.pane && pane.workspace_id === at.workspace && pane.tab_id === at.tab && pane.agent === "opencode"))
                pending.add(at.pane);
        }
        for (const held of this.opencode.values()) {
            const parent = held.target;
            if (!held.fanout || !parent || parent.server !== server)
                continue;
            if (panes.some(pane => pane.pane_id === parent.pane && pane.workspace_id === parent.workspace && pane.tab_id === parent.tab)
                && this.fanoutHeld(parent).length)
                pending.add(parent.pane);
        }
        return pending;
    }
    /** Hands the owner's answer to the fan-out launcher waiting on the worker's session. */
    async answerFanout(id, held, decision) {
        const reply = opencodePermissionReply(decision);
        if (!reply)
            throw new BridgeError(400, "The approval answer is not valid.");
        const file = held.fanout && opencodeApprovalFile(held.fanout.session, "answer");
        if (!held.fanout || !file || opencodeRequest(held.fanout.session)?.id !== id)
            throw new BridgeError(409, "This approval is no longer pending.");
        if (decision === "allow-everywhere")
            await allowOpencodeToolEverywhere(held.request.type);
        // The launcher reads the same approve/deny/always vocabulary the plugin does.
        await atomicInPrivateDir(file, JSON.stringify({ id, decision: decision === "approve" || decision === "deny" ? decision : "always" }));
        this.opencode.delete(id);
        this.pushBindings.dropAction(id);
    }
    async answer(target, id, decision, updatedInput) {
        // A terminal dialog offered to a dispatching Hook (`workerApproval`) or
        // pushed, or a held ask whose hold ended with its dialog still on screen:
        // answered with the pane's own keys.
        if (id.startsWith("dialog-") || (!this.pending.has(id) && await this.releasedHoldDialog(id))) {
            const dialog = this.dialogActions.get(id);
            if (!dialog || JSON.stringify(dialog.target) !== JSON.stringify(target))
                throw new BridgeError(409, "This approval is no longer pending.");
            if (updatedInput !== undefined || !["approve", "deny"].includes(String(decision)))
                throw new BridgeError(400, "The approval answer is not valid.");
            await this.answerDialog(id, decision);
            this.dropForwardedDialog(JSON.stringify(target));
            return;
        }
        const worker = [...this.opencode.entries()].find(([, held]) => held.fanout?.actionId === id && JSON.stringify(held.target) === JSON.stringify(target));
        if (worker) {
            if (updatedInput !== undefined)
                throw new BridgeError(400, "Answers go with an approval.");
            await validateTarget(target);
            await this.answerFanout(worker[0], worker[1], decision);
            return;
        }
        const served = target.source === "opencode" ? this.opencode.get(id) : undefined;
        if (served?.served) {
            if (updatedInput !== undefined)
                throw new BridgeError(400, "Answers go with an approval.");
            if (!served.target || JSON.stringify(served.target) !== JSON.stringify(target))
                throw new BridgeError(409, "This approval is no longer pending.");
            await validateTarget(target);
            await this.answerServed(id, served, decision);
            return;
        }
        if (target.source === "opencode") {
            const reply = opencodePermissionReply(decision);
            if (!reply)
                throw new BridgeError(400, "The approval answer is not valid.");
            const file = opencodeApprovalFile(target.session, "answer");
            if (!file)
                throw new BridgeError(400, "Invalid conversation identity.");
            await validateTarget(target);
            const request = opencodeRequest(target.session);
            if (request?.id !== id)
                throw new BridgeError(409, "This approval is no longer pending.");
            // Everywhere also writes the user's OpenCode config, before the plugin
            // is unblocked so a failed write leaves the ask answerable.
            if (decision === "allow-everywhere")
                await allowOpencodeToolEverywhere(request.type);
            await atomicInPrivateDir(file, JSON.stringify({ id, decision: decision === "approve" || decision === "deny" ? decision : "always" }));
            this.opencode.delete(id);
            this.pushBindings.dropAction(id);
            return;
        }
        const entry = this.pending.get(id);
        if (!entry || JSON.stringify(entry.target) !== JSON.stringify(target))
            throw new BridgeError(409, "This approval is no longer pending.");
        const grantAnswer = entry.conductor && (decision === "allow-project" || decision === "allow-everywhere") ? decision : undefined;
        if (grantAnswer && !entry.conductor)
            throw new BridgeError(400, "Only a conductor dispatch or hand-off can write a grant.");
        const effective = grantAnswer ? "approve" : decision;
        if (!["approve", "deny"].includes(String(effective)))
            throw new BridgeError(409, "This approval is no longer pending.");
        if (updatedInput !== undefined && effective !== "approve")
            throw new BridgeError(400, "Answers go with an approval.");
        const answered = updatedInput === undefined ? undefined : answeredQuestionInput(entry.tool, entry.input, updatedInput);
        await validateTarget(target);
        if (this.pending.get(id) !== entry || entry.response.destroyed)
            throw new BridgeError(409, "This approval is no longer pending.");
        if (grantAnswer) {
            const conductor = entry.conductor;
            if (grantAnswer === "allow-project" && !conductor.project)
                throw new BridgeError(400, "This call has no project to scope a grant to.");
            await ensureGrant({
                scope: grantAnswer === "allow-everywhere" ? "global" : `project:${conductor.project}`,
                actions: [conductor.action],
                ...(conductor.computer && conductor.computer !== "anywhere" ? { computers: [conductor.computer] } : {}),
            });
        }
        if (this.pending.get(id) !== entry || entry.response.destroyed)
            throw new BridgeError(409, "This approval is no longer pending.");
        this.pending.delete(id);
        this.dropPushBindings(id);
        clearTimeout(entry.timer);
        settleBlockedPane(entry.target.server, entry.target.pane, 0);
        entry.response.end(JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: {
                    behavior: effective === "approve" ? "allow" : "deny", ...(effective === "deny" ? { message: "Declined in Phren." } : {}),
                    ...(answered ? { updatedInput: answered } : {}),
                } } }));
    }
    /** A held approval that is really a terminal dialog, answered from the
     * phone with its own keys: let the callback fall back to the terminal at
     * once instead of leaving the card up until the 55-second timer. */
    releaseChoice(target) {
        for (const [id, entry] of this.pending) {
            if (!entry.choice || JSON.stringify(entry.target) !== JSON.stringify(target))
                continue;
            this.pending.delete(id);
            this.dropPushBindings(id);
            clearTimeout(entry.timer);
            if (!entry.response.destroyed)
                entry.response.end("{}");
        }
    }
    /** The phone's Esc or Cancel on a question the Hook holds. A held
     * AskUserQuestion has no terminal choice, so `releaseChoice` never lets it
     * go and the terminal stays frozen until the hold timer; decline it now so
     * Claude moves on. True when a held question was cancelled. */
    cancelHeldQuestion(target) {
        let cancelled = false;
        for (const [id, entry] of this.pending) {
            if (entry.tool !== "AskUserQuestion" || JSON.stringify(entry.target) !== JSON.stringify(target))
                continue;
            this.pending.delete(id);
            this.dropPushBindings(id);
            clearTimeout(entry.timer);
            if (!entry.response.destroyed)
                entry.response.end(JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest",
                        decision: { behavior: "deny", message: "The user cancelled this question in Phren without answering." } } }));
            cancelled = true;
        }
        return cancelled;
    }
    async answerPush(binding, decision) {
        if (!["approve", "deny"].includes(String(decision)))
            throw new BridgeError(400, "The approval answer is not valid.");
        const linked = this.pushBindings.consume(binding);
        if (!linked)
            throw new BridgeError(409, "This approval is no longer pending.");
        // A worker's request on another computer: the callback answers it there.
        const forwarded = this.forwardedPushes.get(linked.action);
        if (forwarded) {
            this.forwardedPushes.delete(linked.action);
            await forwarded(decision);
            return;
        }
        const pending = this.pending.get(linked.action);
        if (pending) {
            await this.answer(pending.target, linked.action, decision);
            return;
        }
        if (await this.releasedHoldDialog(linked.action)) {
            await this.answerDialog(linked.action, decision);
            return;
        }
        const held = this.opencode.get(linked.action);
        // The binding is the single-use proof for a worker's ask, whose parent
        // may not be in any pane the Hook can see.
        if (held?.fanout) {
            await this.answerFanout(linked.action, held, decision);
            return;
        }
        // A served pane's ask is answered over its own API, target or not.
        if (held?.served) {
            await this.answerServed(linked.action, held, decision);
            return;
        }
        if (held?.target) {
            await this.answer(held.target, linked.action, decision);
            return;
        }
        throw new BridgeError(409, "This approval is no longer pending.");
    }
    /** Whether this action now answers a terminal dialog: a released hold's
     * pane is read at once rather than at the next tick, and its dialog
     * adopted under the hold's own action. */
    async releasedHoldDialog(action) {
        const released = [...this.releasedHolds].find(([, hold]) => hold.action === action);
        if (released && !this.dialogActions.has(action)) {
            const target = JSON.parse(released[0]);
            await this.syncTerminalDialog(target, true).catch(() => { });
            const entry = this.terminalPrompts.get(released[0]);
            if (entry?.choice?.title)
                this.adoptReleasedHold(released[0], target, entry.choice, entry.choice.title);
        }
        return this.dialogActions.has(action);
    }
    /** Where a pushed ask lives, without answering it: the phone opens that
     * session's details when the notification itself is tapped. */
    pushTarget(binding) {
        const action = this.pushBindings.peek(binding)?.action;
        if (!action)
            return undefined;
        const held = this.pending.get(action)?.target ?? this.dialogActions.get(action)?.target ?? this.opencode.get(action)?.target;
        if (held)
            return held;
        const released = [...this.releasedHolds].find(([, hold]) => hold.action === action);
        return released ? JSON.parse(released[0]) : undefined;
    }
    /** Every waiting pane on this computer, each Hook tick, whether or not a
     * phone watches: an approval an agent draws as a numbered dialog in its
     * terminal (Claude's fallback prompts, Codex, OpenCode, Copilot) has no
     * hook behind it, so this is the only way it reaches a phone with phren
     * closed. Each dialog is pushed once; a pane that stops waiting drops it. */
    async observeWaitingPanes(server, panes, resolve) {
        const waiting = new Set();
        for (const pane of panes) {
            if (!pane.agent || !["waiting", "blocked"].includes(String(pane.agent_status)))
                continue;
            const target = await resolve(pane).catch(() => undefined);
            if (!target)
                continue;
            const key = JSON.stringify(target);
            waiting.add(key);
            // A served OpenCode pane's asks were pushed as its server listed them.
            if (target.source === "opencode" && servedPane(target.server, target.pane))
                continue;
            // A request its own hook already holds was pushed on arrival.
            if ([...this.pending.values()].some(held => JSON.stringify(held.target) === key))
                continue;
            // Read the dialog even with no push device: the overview marks the
            // tab as needing permission from it, and the phone answers it there.
            await this.syncTerminalDialog(target, true).catch(() => { });
            if (!this.push.available)
                continue;
            const entry = this.terminalPrompts.get(key);
            const title = entry?.dialog || entry?.released ? entry.choice?.title : undefined;
            if (!entry?.choice || !title) {
                this.dropDialogPush(key);
                continue;
            }
            if (this.dialogPushes.get(key)?.title === title)
                continue;
            this.dropDialogPush(key);
            // Its own notification is already on the phone: answer that one.
            if (this.adoptReleasedHold(key, target, entry.choice, title))
                continue;
            const action = `dialog-${randomUUID()}`, binding = randomUUID(), expiresAt = Date.now() + DIALOG_PUSH_MS;
            this.dialogPushes.set(key, { action, title });
            this.dialogActions.set(action, { target, choice: entry.choice, expiresAt });
            this.pushBindings.add(binding, { action, expiresAt });
            const cwd = await trustedDirectory(pane).catch(() => undefined);
            const summary = entry.request ? { request: entry.request, requestKind: entry.requestKind ?? "other" }
                : approvalSummary({ tool: entry.tool, message: title });
            void this.push.notify({ binding, provider: target.source, question: entry.tool === "AskUserQuestion", expiresAt: new Date(expiresAt).toISOString(),
                ...projectField(cwd), computer: this.computerName, ...summary })
                .then(delivered => { if (!delivered)
                this.dropDialogPush(key); }).catch(() => this.dropDialogPush(key));
        }
        // A dialog stays answerable for as long as its pane shows it.
        const horizon = Date.now() + DIALOG_PUSH_MS;
        for (const key of waiting) {
            const hold = this.releasedHolds.get(key);
            if (hold && hold.expiresAt > Date.now())
                hold.expiresAt = Math.max(hold.expiresAt, horizon);
            for (const action of [this.dialogPushes.get(key)?.action, this.forwardedDialogs.get(key)?.action]) {
                const dialog = action ? this.dialogActions.get(action) : undefined;
                if (dialog && dialog.expiresAt > Date.now())
                    dialog.expiresAt = Math.max(dialog.expiresAt, horizon);
            }
        }
        for (const [key, hold] of this.releasedHolds) {
            // Answered in the terminal, or expired: the notification can no longer act.
            if ((!waiting.has(key) && JSON.parse(key).server === server) || hold.expiresAt <= Date.now()) {
                this.dropPushBindings(hold.action);
                this.releasedHolds.delete(key);
            }
        }
        for (const [key, pushed] of this.dialogPushes) {
            if (!waiting.has(key) && JSON.parse(key).server === server) {
                this.dialogActions.delete(pushed.action);
                this.dropPushBindings(pushed.action);
                this.dialogPushes.delete(key);
            }
        }
        for (const key of this.forwardedDialogs.keys())
            if (!waiting.has(key) && JSON.parse(key).server === server)
                this.dropForwardedDialog(key);
    }
    /** Keeps the held asks of these dispatched panes for the dispatching Hook's
     * next polls: without a phone, a watcher or push here, a worker's request
     * would go straight to its terminal and only its `blocked` state would
     * reach the conductor. Keyed by pane, since a worker that has not yet
     * reported a conversation has no session. */
    leaseDispatch(targets) {
        const now = Date.now();
        for (const [key, expiry] of this.dispatchLeases)
            if (expiry <= now)
                this.dispatchLeases.delete(key);
        for (const target of targets) {
            const key = `${target.server}\n${target.pane}\n${target.source}`;
            this.dispatchLeases.delete(key);
            while (this.dispatchLeases.size >= 256)
                this.dispatchLeases.delete(this.dispatchLeases.keys().next().value);
            this.dispatchLeases.set(key, now + DISPATCH_LEASE_MS);
        }
    }
    dispatchLeased(target) {
        return (this.dispatchLeases.get(`${target.server}\n${target.pane}\n${target.source}`) ?? 0) > Date.now();
    }
    dropForwardedDialog(key) {
        const forwarded = this.forwardedDialogs.get(key);
        if (!forwarded)
            return;
        this.forwardedDialogs.delete(key);
        if (this.dialogPushes.get(key)?.action !== forwarded.action)
            this.dialogActions.delete(forwarded.action);
    }
    /** What this pane's worker is waiting on, for the dispatching Hook: the held
     * or app-server request, else the terminal dialog the pane draws (offered
     * under a `dialog-` action the answer route accepts). */
    workerApproval(target) {
        const clip = (value, max) => typeof value === "string" && value ? value.slice(0, max) : undefined;
        const held = this.approval(target);
        if (held) {
            const actionId = String(held.actionId), pending = this.pending.get(actionId);
            const pushed = pending ? this.pushedHolds.has(actionId) || (!!pending.appServer && this.push.available) : this.push.available;
            const title = clip(held.title, 200), request = clip(held.request, 500), expiresAt = clip(held.expiresAt, 40);
            const conductor = held.conductor;
            return { actionId, tool: clip(held.toolName, 200) ?? "action", ...(title ? { title } : {}), ...(request ? { request } : {}),
                ...(pending ? { requestKind: pending.requestKind } : {}), ...(expiresAt ? { expiresAt } : {}), ...(conductor ? { conductor } : {}), ...(pushed ? { pushed: true } : {}) };
        }
        const key = JSON.stringify(target), entry = this.terminalPrompts.get(key);
        const title = entry && (entry.dialog || entry.released) ? entry.choice?.title : undefined;
        if (!entry?.choice || !title)
            return undefined;
        // The ask was offered while held: keep offering it under the same id.
        this.adoptReleasedHold(key, target, entry.choice, title);
        const pushedDialog = this.dialogPushes.get(key);
        // A dialog is the same one only with the same title and command: Claude and Codex share generic titles.
        const pushedAction = pushedDialog && this.dialogActions.get(pushedDialog.action);
        let action = pushedDialog?.title === title && pushedAction && pushedAction.choice.body === entry.choice.body && pushedAction.expiresAt > Date.now() ? pushedDialog.action : undefined;
        if (!action) {
            const known = this.forwardedDialogs.get(key);
            if (known?.title === title && known.body === entry.choice.body && (this.dialogActions.get(known.action)?.expiresAt ?? 0) > Date.now())
                action = known.action;
            else {
                this.dropForwardedDialog(key);
                while (this.forwardedDialogs.size >= 64)
                    this.dropForwardedDialog(this.forwardedDialogs.keys().next().value);
                action = `dialog-${randomUUID()}`;
                this.forwardedDialogs.set(key, { action, title, body: entry.choice.body });
                this.dialogActions.set(action, { target, choice: entry.choice, expiresAt: Date.now() + DIALOG_PUSH_MS });
            }
        }
        const request = clip(entry.request ?? approvalSummary({ tool: entry.tool, message: title }).request, 500);
        return { actionId: action, tool: entry.tool.slice(0, 200), title: title.slice(0, 200), ...(request ? { request } : {}),
            ...(entry.requestKind ? { requestKind: entry.requestKind } : {}),
            expiresAt: new Date(this.dialogActions.get(action).expiresAt).toISOString(), terminal: true,
            ...(pushedDialog?.action === action ? { pushed: true } : {}) };
    }
    /** A worker's request on another computer, sent to this Hook's own phone:
     * the notification's answer calls `answer`, which sends it back over the
     * dispatch path. */
    pushForwarded(summary, answer) {
        if (!this.push.available || this.closed)
            return;
        const action = `forward-${randomUUID()}`, binding = randomUUID(), expiresAt = Date.now() + DIALOG_PUSH_MS;
        while (this.forwardedPushes.size >= 64) {
            const oldest = this.forwardedPushes.keys().next().value;
            this.forwardedPushes.delete(oldest);
            this.dropPushBindings(oldest);
        }
        this.forwardedPushes.set(action, answer);
        this.pushBindings.add(binding, { action, expiresAt });
        const drop = () => { this.forwardedPushes.delete(action); this.dropPushBindings(action); };
        void this.push.notify({ binding, provider: summary.provider, question: false, expiresAt: new Date(expiresAt).toISOString(),
            ...(summary.project ? { project: summary.project } : {}), computer: summary.computer, request: summary.request,
            ...(summary.requestKind ? { requestKind: summary.requestKind } : {}) })
            .then(delivered => { if (!delivered)
            drop(); }).catch(drop);
    }
    /** Moves a released hold onto the dialog its pane now draws, under the
     * hold's action: as the pushed dialog when its notification is on the
     * phone, else as the dialog offered to a dispatching Hook. True when the
     * hold was pushed, so the dialog needs no second notification. */
    adoptReleasedHold(key, target, choice, title) {
        const hold = this.releasedHolds.get(key);
        if (!hold)
            return false;
        this.releasedHolds.delete(key);
        if (hold.expiresAt <= Date.now()) {
            this.dropPushBindings(hold.action);
            return false;
        }
        this.dialogActions.set(hold.action, { target, choice, expiresAt: hold.expiresAt });
        if (hold.pushed)
            this.dialogPushes.set(key, { action: hold.action, title });
        else {
            this.dropForwardedDialog(key);
            this.forwardedDialogs.set(key, { action: hold.action, title, body: choice.body });
        }
        return hold.pushed;
    }
    dropDialogPush(key) {
        const pushed = this.dialogPushes.get(key);
        if (!pushed)
            return;
        this.dialogActions.delete(pushed.action);
        this.dropPushBindings(pushed.action);
        this.dialogPushes.delete(key);
    }
    /** The dialog a pane's screen shows now. Codex draws "> 1. Yes, proceed (y)"
     * rows and Copilot a boxed select with a cursor row, both answered by moving
     * to a row; OpenCode's prompt is one row of options whose cursor is only a
     * color; the other fallbacks number rows without a key in the label. */
    async screenDialog(target, text) {
        return ["codex", "copilot"].includes(target.source) ? visibleTerminalChoice(text)
            : target.source === "opencode" ? opencodePermissionDialog(await this.paneAnsi(target))?.choice ?? numberedDialog(text)
                : numberedDialog(text);
    }
    /** Approve picks the dialog's yes/allow row (else its first), Deny its
     * no/deny row (else Escape), typed as the pane's own keys. */
    async answerDialog(action, decision) {
        const dialog = this.dialogActions.get(action);
        if (!dialog || dialog.expiresAt <= Date.now())
            throw new BridgeError(409, "This approval is no longer pending.");
        // Claimed before any await: a second answer for the same dialog gets a 409 instead of typing the keys twice.
        this.dialogActions.delete(action);
        const key = JSON.stringify(dialog.target);
        try {
            await validateTarget(dialog.target, false, true);
        }
        catch (error) {
            if (!this.dialogActions.has(action))
                this.dialogActions.set(action, dialog);
            throw error;
        }
        // Read the screen now, not the activity tick's cached prompt: in the seconds
        // since that tick the dialog may have been answered and replaced by another
        // with the same title, and the keys must only ever answer the one forwarded.
        const live = await this.screenDialog(dialog.target, await this.paneLines(dialog.target));
        if (!live || live.title !== dialog.choice.title || live.body !== dialog.choice.body) {
            this.dropDialogPush(key);
            throw new BridgeError(409, "That question in the terminal has changed. Open phren to answer it.");
        }
        const option = decision === "approve"
            ? live.options.find(row => /^(yes|allow|approve|proceed|continue|run)\b/i.test(row.label)) ?? live.options[0]
            : live.options.find(row => /^(no|deny|reject|don'?t|cancel|skip)\b/i.test(row.label));
        const keys = option ? await this.dialogAnswerKeys(dialog.target, [option.key]) : ["Escape"];
        await terminalProvider().sendKeys(dialog.target.server, dialog.target.pane, keys.map(key => key === "Enter" ? "enter" : key === "Escape" ? "esc" : key.toLowerCase()));
        this.clearTerminalPrompt(dialog.target);
        this.dropDialogPush(key);
    }
    dropPushBindings(action) {
        this.pushBindings.dropAction(action);
    }
    /** A server request from one of the Hook's own Codex app-servers: an
     * approval card like a held PermissionRequest, pushed the same way, but
     * with no hold timer. The request stays parked in Codex until someone
     * answers it, here or in the pane's TUI (`codexResolved`). */
    codexRequest(target, request, answer) {
        const shown = appServerApproval(request);
        if (!shown || this.closed)
            return;
        // Replayed after a reconnect: the card stays, answered through the new connection.
        const known = [...this.pending.values()].find(entry => entry.appServer?.requestId === request.requestId && JSON.stringify(entry.target) === JSON.stringify(target));
        if (known?.appServer) {
            known.appServer.answer = answer;
            return;
        }
        const action = randomUUID();
        const held = { requestId: request.requestId, answer };
        const reply = { destroyed: false, end(body) {
                if (reply.destroyed)
                    return;
                reply.destroyed = true;
                // "{}" gives the request back to the pane: it stays parked in Codex.
                const behavior = object(object(object((() => { try {
                    return JSON.parse(body);
                }
                catch {
                    return {};
                } })()).hookSpecificOutput).decision).behavior;
                if (behavior === "allow" || behavior === "deny")
                    held.answer(appServerDecision(request, behavior === "allow"));
            } };
        const cwd = typeof request.params.cwd === "string" && path.isAbsolute(request.params.cwd) ? request.params.cwd : undefined;
        const summary = approvalSummary({ tool: shown.tool, input: shown.input, cwd });
        const expiresAt = Date.now() + DIALOG_PUSH_MS;
        this.pending.set(action, { target, response: reply, tool: shown.tool, input: shown.input, message: JSON.stringify(shown.input, null, 2).slice(0, 32_768),
            ...summary, expiresAt: new Date(expiresAt).toISOString(), appServer: held });
        while (this.pending.size > 64)
            this.pending.delete(this.pending.keys().next().value);
        if (!this.push.available)
            return;
        const binding = randomUUID();
        this.pushBindings.add(binding, { action, expiresAt });
        void this.push.notify({ binding, provider: "codex", question: false, expiresAt: new Date(expiresAt).toISOString(),
            ...projectField(cwd), computer: this.computerName, ...summary })
            .then(delivered => { if (!delivered)
            this.pushBindings.consume(binding); }).catch(() => { });
    }
    /** Another client (the pane's TUI) answered the request, or this one declined it. */
    codexResolved(target, requestId) {
        for (const [action, entry] of this.pending) {
            if (entry.appServer?.requestId !== requestId || JSON.stringify(entry.target) !== JSON.stringify(target))
                continue;
            this.pending.delete(action);
            this.dropPushBindings(action);
            entry.response.destroyed = true;
            settleBlockedPane(target.server, target.pane, 0);
        }
    }
    async start() {
        await this.push.start();
        // The public helper singleton was already checked before this is called.
        const previous = await lstat(localSocket()).catch(() => undefined);
        if (previous) {
            if (!previous.isSocket() || previous.uid !== process.getuid?.())
                throw new Error("Unexpected agent callback socket.");
            await unlink(localSocket());
        }
        this.server = createServer(async (req, res) => {
            res.setHeader("Content-Type", "application/json");
            try {
                if (req.method === "POST" && req.url === "/sudo") {
                    await this.sudo.handle(req, res);
                    return;
                }
                if (req.method !== "POST" || !["/hook", "/conductor-context"].includes(req.url ?? ""))
                    throw new Error("Invalid callback");
                let size = 0;
                const chunks = [];
                for await (const bytes of req) {
                    size += bytes.length;
                    if (size > 1_048_576)
                        throw new Error("Oversized hook");
                    chunks.push(bytes);
                }
                const body = object(JSON.parse(Buffer.concat(chunks).toString()));
                // OpenCode and phren-agent ask when building the root session's system prompt. Resolve
                // its process against the current conductor; no inherited pane env or
                // plugin callback may create a session binding or a turn record here.
                if (req.url === "/conductor-context") {
                    if (this.modules?.has("conductor") === false) {
                        res.end("{}");
                        return;
                    }
                    if (!Number.isSafeInteger(body.pid) || Number(body.pid) <= 0 || typeof body.session !== "string")
                        throw new Error("Invalid context callback");
                    const role = await recordedConductor();
                    if (!role) {
                        res.end("{}");
                        return;
                    }
                    const s = await snapshot(role.server);
                    const pane = objects(s.panes).find(p => p.pane_id === role.pane);
                    if (!pane || !["opencode", "phren"].includes(String(pane.agent)) || (role.terminal && pane.terminal_id !== role.terminal)) {
                        res.end("{}");
                        return;
                    }
                    const pids = (await terminalProvider().processes(role.server, role.pane)).foregroundPids;
                    if (!pids.includes(Number(body.pid)) || await paneIdentity(role.server, pane, true) !== body.session) {
                        res.end("{}");
                        return;
                    }
                    const target = targetSchema.parse({ server: role.server, workspace: pane.workspace_id, tab: pane.tab_id,
                        pane: role.pane, source: pane.agent, session: body.session });
                    const context = await conductorContext(target, s);
                    res.end(JSON.stringify(context ? { context: pane.agent === "phren" ? `${context}\n\n${await conductorBrief()}` : context } : {}));
                    return;
                }
                let target = targetSchema.parse(body.target);
                if (this.modules?.has("git") === false && ["PreToolUse", "PostToolUse"].includes(String(body.event))) {
                    res.statusCode = 404;
                    res.end(JSON.stringify({ error: disabledHint("git") }));
                    return;
                }
                // A callback Codex's app-server daemon ran names the pane that started
                // the daemon, not the conversation's. Place it by the conversation the
                // Hook proves each pane shows, and never record a binding from it.
                const daemon = body.daemon === true;
                if (daemon) {
                    if (target.source !== "codex")
                        throw new Error("Invalid callback");
                    const placed = await paneForCodexSession(target.server, target.session, typeof body.cwd === "string" && path.isAbsolute(body.cwd) ? body.cwd : undefined);
                    if (!placed) {
                        res.end("{}");
                        return;
                    }
                    target = targetSchema.parse({ ...target, workspace: placed.workspace_id, tab: placed.tab_id, pane: placed.pane_id });
                }
                const s = await snapshot(target.server);
                const pane = findPane(s, { workspace: target.workspace, tab: target.tab, pane: target.pane });
                if (!pane || (pane.agent && pane.agent !== target.source))
                    throw new Error("The pane changed");
                const pids = (await terminalProvider().processes(target.server, target.pane)).foregroundPids;
                if (!pids.length)
                    throw new Error("No foreground process");
                if (!daemon && target.source === "claude")
                    notePaneTranscript(paneAccountKey(target.server, target.pane), body.transcript, String(pane.terminal_id ?? ""));
                if (!daemon)
                    await atomicInPrivateDir(bindingPath(target.server, target.pane), JSON.stringify({ terminal: pane.terminal_id, source: target.source,
                        session: target.session, pids, workspace: target.workspace, tab: target.tab, event: String(body.event).slice(0, 64), at: new Date().toISOString() }));
                // A terminal that does not watch its agents (tmux) takes the agent's
                // status from these events.
                // Codex's automatic reviewer decides this request on its own: it is
                // not a question for the owner, so nothing is held, pushed or
                // remembered and the pane is not marked blocked. If Codex hands the
                // request back to the owner it draws its approval dialog, which the
                // waiting-pane dialog read picks up and pushes like any other.
                const autoReview = body.event === "PermissionRequest" && target.source === "codex" && body.autoReview === true;
                const status = autoReview ? undefined : eventStatus(body.event);
                if (status && typeof pane.terminal_id === "string")
                    notePaneStatus(target.server, target.pane, pane.terminal_id, status);
                // Herdr does not watch phren-agent: a pane the Hook reported as running
                // it (a phone launch) takes its status from these events.
                if (status && target.source === "phren" && pane.agent === "phren" && pane.agent_status !== status) {
                    await terminalProvider().reportAgent?.(target.server, target.pane, "phren", status).catch(() => undefined);
                }
                // What a shell call changed on disk: snapshot before, diff after.
                const input = typeof body.input === "string" ? { patch: body.input } : object(body.input), command = [input.command, input.cmd].find(v => typeof v === "string");
                // A shell call by name, or any tool whose input is a command line —
                // Codex has renamed its shell tool more than once.
                // A launched brief's receipt: the worker's own hook names its dispatch
                // id (from its environment, or the brief path in its first prompt when
                // Codex's shared daemon ran the hook with another pane's variables).
                let dispatch;
                if (body.event === "SessionStart" || body.event === "UserPromptSubmit") {
                    const named = !daemon && briefId.safeParse(body.dispatchId).success ? String(body.dispatchId) : undefined;
                    dispatch = named ?? (typeof body.prompt === "string" ? briefIdInPrompt(body.prompt) : undefined);
                    if (dispatch)
                        await recordBriefArrival(dispatch, String(body.event), target).catch(() => undefined);
                }
                // `/new` or `/resume` in a pane on the Hook's own Codex server: follow
                // the TUI to its new thread. Only a callback trusted to name its pane.
                // A prompt or a turn's end also moves a server a nested `codex exec`
                // had taken (before thread-origin.ts) back to the TUI's thread.
                if (["SessionStart", "UserPromptSubmit", "Stop"].includes(String(body.event)) && target.source === "codex" && !daemon)
                    codexServers.follow(target.server, target.pane, target.session);
                if (body.event === "PreCompact") {
                    this.startCompacting(target);
                    res.end("{}");
                    return;
                }
                if (["SessionStart", "UserPromptSubmit", "Stop"].includes(String(body.event)))
                    this.stopCompacting(target);
                if (body.event === "UserPromptSubmit") {
                    if (typeof body.prompt === "string")
                        await this.deliveries.submitted(target, body.prompt.slice(0, 65_536));
                    await this.recordTurn(target, pane, body, dispatch);
                    const context = this.modules?.has("conductor") === false ? undefined : await conductorContext(target, s).catch(() => undefined);
                    res.end(JSON.stringify(context ? { hookSpecificOutput: { hookEventName: "UserPromptSubmit", additionalContext: context } } : {}));
                    return;
                }
                if (body.event === "Stop")
                    this.deliveries.stopped(target);
                if (body.event === "SessionStart" || body.event === "Stop")
                    await this.recordTurn(target, pane, body, dispatch);
                if (body.event === "SessionStart") {
                    const context = this.modules?.has("conductor") === false ? undefined : await conductorContext(target, s).catch(() => undefined);
                    res.end(JSON.stringify(context ? { hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context } } : {}));
                    return;
                }
                if ((this.modules?.has("git") ?? true) && ["PreToolUse", "PostToolUse"].includes(String(body.event)) && capturesChanges(String(body.tool), input)) {
                    const conversation = `${target.source}:${target.session}`, id = String(body.toolUseId || "").slice(0, 200);
                    if (body.event === "PreToolUse") {
                        const hungUp = watchHangUp(res);
                        await this.changes.before(conversation, id, typeof body.cwd === "string" && path.isAbsolute(body.cwd) ? body.cwd : await trustedDirectory(pane), command ?? "", input);
                        // The callback gave up (TOOL_HOOK_BUDGET_MS) and the tool ran while this was read.
                        if (hungUp())
                            this.changes.drop(conversation, id);
                    }
                    else
                        await this.changes.after(conversation, id);
                    res.end("{}");
                    return;
                }
                // Validate applicable shell restrictions before any directory/Git reads.
                const ruleEffect = body.event === "PermissionRequest" && target.source === "claude"
                    ? await approvalRuleEffect(String(body.tool), body.input, async () => {
                        if (typeof body.cwd !== "string" || !path.isAbsolute(body.cwd))
                            return undefined;
                        const [paneContext, requestContext] = await Promise.all([
                            trustedDirectory(pane).then(cwd => approvalRuleContext(cwd, target.source, target.session)).catch(() => undefined),
                            approvalRuleContext(body.cwd, target.source, target.session),
                        ]);
                        return paneContext && requestContext?.project === paneContext.project ? requestContext : undefined;
                    }, undefined, { harness: target.source, session: target.session, computer: hostname() }) : undefined;
                if (ruleEffect === "deny") {
                    res.end(JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "deny", message: "Denied by an owner approval rule." } } }));
                    return;
                }
                if (body.event === "PermissionRequest")
                    this.terminalPrompts.delete(JSON.stringify(target));
                if (body.event === "PermissionRequest" && ruleEffect !== "always-ask") {
                    const conductor = conductorCall(String(body.tool || "action"), body.input);
                    if (conductor) {
                        // A standing grant answers the call before it becomes an approval card.
                        const grant = await findGrant({
                            action: conductor.action, project: conductor.project, computer: conductor.computer,
                        }).catch(() => undefined);
                        if (grant) {
                            res.end(JSON.stringify({ hookSpecificOutput: { hookEventName: "PermissionRequest", decision: { behavior: "allow" } } }));
                            return;
                        }
                    }
                }
                if (autoReview) {
                    res.end("{}");
                    return;
                }
                // A thread on the Hook's own Codex app-server: the request reaches
                // this Hook as a server request with no time limit, so the callback
                // lets Codex ask for it at once instead of holding a second card.
                if (body.event === "PermissionRequest" && target.source === "codex" && codexServers.forThread(target.session)) {
                    res.end("{}");
                    return;
                }
                if (body.event !== "PermissionRequest" || target.source === "copilot"
                    || (!this.watching.has(JSON.stringify(target)) && !this.overview.has(target.server) && !this.push.available && !this.dispatchLeased(target))) {
                    if (body.event === "PermissionRequest")
                        this.rememberTerminalPrompt(target, body);
                    res.end("{}");
                    return;
                }
                // A foreground watcher or configured push device can hold the callback.
                // Timeouts always return control to the ordinary terminal prompt.
                if (this.pending.size >= 64) {
                    res.end("{}");
                    return;
                }
                const action = randomUUID();
                this.dialogReads.delete(JSON.stringify(target));
                const conductor = body.event === "PermissionRequest" ? conductorCall(String(body.tool || "action"), body.input) : undefined;
                const locallyWatched = this.watching.has(JSON.stringify(target)) || this.overview.has(target.server) || this.dispatchLeased(target);
                const cwd = typeof body.cwd === "string" && path.isAbsolute(body.cwd) ? body.cwd : await trustedDirectory(pane).catch(() => undefined);
                // When the hold ends the request stays in the terminal. A pushed
                // notification keeps working: its binding waits for the pane's dialog
                // and then answers that, instead of going dead with the hold.
                let released = false;
                const timer = setTimeout(() => {
                    released = true;
                    this.pending.delete(action);
                    this.rememberTerminalPrompt(target, body);
                    // The id was already handed out (phone card, push, dispatcher): it keeps answering the dialog.
                    const pushed = this.pushedHolds.get(action);
                    this.releasedHolds.set(JSON.stringify(target), { action, expiresAt: pushed ?? Date.now() + DIALOG_PUSH_MS, pushed: pushed !== undefined });
                    this.pushedHolds.delete(action);
                    res.end("{}");
                }, APPROVAL_HOLD_MS);
                const expiresAt = new Date(Date.now() + APPROVAL_HOLD_MS).toISOString();
                const { choice, title } = permissionPrompt(String(body.tool || "action"), body.input);
                const summary = approvalSummary({ tool: String(body.tool || "action"), input: body.input, cwd, question: body.tool === "AskUserQuestion" });
                this.pending.set(action, { target, response: res, tool: String(body.tool || "action").slice(0, 200), input: body.input, title,
                    message: JSON.stringify(body.input || {}, null, 2).slice(0, 32_768), ...summary, ...(choice ? { choice } : {}), expiresAt, timer,
                    ...(conductor ? { conductor } : {}) });
                res.on("close", () => { clearTimeout(timer); this.pending.delete(action); this.pushedHolds.delete(action); if (!released)
                    this.dropPushBindings(action); });
                if (this.push.available) {
                    const binding = randomUUID(), pushExpiresAt = Date.now() + DIALOG_PUSH_MS;
                    this.pushBindings.add(binding, { action, expiresAt: pushExpiresAt });
                    this.pushedHolds.set(action, pushExpiresAt);
                    void this.push.notify({ binding, provider: target.source, question: body.tool === "AskUserQuestion",
                        expiresAt: new Date(pushExpiresAt).toISOString(), ...projectField(cwd), computer: this.computerName, ...summary }).catch(() => false).then(delivered => {
                        if (!delivered) {
                            this.pushBindings.consume(binding);
                            this.pushedHolds.delete(action);
                            const pending = this.pending.get(action);
                            if (!locallyWatched && pending) {
                                clearTimeout(pending.timer);
                                this.pending.delete(action);
                                res.end("{}");
                            }
                        }
                    });
                }
            }
            catch {
                if (!res.headersSent)
                    res.statusCode = 400;
                res.end("{}");
            }
        });
        this.server.requestTimeout = 65_000;
        await new Promise((resolve, reject) => { this.server.once("error", reject); this.server.listen(localSocket(), () => resolve()); });
        await chmod(localSocket(), 0o600);
        // A permission ask the opencode plugin writes must reach the phone even
        // when nobody is polling; fs.watch catches the atomic rename, and a slow
        // poll covers a watcher that a platform drops.
        await mkdir(this.approvalsDirectory(), { recursive: true, mode: 0o700 }).catch(() => { });
        try {
            this.opencodeWatcher = watch(this.approvalsDirectory(), { persistent: false }, () => this.scheduleOpencodeSweep());
            this.opencodeWatcher.on("error", () => { this.opencodeWatcher?.close(); this.opencodeWatcher = undefined; });
        }
        catch {
            this.opencodeWatcher = undefined;
        }
        // The poll backs up the watcher. It skips while nothing is held and every
        // running Herdr server's recent snapshot shows no opencode pane; a new
        // request file still reaches the sweep through the watcher.
        this.opencodePoll = setInterval(() => {
            countTick("opencode-approvals");
            const panes = this.opencodeWatcher && !this.opencode.size ? knownPanes(OPENCODE_PANES_FRESH_MS) : undefined;
            if (panes && !panes.some(pane => pane.agent === "opencode"))
                return;
            this.scheduleOpencodeSweep();
        }, APPROVAL_SWEEP_MS);
        this.opencodePoll.unref?.();
        this.fanoutTimer = setInterval(() => { countTick("fanout-blocked"); void this.sweepBlockedFanouts(); }, FANOUT_SWEEP_MS);
        this.fanoutTimer.unref?.();
        // The archive sweep runs once at start so a long-dormant store clears
        // immediately, then hourly.
        void this.sweepFanoutArchive();
        this.fanoutArchiveTimer = setInterval(() => { countTick("fanout-archive"); void this.sweepFanoutArchive(); }, FANOUT_ARCHIVE_MS);
        this.fanoutArchiveTimer.unref?.();
        this.scheduleOpencodeSweep();
        void this.sweepBlockedFanouts();
        this.paneServers.tick();
    }
    close() {
        this.closed = true;
        this.sudo.close();
        void this.changes.close().catch(() => { });
        for (const pending of this.pending.values()) {
            clearTimeout(pending.timer);
            pending.response.end("{}");
        }
        this.pending.clear();
        this.server?.close();
        this.server?.closeAllConnections();
        this.pushBindings.clear();
        this.opencode.clear();
        this.forwardedPushes.clear();
        this.paneServers.close();
        this.servedQuestions.clear();
        if (this.opencodeDebounce)
            clearTimeout(this.opencodeDebounce);
        if (this.opencodePoll)
            clearInterval(this.opencodePoll);
        this.opencodeWatcher?.close();
        this.opencodeWatcher = undefined;
        if (this.fanoutTimer)
            clearInterval(this.fanoutTimer);
        if (this.fanoutArchiveTimer)
            clearInterval(this.fanoutArchiveTimer);
        this.fanoutSeen.clear();
    }
}