UNPKG

@phren/cli

Version:

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

414 lines (413 loc) • 23.9 kB
import { type ModuleSnapshot } from "../modules/runtime.js"; import { type ServerResponse } from "node:http"; import { type Json, type Provider, type Target } from "./protocol.js"; import { ToolChanges } from "./changes.js"; import { ApprovalPushService } from "./push.js"; import { type RequestKind } from "./approval-summary.js"; import { type DialogAnswer, type DialogQuestion } from "./claude-question-dialog.js"; import { type TerminalChoice } from "./terminal-choice.js"; import { ApprovalWatchLeases } from "./agent-hook-stores.js"; import { SudoBroker } from "./sudo.js"; import type { AppServerRequestId, PendingServerRequest } from "./codex-app-server.js"; import { PaneServerWatcher, type PaneAsks } from "./opencode-panes.js"; import type { PaneClient, PaneServerEntry } from "./opencode-pane-server.js"; export { permissionPrompt, terminalChoice, visibleTerminalChoice, type TerminalChoice, type TerminalQuestion } from "./terminal-choice.js"; export { ApprovalWatchLeases, PushBindingStore, recordedSession } from "./agent-hook-stores.js"; /** 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 declare function watchHangUp(res: ServerResponse): () => boolean; interface HeldReply { end(body: string): unknown; readonly destroyed: boolean; } interface Pending { target: Target; response: HeldReply; tool: string; input: unknown; message: string; request: string; requestKind: RequestKind; title?: string; choice?: TerminalChoice; expiresAt: string; timer?: NodeJS.Timeout; conductor?: { action: "dispatch" | "hand_off"; project?: string; computer?: string; }; /** A server request of the Hook's own Codex app-server: answered over RPC, never held on a timer. */ appServer?: { requestId: AppServerRequestId; answer: (result: Json) => void; }; } /** The approval card's tool and input for an app-server request, in the * shapes `approvalSummary` and the phone already read for Codex's hook. * Undefined for questions (`item/tool/requestUserInput`) and MCP * elicitations, which stay in the pane. */ export declare function appServerApproval(request: PendingServerRequest): { tool: string; input: Json; } | undefined; /** The app-server's answer for the phone's allow or deny (T3's * CodexSessionRuntime shapes: accept / decline, a permission grant or none). */ export declare function appServerDecision(request: PendingServerRequest, allow: boolean): Json; /** What a dispatched worker's Hook tells the dispatching Hook it is waiting on: * a card without the request's full message or details, which can run to 32 KB * and cross SSH on every poll. */ export interface ForwardedApproval { actionId: string; tool: string; title?: string; request?: string; requestKind?: RequestKind; expiresAt?: string; /** The pane is drawing this as a terminal dialog; the answer types its keys. */ terminal?: true; conductor?: Pending["conductor"]; /** This Hook already pushed the request to a phone, so the dispatching Hook does not push it again. */ pushed?: true; } /** A conductor `dispatch` or `hand_off` permission ask the Hook can answer * itself under a standing grant, or offer the phone two grant-writing answers. */ export declare function conductorCall(tool: string, input: unknown): Pending["conductor"] | undefined; /** The decisions an OpenCode permission ask offers the phone. OpenCode's * `always` covers the current session, so both grant answers reply `always`; * `allow-everywhere` additionally writes an allow rule into the user's * OpenCode config. Advertised as the approval card's `options`, the same * shape the phone already renders for a provider-supplied option list. */ export declare const OPENCODE_APPROVAL_OPTIONS: Json[]; /** The OpenCode permission reply a card decision maps to: both grant scopes * are `always`, a plain approve is `once`, a deny is `reject`. Undefined for * anything else. */ export declare function opencodePermissionReply(decision: unknown): "once" | "always" | "reject" | undefined; export type DeliveryOutcome = "delivered" | "blocked" | "pending"; /** A phone message typed under its delivery id, so the phone can ask what * became of it by that id. `typed` until the Hook answers it queued; the * typed record's own settlement moves it on by id, never by its words. */ interface TrackedDelivery { source: Provider; session: string; state: "typed" | "queued" | "delivered" | "blocked"; at: number; } export type TrackedState = Exclude<TrackedDelivery["state"], "typed"> | "unknown"; export declare class AgentHooks { readonly push: ApprovalPushService; private modules?; readonly changes: ToolChanges; private pending; /** Prompts Phren has typed into a pane, by a hash of their words, until the agent that * actually receives one reports in through UserPromptSubmit. Herdr writes * to a pane, not a conversation; the receiving agent's hook is the only * party that knows which conversation consumed the text, so it is the one * that can refuse it when that is not the conversation the phone meant. */ private deliveries; /** Phone messages by delivery id, until TRACK_MS: see `queueDelivery`. */ private tracked; /** 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. */ private terminalPrompts; /** 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. */ private dialogReads; /** 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. */ private passwords; /** Conversations Claude Code is compacting, by target, until the new context * starts. The phone shows the state instead of the summary row's text. */ private compactingSince; /** 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. */ private menus; private watching; readonly overview: ApprovalWatchLeases; private server?; private pushBindings; /** Held asks that were pushed: action -> when the notification expires. */ private pushedHolds; /** Pushed asks whose hold ended with the request left in the terminal. */ private releasedHolds; /** Terminal dialogs pushed to phones: by pane, the dialog last pushed; by * action, what an answer from the notification types. */ private dialogPushes; private dialogActions; /** Terminal dialogs offered to a dispatching Hook, by pane: the `dialog-` action minted for the dialog's current title. */ private forwardedDialogs; /** Requests this Hook pushed to its own phone on behalf of a dispatched worker on another computer: action -> the answer to send back. */ private forwardedPushes; /** Panes a dispatching Hook polled lately (`server\npane\nsource` -> expiry). */ private dispatchLeases; /** opencode permission asks seen on disk or listed by a served pane, by request id. */ private opencode; /** Questions served OpenCode panes are asking, by question id. */ private servedQuestions; /** One event subscription per served OpenCode pane; ticked with the Hook. */ readonly paneServers: PaneServerWatcher; private closed; private opencodeSweep?; private opencodeLive; private fanoutLive; private fanoutSweeping; private opencodeWatcher?; private opencodePoll?; private opencodeDebounce?; /** Fan-out jobs already pushed as blocked, by job id and blocked timestamp. */ private fanoutSeen; private fanoutTimer?; private fanoutArchiveTimer?; /** The name the phone paired with (macOS Computer Name), for approval alerts; * the short host name until that lookup answers. */ private computerName; /** sudo -A requests from askpass, answered from the phone (sudo.ts). */ readonly sudo: SudoBroker; constructor(push?: ApprovalPushService, modules?: ModuleSnapshot | undefined); private approvalsDirectory; private scheduleOpencodeSweep; /** 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. */ sweepOpencodeApprovals(): Promise<void>; private readOpencodeApprovals; /** 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). */ private servedTarget; /** 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. */ servedAsks(entry: PaneServerEntry, client: PaneClient, asks: PaneAsks): Promise<void>; /** A served pane's process is gone: nothing it asked can be answered. */ servedGone(key: string): void; /** The question a served OpenCode pane is asking, in the shape the phone * answers Claude's AskUserQuestion with (`/v1/questions/answer`). */ servedQuestion(target: Target): Json | undefined; private servedClient; /** Answers a served pane's permission ask over its own API. */ private answerServed; /** 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. */ answerServedQuestion(target: Target, questions: DialogQuestion[], answers: DialogAnswer[]): Promise<void>; /** 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. */ servedKeys(target: Target, keys: readonly string[], status: string): Promise<boolean>; /** A session's exact pane. A recorded binding carries the full target; when * there is none, Herdr's explicit session identity names the pane. */ private resolveTarget; /** Push once for each fan-out job whose blocked.json the plugin wrote. */ private sweepBlockedFanouts; /** 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. */ private sweepFanoutArchive; watch(target: Target): () => void; /** Register a prompt about to be typed into `target`'s pane. The returned * promise settles "delivered" once that conversation's own hook submits the * text, "blocked" if another conversation in the pane tried to, or "pending" * after `waitMs`: a busy agent queues typed input and submits it only when * its turn ends, so the record outlives the wait (up to ten minutes) and a * late submission to the wrong conversation is still refused. A phone * message's `id` follows the record, so its outcome lands on that id. */ expectDelivery(target: Target, text: string, waitMs?: number, signal?: AbortSignal, id?: string): Promise<DeliveryOutcome>; /** A prompt Phren typed into `target` that its conversation has not submitted yet. */ deliveryPending(target: Target, text: string): boolean; /** Wait again for a delivery `expectDelivery` already reported pending, * after the Hook pressed Enter a second time. */ awaitLateDelivery(target: Target, text: string, waitMs?: number): Promise<DeliveryOutcome>; private track; /** The Hook answered the phone message `id` as queued: its typed record * still waits for the agent's hook, which settles it by this id. */ queueDelivery(id: string, target: Target): void; /** What became of the phone message `id` sent to `target`'s conversation: * queued until the agent submits it, then delivered, or blocked when * another conversation in the pane took it. Unknown while the Hook has not * answered it, for another conversation, or older than TRACK_MS. A * message answered uncertain still turns delivered once its hook takes it. */ deliveryState(id: string, target: Target): TrackedState; /** Every known phone message to `target`'s conversation, for its stream. */ deliveriesFor(target: Target): { deliveryId: string; state: TrackedState; }[]; private settleTracked; /** The conversation `target` just submitted `prompt`. Nothing Phren typed * matches: a locally typed prompt, always allowed. Otherwise the oldest * matching delivery decides: its own conversation consumes it; any other * conversation is told to drop it, so the text is never spoken to the * wrong agent and the phone can safely send it again. */ private submitted; /** The conversation's own turn events, for dispatch returns (turn-records.ts). * A failed write never fails the agent's callback. */ private recordTurn; private rememberTerminalPrompt; /** 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: Target): Json | undefined; clearTerminalPrompt(target: Target): void; /** True while the pane's own terminal is reading a password. */ passwordPrompt(target: Target): boolean; /** 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. */ syncTerminalDialog(target: Target, active: boolean): Promise<void>; /** Resolve a phone option identifier. Real shortcuts retain their key path; * keyless rows are reached and verified before returning Enter to the route. */ dialogAnswerKeys<K extends string>(target: Target, keys: readonly K[]): Promise<(K | "Enter")[]>; moveDialogHighlight(target: Target, expected: TerminalChoice, key: string, beforeKeys?: () => Promise<void>): Promise<void>; /** 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. */ private selectOpencodeOnce; /** 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. */ answerClaudeQuestions(target: Target, questions: DialogQuestion[], answers: DialogAnswer[], options?: { from?: number; submit?: boolean; }): Promise<void>; /** 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. */ answerReleasedQuestion(target: Target, keys: readonly string[]): Promise<boolean>; private startCompacting; private stopCompacting; /** 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: Target): boolean; menuOpened(target: Target, command?: string): void; menuOpen(target: Target): boolean; /** The bare slash command that opened this pane's current menu window. */ menuCommand(target: Target): string | undefined; menuClosed(target: Target): void; /** The pane with its colors, for dialogs whose cursor is only a color. */ paneAnsi(target: Target): Promise<string>; /** Read what the pane draws, stripping ANSI unless placeholder styling is needed. */ paneLines(target: Target, stripAnsi?: boolean): Promise<string>; /** 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. */ walkMenuConfirmation(target: Target): Promise<{ menuClosed: boolean; waiting?: { message: string; choice?: TerminalChoice; }; }>; approval(target: Target): Json | undefined; private opencodeApproval; /** Live fan-out worker asks shown on this parent conversation, oldest first. */ private fanoutHeld; pendingPanes(server: string, state: Json): Set<string>; /** Hands the owner's answer to the fan-out launcher waiting on the worker's session. */ private answerFanout; answer(target: Target, id: string, decision: unknown, updatedInput?: unknown): Promise<void>; /** 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: Target): void; /** 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: Target): boolean; answerPush(binding: string, decision: unknown): Promise<void>; /** Where a pushed ask lives, without answering it: the phone opens that * session's details when the notification itself is tapped. */ pushTarget(binding: string): Target | 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. */ observeWaitingPanes(server: string, panes: Json[], resolve: (pane: Json) => Promise<Target | undefined>): Promise<void>; /** 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: readonly { server: string; pane: string; source: string; }[]): void; private dispatchLeased; private dropForwardedDialog; /** 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: Target): ForwardedApproval | undefined; /** 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: { provider: string; computer: string; project?: string; request: string; requestKind?: RequestKind; }, answer: (decision: "approve" | "deny") => Promise<void>): void; private adoptReleasedHold; private dropDialogPush; /** 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. */ private screenDialog; /** 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. */ private answerDialog; private dropPushBindings; /** 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: Target, request: PendingServerRequest, answer: (result: Json) => void): void; /** Another client (the pane's TUI) answered the request, or this one declined it. */ codexResolved(target: Target, requestId: AppServerRequestId): void; start(): Promise<void>; close(): void; } /** What a Stop payload says about the turn it ends: the harness's last * assistant message (Claude, Codex) and, from Claude Code, the background * tasks still in flight, which will wake the conversation again when they * finish. Absent fields are left out, so the Hook falls back to the transcript. */ export declare function stopFacts(value: Json): { background?: number; reply?: string; }; /** One agent callback, run as `hook <source>`. Resolves "abandoned" when a * tool call's callback ran out of its budget: the caller then exits at once, * since a socket or a process read still pending would hold the agent. */ export declare function agentHook(source: Provider, elapsed?: () => number): Promise<void | "abandoned">; /** * Runs a callback's work. A PreToolUse or PostToolUse callback gets what is * left of TOOL_HOOK_BUDGET_MS since its process started (hook-fast.ts says * why): "abandoned" once that runs out, and the caller exits without an * answer so the tool runs. Every other event runs to its own timeouts. */ export declare function withinToolBudget<T>(event: string, work: (left: () => number) => Promise<T>, elapsed?: () => number, budgetMs?: number): Promise<T | "abandoned">;