UNPKG

@phren/cli

Version:

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

307 lines (306 loc) • 18.3 kB
import type { ModuleSnapshot } from "../modules/runtime.js"; import { type ServerResponse } from "node:http"; import { type Json, 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 { PromptDeliveries } from "./prompt-deliveries.js"; import { type ConductorCall } from "./agent-hook-asks.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 { 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"; /** 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; /** 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?: ConductorCall; /** This Hook already pushed the request to a phone, so the dispatching Hook does not push it again. */ pushed?: true; } export declare class AgentHooks { readonly push: ApprovalPushService; private modules?; readonly changes: ToolChanges; private pending; /** Phone messages Phren typed into a pane, until a conversation there * submits them through UserPromptSubmit: see prompt-deliveries.ts. */ readonly deliveries: PromptDeliveries; /** 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; /** 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. */ 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, deliveriesFile?: string); 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; /** 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, lines?: number): 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>; /** 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. */ private releasedHoldDialog; /** 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; /** 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. */ 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; }