Scripts

A script is a TypeScript or JavaScript file the hive server runs. It calls the same methods as the CLI and the apps, and hears the same events. You don't need Node, Bun or Deno: hive strips the types and runs the code itself.

// tests.ts: open a split running the tests, and wait for the result.
const pane = await h.block();
const [tests] = await pane.new_split({ layout: { command: ["make", "test"] }, focus: false });
const { exit_code } = await tests.wait();
console.log(exit_code === 0 ? "tests pass" : "tests fail");
export default () => exit_code;
hive script run --allow spawn tests.ts

The log goes to stderr, and what the default export returns goes to stdout as JSON. A script that throws exits with status 1.

Running a script

Command Does
hive script run FILE [KEY=VALUE…] Run a file once. KEY=VALUE pairs become h.args.
hive script run -e CODE Run code from the command line
hive script run - Run code from stdin
hive script stop RUN_ID Stop a running script. Ctrl-C does the same for the one you started.
hive script ls Scripts in the folder, running one-shots, and the latest that ended
hive script check FILE Report errors without running it
hive script types [--out FILE] Write hive.d.ts, for your editor

Options for run:

Option Does
--allow PERMS What the script may do, such as control,spawn. Without it, it can only read.
--args JSON Arguments as a JSON object, read as h.args
--for DUR Stop it after this long, such as 10m
--all Hear events from every session, not only its own

Inside a hive terminal, a script runs in that pane's session: methods that need a session_id or a block_id get the current ones when you leave them out. Outside hive, name them with -s and -b, as other commands do.

The CLI bundles the script on your machine, so hive -S box script run ./x.ts works with your local files. The server only runs scripts sent from other machines when you turn on script-remote-run in its settings.

Types in your editor

hive script types writes hive.d.ts: every method of the server you talk to, with its arguments, its result and the permission it needs. Put it beside your scripts with a tsconfig.json, and your editor completes h. and flags a misspelled argument before you run anything:

{
  "compilerOptions": { "target": "es2022", "module": "esnext", "strict": true, "noEmit": true, "lib": ["es2022"] },
  "include": ["*.ts"]
}

Run hive script types again after you update hive. hive doesn't check types when it runs a script; tsc --noEmit does, if you want it to.

What a script can do

Everything is on h. Names are the API's own, so hive api describe explains each one.

Write Does
await h.session.list() Any method, by its name: h.session.new_split(…), h.agent.list(), h.whoami()
await h.terminal.format({ args: { tail: 5 } }) Terminal methods, without the hive. prefix
await h.call("session.view", {…}) The same, with the method's name as a string
await h.block() / h.block("logs") The current block, or one by target: write, send_key, format, wait, new_split, label, zoom, focus, close, agent
await h.session_of() The current session: label, view, new_window, close
h.on("terminal.bell", fn) Run fn(ctx, event) for every event of that name. h.on("*", fn) hears them all.
h.on("terminal.agent_changed", { state: "blocked" }, fn) Only events whose fields match. A list matches any of its values.
await h.wait("terminal.child_exited", { block_id }, { timeout: "1m" }) Wait for one event
h.after("5s", fn), h.every("1m", fn), await h.sleep("200ms") Timers
console.log(…), h.log("warn", …) Write to the script's log
h.args, h.ctx The arguments, and where the script runs (session_id, block_id)
h.stop() End the script now

Events use short names: session events by their own name (window_created), terminal events with terminal. in front (terminal.child_exited, terminal.agent_changed). Their fields are those in the API's events, with block_id and session_id added. In terminal.agent_changed, session_id is the agent's own session and hive_session_id is hive's.

A script ends when its code and everything it waits for have finished. One that registers h.on handlers or h.every timers keeps running until you stop it, it calls h.stop(), or --for runs out.

A failed call throws a HiveError with the API's code and status:

