Input, timers and questions
Docs/Extend/Input, timers and questions

Input, timers and questions

Edit on GitHub
docs-site/extend/run-and-person.md

An extension can drive a run as a client does: send it input, keep it going with a driver, wake it with timers, and watch what it does. It can also ask the person a question, tell them something, and show a status. None of it draws its own screen: the host and each client draw these, so they look the same everywhere.

Send input

// TypeScript
await ctx.run.send("Check the build again.", { target: "queue", shown: { lead: "Re-checking the build" } });
const status = await ctx.run.status();   // { state: "running", pending: 1 }
// Rust, inside a process extension: the same call on the host handle
use orrery_ext_api::facades::RunFacade;
let host = orrery_ext_sdk::host().expect("served by orrery");
let status = host.run_status().await?;
  • target is how the input joins the run, as a client sends it: steer, queue or inject.
  • to is main (the default) or an agent the extension spawned. Sending to main needs the run.send aspect, shown at install.
  • shown says how the thread draws the input, and it is never drawn as the person's:
    • "message" (the default): drawn as the extension's, named after it.
    • { lead: "…" }: a one-line lead in its place, as a turn nobody typed.
    • "hidden": not drawn; still in the session log and the audit.
  • The reply says whether it started the run, was delivered, was queued, or was rejected.
  • The session log keeps it as an ext.send entry with its shown, so a reloaded session draws it as it was drawn live. A run it started stores the message with by: ext:<id> (Who said it).
  • Input counts against the run's budget. limits.extensionRuns (3 by default) caps how many runs an extension starts with no person's input in between.

Drive the run

A driver answers what comes next after each completed turn. Name it in config so the host asks it:

driver = "goal"        # the driver an extension registered; a name nothing registered fails at start
driver: {
  name: "goal",
  afterTask({ last, tasks }, host) {
    if (tasks >= 5 || /done/i.test(last)) return null;   // nothing: the run ends
    return { text: "Keep going on the next item." };
  },
},
  • An answer that is nothing ends the run as it would have. A driver that does not answer in time is also nothing: the log and a notice say why.
  • Nothing returns "continue". A run stays alive by input the extension sends, or by a hold (below).

Timers

// TypeScript
const id = await ctx.timer.set(60_000, "poll-ci", { hold: true });
await ctx.timer.cancel(id);

export default defineExtension({
  onTimer(tag, host) {
    if (tag === "poll-ci") return host.run.send("CI poll due.", { target: "queue" });
  },
});
// Rust: the host calls the handler for every timer this extension set
orrery_ext_sdk::on_timer(handler);       // handler gets the tag
let id = host.timer(TimerSetParams { /* after_ms, tag, hold */ }).await?;
  • The host holds the timer, so a parked extension costs nothing while it waits. A timer that fires starts the extension if it is parked.
  • hold: true keeps the current run alive until the timer fires or is cancelled, for at most limits.holdMs (10 minutes by default).

Watch the run

An extension can subscribe to the run's bus. Declare the kinds it wants, so the host starts it when a session opens instead of on first use:

export default defineExtension({
  subscribes: ["session", "turn", "tool", "context"],
  onEvent(received, host) {
    if (received.t === "event" && received.event.kind === "turn") { /* … */ }
  },
});

In Rust, declare the kinds at load with ctx.subscribes(&[EventKind::Turn, EventKind::Tool]) and read them with ctx.events().subscribe(&kinds); a process extension is then started when a session opens, as a TypeScript one is.

The kinds are run, agent, message, wait, lock, state, session, turn, tool, context. A watcher observes and lags: publishing never waits for it, and it cannot change anything. A lagged event says how many it missed. A body is read on demand with session.read, not carried on the bus.

Ask the person

const answer = await ctx.person.ask("Which branch?", ["main", "dev"]);            // one choice
const ok = await ctx.person.ask("Push?", { kind: "confirm", detail: "to origin" });
const note = await ctx.person.ask("Why?", { kind: "text", multiline: true });
KindAsksAnswer
(none)One choice from a list, or free text. As before.A string.
choose { choices, many }One choice, or several with many: true.A string, or JSON for many.
confirm { detail }Yes or no.true or false.
text { placeholder, multiline }A line, or several.A string.
form { fields }Several fields at once, drawn as a form.JSON.

A run that cannot ask (orrery run -p, with no client to answer) refuses, and the refusal says why. In Rust, host.ask(question, AskKind::Choose { choices, many }) takes the same kinds (orrery_ext_api::wire::AskKind).

Tell the person

await ctx.ui.notify("Build finished.", "info");      // "info", "warn" or "error"
await ctx.ui.status("ci", "CI: 3 of 5 green");      // one segment of the status line
await ctx.ui.status("ci", null);                     // clear it
  • notify is the host's own toast, labelled with the extension. With no screen (orrery run), it goes to stderr as <id>: <text> (warning: or error: before the text by level) and, with --json, as the event CUSTOM orrery.notice {ext, level, text}.
  • status sets one segment of the status line, labelled with the extension. Only the latest text shows.

To show something larger, place a surface in a band row, a pane beside the thread, or the footer. The same id replaces it in place; hide takes it away:

await ctx.ui.show({ id: "ci", placement: "band", title: "CI", surface: ctx.ui.text("3 of 5 green") });
await ctx.ui.hide({ id: "ci" });

The agent has the same two calls as the core tools ui.show and ui.hide, and the person places with config or /ui. The placements, the fallback a client uses when it cannot draw a surface, and the surface kinds (charts, rasters, images) are in Show things: placements.

Limits

  • An extension never draws. The client in use draws what it sends, with that client's own elements. There is one client per screen; an extension is never a second one.
  • A segment outlives its writer. It stays until the extension clears it, also after the extension is parked or has crashed, when nothing updates it any more. Clear it before you stop.
  • A placed surface is session content, not a notice. It stays until its owner replaces or hides it, or the extension is unloaded. A placement that a ui(<placement>) rule denies is refused, naming the rule. An ask rule is refused too: a placement cannot wait for an answer, so use allow or deny.
  • An idle extension is parked. After 10 minutes with no call in flight, no open stream and no event subscription or watch, its process is stopped, and the next call starts it again. An extension that keeps a segment current should subscribe to the events it reflects, or set a timer: a timer wakes a parked extension.

Keys

An extension may bind a key to one of its commands. Declare it in the manifest:

[[provides.keys]]
key = "ctrl+g"
command = "git status"

A key runs a command, and a command is a tool call. [keys] in config.toml overrides a declared key. Esc, Ctrl+C, Ctrl+D, Enter and Tab belong to the host: a manifest that binds one is refused at install, naming the key.