Saltar al contenido principal
Versión: 0.22.0

Interaction port

An InteractionPort is the UI side of a checkpoint: how a host shows a question and reads a person's answer. It lives in the core, so the checkpoints never depend on a terminal.

interface ApprovalPrompt {
title: string;
lines: string[];
/** The question in a plain terminal; defaults to the approve/reject one. */
question?: string;
}

interface InteractionPort {
/** A step-gate or approval checkpoint; resolves to the raw answer ("", "y", "n", "q"…). */
askDecision(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
/** Waits until the person confirms they intervened by hand (e.g. logged in). */
askManualIntervention(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
/** Optional: a free-text answer, case kept ("" for none), e.g. a file's path for request_file. */
askText?(prompt: ApprovalPrompt, signal: AbortSignal): Promise<string>;
/** Optional: a choice; resolves to the numbers ("1", "1,3") and/or the person's own words on the next line. */
askChoice?(prompt: ApprovalPrompt, choice: { options: readonly string[]; multiple: boolean }, signal: AbortSignal): Promise<string>;
/** Optional: a form an MCP server asks for, whole; resolves to { action, content? }. */
askForm?(form: FormRequest, signal: AbortSignal): Promise<FormAnswer>;
/** A one-way message for the person. */
notify(message: string): void;
}

One port is installed per process: setInteractionPort(port) and getInteractionPort().

askForm is optional too: without it, a form a server asks for is asked field by field through the other methods; the chat controller's port (panels: "state") has it. askText and askChoice are optional: a port without them gets the question through askDecision (a choice with its options numbered in the lines), which a terminal reads as text anyway; the kit's own ports implement both (the Ink port with a text field, and a list with "Other"). Their answers keep their case, unlike a decision's. An ApprovalPrompt with markdown: true (a plan) should be rendered as markdown where the UI can.

The ports the kit installs​

WhenPortHow it asks
Importing the packageterminalInteractionPortPrints the checkpoint and asks with readline (on the chat's own interface when runChatTui() is running).
While runChatInk() or createProgressView() runsthe Ink portA panel inside the Ink view; restored to the previous port when the chat or view ends.

Without a TTY the terminal port prints nothing and never answers, so the response file is the only channel.

Rules for a port​

  • Resolve with the raw answer: "y", "n", "q", ""… The kit lowercases and trims it.
  • Never resolve if you can't ask. Return a promise that stays pending (for example, when no window is open) so the response file can win.
  • Honor signal. It aborts when the other channel answered first: close your dialog and leave the UI usable. Don't resolve after an abort.
  • Don't throw for normal cases. A rejected promise is treated like "can't ask": the file channel stays in the race.

A port for an Electron app​

The main process runs the agent (the core has no terminal code), and asks the renderer through IPC:

main/interaction.ts
import { ipcMain, type BrowserWindow } from "electron";
import { randomUUID } from "node:crypto";
import { setInteractionPort, type ApprovalPrompt, type InteractionPort } from "@falkenslab/agent-kit";

export function installWindowPort(window: BrowserWindow): void {
const ask = (kind: "decision" | "manual", prompt: ApprovalPrompt, signal: AbortSignal) =>
new Promise<string>((resolve) => {
if (window.isDestroyed()) return; // can't ask: let the response file answer
const id = randomUUID();
const onAnswer = (_event: unknown, answer: { id: string; value: string }) => {
if (answer.id !== id) return;
ipcMain.off("checkpoint:answer", onAnswer);
resolve(answer.value);
};
ipcMain.on("checkpoint:answer", onAnswer);
signal.addEventListener(
"abort",
() => {
ipcMain.off("checkpoint:answer", onAnswer);
window.webContents.send("checkpoint:cancel", { id });
},
{ once: true },
);
window.webContents.send("checkpoint:ask", { id, kind, prompt });
});

const port: InteractionPort = {
askDecision: (prompt, signal) => ask("decision", prompt, signal),
askManualIntervention: (prompt, signal) => ask("manual", prompt, signal),
notify: (message) => window.webContents.send("agent:notice", message),
};
setInteractionPort(port);
}

The renderer shows a dialog on checkpoint:ask, sends checkpoint:answer with "y", "n" or "q" (or "" for a manual intervention), and closes the dialog on checkpoint:cancel.

Answering through the file only​

setInteractionPort(null);

Every checkpoint then waits for <runDir>/approval-response.txt. Useful for tests and fully remote supervision.

Reusing the terminal port​

terminalInteractionPort is exported, with setSharedReadline(rl) and getSharedReadline(), for a program that has its own readline interface and wants checkpoints asked on it rather than on a second one fighting it for the keyboard.