try {
  await h.session.create({ command: ["make"] });
} catch (e) {
  if (e.code === "permission_denied") console.log("run me with --allow spawn");
}

Agents, notifications and badges

A script can tell you things, and mark panes:

await h.notify.send({ title: "Tests failed", body: "3 failures in api", target: h.ctx.block_id, level: "error" });
await h.status.set({ target: h.ctx.block_id!, id: "tests", state: "error", msg: "3 failing" });

Notifications reach every app, and the server keeps them for a day, so an app that was closed shows what it missed. A script can send 10 a minute. Badges show in the pane's header, on the Mac and the phone, those that need you first; they go away when their script stops. Both need the ui permission.

h.agents.on_state runs when an agent reaches a state, with its row from agent.list (its folder, its label, what it's doing):

export default hive.script({
  name: "nag-when-blocked",
  permissions: ["ui"],
  setup(h) {
    h.agents.on_state("blocked", async (ctx, row) => {
      await h.sleep("10m");
      const now = (await h.agents.list()).find((a) => a.block_id === row.block_id);
      if (now && now.agent.revision === row.agent.revision) {
        await h.notify.send({ title: "Still waiting for you", body: now.agent.message ?? "", target: row.block_id });
      }
    });
  },
});

A rule answers what you'd answer the same way every time. When an agent is blocked on what the rule describes, you get a notification with a button; pressing it sends the answer, but only if the agent is still waiting on the same thing:

export default hive.script({
  name: "approve-reads",
  permissions: ["input", "ui"],
  setup(h) {
    h.agents.rule({
      name: "approve-reads",
      when: { state: "blocked", message: "permission to use (Read|Grep|Glob)\\b", cwd: "~/src" },
      do: { send_key: ["enter"] },
    });
  },
});

A rule only answers permission prompts, unless its when names another kind: a question an agent asks you is blocked too, and never matches by accident. Rules don't act on their own; they only offer the button.

Watching what terminals print

h.trigger runs for every line a terminal prints that matches a pattern. Patterns are RE2, matched by the server; colors and other escape codes are left out, and a spinner's redraws count as one line:

export default hive.script({
  name: "red-on-fail",
  permissions: ["screen", "ui"],
  setup(h) {
    h.trigger({ pattern: "^(FAIL|panic:)" }, async (ctx, m) => {
      await h.status.set({ target: m.block_id, id: "fail", state: "error", msg: m.line });
    });
    h.trigger({ pattern: "Listening on :(\\d+)" }, async (ctx, m) => {
      await h.status.set({ target: m.block_id, id: "port", state: "done", msg: "port " + m.groups[0] });
    });
  },
});

h.wait_output({ pattern, timeout }) waits for one line. A trigger can name a block or a session, screen: "alt" for full-screen programs, or mode: "snapshot" to match what a full-screen program shows instead of lines. hive.terminal.search finds lines printed before a script started. Triggers need the screen permission.

Actions

An action is something a script does when asked: by hive api call action.run, by a notification's button, or by another script.

export default hive.script({
  name: "scratch",
  permissions: ["control"],
  setup(h) {
    h.actions.register({
      name: "scratch",
      title: "Open a scratch window",
      run: async (ctx) => {
        await h.session.new_window({ session_id: ctx.session_id!, label: "scratch" });
      },
    });
  },
});

An action runs with its script's permissions. Another script can run it only when it's public: true and that script already has those permissions itself.

Keys

A script in the scripts folder can give an action a key, in the Mac app:

export default hive.script({
  name: "scratch-key",
  permissions: ["control", "ui"],
  setup(h) {
    h.actions.register({
      name: "scratch-window",
      title: "Open a scratch window",
      run: async (ctx) => { await h.session.new_window({ session_id: ctx.session_id!, label: "scratch" }); },
    });
    h.bind("ctrl+cmd+n", "scratch-window");
    h.bind("ctrl+b>c", "client.new_tab");               // a sequence: ctrl+b, then c
    h.mode("resize", { exclusive: true });              // keys only for this mode, until Escape
    h.bind("ctrl+cmd+r", "client.mode", { mode: "resize" });
    h.bind("resize/left", "client.resize", { direction: "left" });
  },
});

Keys are written as cmd+shift+t, with ctrl, alt, shift and cmd; sequences as ctrl+b>c; keys in a mode as resize/left. A binding can name a script's action, or one of the app's own: client.new_tab, client.split_right, client.zoom, client.palette, client.mode, and the rest hive keymap lists. h.bind(key, fn) binds a function.

You can change any key in the settings file too, and those win over scripts'. hive keymap shows every binding and where it came from. The app takes keys only from the server on its own Mac; actions of other hosts are in the command palette, under the host's name. On the phone, every host's actions are in the switcher, under the host.

Permissions

A script can only do what it was allowed. Without --allow, it can read: list sessions, describe blocks, hear events.

Permission Allows
read Sessions, windows, blocks, agents, the server's details, events. Every script has it.
screen What terminals show: format, and the folders list_dir reads
control Arrange, label, focus and close; new panes with your shell, in any folder
spawn New panes that run a command. Includes control.
input Typing into terminals: write and send_key, and answering agents
ui Notifications, badges, actions and keys
config Changing settings, except listen and those starting with script or tailscale
server Stopping the server, saving now, and the settings config can't change

hive api describe METHOD shows the permission each method needs.

A script can also declare what it needs, so you don't pass --allow every time:

export default hive.script({
  name: "label-by-folder",
  permissions: ["control"],
  async setup(h) {
    const pane = await h.block();
    await pane.label((await pane.pwd()).split("/").pop()!);
  },
});

A script that starts another can't give it more than it has itself.

Scripts that keep running

Put a script in the scripts folder, ~/.config/hive/scripts (beside the settings file, or HIVE_SCRIPTS), and the server runs it whenever it runs: it starts after your sessions are restored, and keeps running with every app closed. A script there is NAME.ts or NAME/index.ts. Shared code goes in lib/, which isn't run on its own.

// ~/.config/hive/scripts/tests-on-done.ts
export default hive.script({
  name: "tests-on-done",
  permissions: ["spawn"],
  setup(h) {
    h.on("terminal.agent_changed", { state: "done" }, async (ctx, ev) => {
      const [tests] = await (await h.block(ev.block_id)).new_split({
        layout: { command: ["make", "test"], keep_open: true }, focus: false,
      });
      const { exit_code } = await tests.wait({ timeout: "10m" });
      console.log(exit_code === 0 ? "tests pass" : "tests fail");
    });
  },
});

A new script waits until you allow it:

hive script ls                 # tests-on-done  pending  asks spawn
hive script grant tests-on-done

You grant a script once. Editing it doesn't ask again; asking for more permissions does.

Command Does
hive script ls Every script in the folder: running, pending (waiting for a grant), stopped (it called h.stop()), failed, disabled or quarantined
hive script grant NAME / revoke NAME Allow what it asks for, or take it back
hive script disable NAME / enable NAME Turn it off or on, without removing it
hive script reload [NAME] Load it again now
hive script logs NAME [-f] Its log (-f keeps following)
hive script audit [NAME] What it changed

When you save a script, the server loads the new version within a few seconds. If the new version has an error, the old one keeps running, and hive script ls shows the error.

A script in the folder can keep values with h.store. They survive reloads and restarts:

export default hive.script({
  name: "counter",
  permissions: [],
  async setup(h) {
    const starts = ((await h.store.get("starts")) ?? 0) + 1;
    await h.store.set("starts", starts);
    console.log("started " + starts + " times");
  },
});

h.store.get, set, delete and keys(prefix) take JSON values, up to 1 MB per script.

These events help scripts in the folder: server_started, then restore_finished (with sessions and agents_resumed), and config_changed (with the keys that changed).

Seeing what scripts do, and turning them off

Nothing a script does is hidden from you:

  • It shows where it happened. When a script types into a pane, opens or closes one, or asks the app to do something, the pane's header shows it for a few seconds, such as "⚡ limit-resume answered the agent". Clicking it opens the script.
  • It's listed. Every change a script makes goes into its activity: hive script show NAME, hive script audit -f, or the Scripts window (Hive → Scripts… on the Mac, Settings → Scripts on the phone). You see the start of what it typed there; the log file on disk keeps only its length.
  • You can turn it off, or let it only watch. A script in watch mode may still look, mark panes and notify you, but every change it tries is refused, and the refusal is listed. Use the switches in the Scripts window, or hive script watch NAME and hive script disable NAME.

A script can declare its own switches, which the Scripts window shows and hive script set changes:

export default hive.script({
  name: "nudge",
  permissions: ["input", "ui"],
  options: {
    auto: { title: "Type it by itself (off: only tell me)", default: true },
    after_min: { title: "Minutes to wait", type: "number", default: 10, min: 1, max: 120 },
  },
  setup(h) {
    // Read a switch when you act: it can change at any time.
    h.on("options_changed", (ctx, ev) => console.log(ev.key + " is now " + ev.value));
  },
});

h.options.auto reads the current value. Only people change switches, modes and grants: a script can't, not even one allowed server.

A notification can offer to turn its script down, from where it says what the script did. These buttons are run by the server for whoever presses them, never by the script:

await h.notify.send({
  title: "Resumed oracle",
  actions: [
    { title: "Stop typing it", action: "hive.set_option", args: { key: "auto", value: false } },
    { title: "Watch only", action: "hive.set_mode", args: { mode: "watch" } },
  ],
});

Starter scripts

hive comes with scripts for working with many agents. hive script add lists them, and hive script add NAME copies one into your scripts folder, where it waits for you to allow it.

Starter Does Asks
limit-resume When an agent stops at its usage limit, marks its pane with when the limit resets, and types "resume" into it then. Each resume is a notification, with buttons to stop doing it. input, screen, ui
needs-you Reminds you of agents that keep waiting, and ⌃⌘J takes you to the one that has waited longest. ui
stall-watch Marks agents that say they're working but whose screen hasn't moved for 15 minutes. screen, ui
digest One notification with where every agent is, from the palette or the phone, and each morning if you like. ui
lane-keeper For a team of agents with an orchestrator: notices lanes that went idle without asking anything, and tells the orchestrator (asks you first, unless you switch that on). input, ui
team Pause, resume, reload skills or say one line to every agent in a session. input, ui
keep-awake Keeps the Mac awake with a caffeinate tab while agents work. spawn, ui
dev-panes Marks failed test runs and dev server ports in shell panes. screen, ui

Each is a short file you can read before you allow it, and change after.

When a script misbehaves

  • An error in a handler is logged, and the script keeps running.
  • A handler that runs for more than 5 seconds without a break is stopped. Five of those in 5 minutes, and the script is quarantined: stopped until you hive script enable it, even if you edit it meanwhile.
  • If the server's memory grows by more than 1 GB, the busiest script is quarantined.
  • At most 64 scripts run at once. A script that starts another stops it when it stops.
  • A script's own changes don't wake its own h.on handlers (pass { self: true } as a fourth argument to hear them). Scripts that keep waking each other are stopped after 8 rounds: the call fails with loop_suspected.

Set scripts = false in the settings to start the server without them.

Safety

Scripts run as you, inside the server. Permissions protect you from a script's mistakes, not from someone who can already reach your server: that is what the Unix socket and your tailnet are for.

Everything a script changes (arranging, running, typing, settings) goes to an audit log, scripts/audit.jsonl in the server's state folder. Text a script types is logged as its length and a hash, not the text.

Imports must be your own files (./lib), not packages.