@phren/cli
Version:
Knowledge layer for AI agents - CLI, MCP server, and data layer
414 lines (413 loc) • 23.9 kB
TypeScript
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">;