# Plan: make the agent runtime swappable

**Status:** Implemented 2026-09-07 on `agent/runtime-agnostic-cli`. Written 2026-09-05.
**Goal:** Change which AI CLI drives an agent with a config value, without a rewrite each time.
**Decided:** Text agents first, ops agent later or never. No brain-service, no headless rewrite, no adapter framework. The bulk of this plan is deleting code, not adding it.

## What actually happened

Read the sections below as the original reasoning. Four things in it turned out to be wrong, and the shipped work differs accordingly.

**Terminals stayed; the ops agent was in scope, not deferred.** Both were admin decisions taken during the build.

**The mount namespace could not be the primary wall.** Per-client data has largely left the filesystem — it lives in `engine/.state/state.db`, which a namespace does not isolate — and `veuze-ai` is one permanent session shared across the dashboard, WhatsApp and the inbox, so a per-task namespace cannot be established from outside. The DB and CLI surface is guarded at the DAL layer instead, uid-anchored. Per-tenant *files* are walled with named-user POSIX ACLs applied at dispatch and cleared at every task-end path. Building it surfaced a pre-existing hole: 19 of 56 client directories were owned by `veuze-agent`, and the owner class outranks a deny ACL, so the agent could read a walled directory and strip the entry. Out-of-scope directories are now reclaimed before the deny is set.

**`pretool-scope-guard.js` was NOT deleted, deliberately.** Raw `Read`/`Grep` of another tenant's directory is a tool-layer concern; the ACL wall covers the files but the hook stays as defence in depth. Deleting it would have been a regression.

**Deleting `pressEnterUntilSubmitted` (Step 2) was wrong.** The lease is taken *before* dispatch, so it cannot confirm a prompt was submitted; removing the retry would let a wake prompt sit as an unsubmitted draft until the lease expired. It was made runtime-agnostic instead — it watches the input buffer flip from draft to empty through the profile. Liveness became optional rather than removed, because an empty input buffer does not mean idle: a CLI shows an empty prompt while a turn is still running.

## Known limits

- An approval is a blanket unlock across the protected pairs **within one approved message**. Narrowing it requires the approval row to carry the action — a schema and protocol change across the dashboard, the WhatsApp resolve flow and the inbox resubmit path.
- The agent has group write on `engine/scripts/**`, so it can edit the guard files in place. Pre-existing, and a permissions decision rather than a code one.
- `engine/scripts/provision-text-agent-users.sh` is untracked and exists only on the production checkout, so a migration cannot review or commit changes to it.
- If the target CLI authenticates by browser OAuth, logging each agent in is not automatable.
- The inventory and smoke tests judge a profile partly against fixtures the profile itself supplies; invented samples pass.

---

## The finding that shapes everything

The investigation expected to find the guardrails living inside Claude Code's hooks, and therefore that a runtime swap would strip the text agent naked. **That is mostly false.** For the text agent, the filesystem already enforces what the hooks enforce:

- `veuze-textagent*` is not in group `veuze`; the repo is `drwxrwsr-x stier_tristan:veuze`. The checkout is read-only to it at the FS layer (`engine/scripts/provision-text-agent-users.sh:33-42`).
- Its only write grants are narrow POSIX ACLs on its own state DB (`provision-text-agent-users.sh:72-86`).
- Trainees hold `r--` on `engine/.ghl-sim-token` and have **no ACL** on `engine/.ghl-service-token`. A trainee pointed at the production GHL port gets 401 regardless of any config assertion.
- Every state-changing GHL call is tiered and approval-bound in the service (`services/ghl-service/lib/gate.js:10-18`), plus a fail-closed per-location enable check (`services/ghl-service/lib/text-agent-allowlist.js:30-54`).

So `pretool-text-agent-readonly.js` is largely a nicer error message over rules the OS already enforces. Its `Write/Edit/MultiEdit` deny (`:12,60-62`) and most of `DENIED_COMMANDS` (`:13` — `sudo`, `su`, `pm2`, `systemctl`, `crontab`) are redundant: those users have no sudoers entry, PM2 is owned by `stier_tristan`, and the rest need root.

**Exactly two rules are load-bearing with nothing behind them:** the `text-agent-admin` domain block (`:24-25,65`) and the `node -e` block (`:19,68`). Both matter for the same reason — the agent's ACL grants `rw` on the state DB, so `node -e` reaching the DAL directly, or the admin CLI, would let it unfreeze its own queue. Fix those two at the OS/service layer and the text agent is portable **today**.

The ops agent is the opposite case. `pretool-scope-guard.js` enforces per-tenant client scoping with no OS backing at all, and the protected-action gate for VA tasks is prompt text only (`engine/CLAUDE.md`, "the deterministic keyword filter … is gone"). Porting the ops agent means rebuilding those first. Hence: text agents first.

---

## The second finding: the completion signal already exists and is already agnostic

The dispatcher currently learns a turn is finished from Claude's `Stop` hook → `agent-stop.js:6` → `markIdle()` → touch `<livenessDir>/idle` → read by `worker-loop.js:783` via `isAvailable()` (`engine/scripts/lib/agent-liveness.js:48-51`).

But there is a second, independent completion signal that is **already 100% runtime-agnostic**: the agent's last action is `cli.js text-agent done <itemId>`, which drops the lease (`worker-loop.js:683,709`). For the ops agent the equivalent is deleting the inbox file. Both are tool calls the agent makes through the CLI. Any runtime that can run a shell command produces them.

The hook path is treated as primary and the tool call as incidental. **Invert that.** It costs nothing, it is a deletion, and it removes the single hardest dependency in the port.

The liveness files then demote to what they are actually good at — "is this thing wedged" — where a 90s heartbeat TTL is fine and a missing signal is not fatal.

Worth knowing regardless: `isAvailable()` is already wrong in both directions. A long tool call with no `PreToolUse` in 90s reads as idle and gets dispatched on top of (`agent-liveness.js:48-51`, `worker-loop.js:783`); a stale `idle` file from a crashed session reads as idle forever, since nothing ages it out (`agent-liveness.js:53`).

---

## Grounding: where runtime knowledge actually lives

Six files, no abstraction:

- `dashboard/server/pty/tmux-session.js:76-84` — `resolveClaudeBin()`, hardcoded `/home/<user>/.local/bin/claude`.
- `dashboard/server/pty/tmux-session.js:174-175` — the launch line: `claude --model X --effort Y --dangerously-skip-permissions`. `model`/`effort` are per-instance; their **flag spelling** is not. Also `CLAUDE_CODE_NO_FLICKER=1` (`:171,188`).
- `engine/scripts/lib/input-buffer.js` — the whole file (25 lines) models Claude's bordered input widget: `❯` marker, `─` rule lines, region between the last two rules.
- `engine/scripts/lib/rate-limit-patterns.js:2-7` — literal Claude strings, scraped from pane text (`worker-loop.js:342-360`) and from the transcript (`hooks/rate-limit-stop.js:79-80`).
- `engine/scripts/lib/text-agent/session-home.js:11,15,101-127,163-219` — installs Claude's `settings.json` hooks, `/etc/claude-code/managed-settings.json`, reads `.credentials.json` → `claudeAiOauth`, writes `.claude.json` → `hasCompletedTrustDialog`.
- `engine/scripts/provision-text-agent-users.sh:13-14,44-57,119-125` — copies the `claude` binary and vendor onboarding keys (`unpinOpus47LaunchEffort`, `sonnet1m45MigrationComplete`, …).

Per-instance config is otherwise in good shape: `lib/config.js:227-246` → `instances.js:43-58` already parameterizes `id`, `tmuxSession`, `livenessDir`, `osUser`, `claudeConfigDir`, `ghlServiceUrl`, `stateDb`, `skillRef`, `model`, `effort`. **One field is missing: which runtime.**

---

## Step 1 — delete the redundant guardrails, fix the two real ones

No runtime change. Valuable on its own; it shrinks what the port has to carry.

1. In `pretool-text-agent-readonly.js`, drop the `Write/Edit/MultiEdit/NotebookEdit` deny and the `sudo|su|pm2|systemctl|crontab` entries. Keep `git`, `tmux`, `node -e`, `text-agent-admin`. Leave a one-line note in the file's header doc saying the FS enforces the rest, so nobody re-adds them.
2. Move queue freeze/unfreeze off the agent's reachable surface: it should require something the agent's ACL does not grant (a token, or route it through the dashboard). Once it does, the `text-agent-admin` regex is defence-in-depth rather than the only wall.
3. Delete `assertSimulatedGate` (`instances.js:87-98`) and `assertPrivateStateDb` (`:100-116`). The token and state-DB ACLs make both guarantees absolutely; these are config lint asserting what the OS already refuses.
4. Collapse the three "is this a text-agent session?" detectors (`pretool-text-agent-readonly.js:44-47`, `instances.js:250-269`) into one.
5. Leave `engine/scripts/lib/text-agent/allowlist.js` alone — it is a UX filter, not enforcement, and the service-side one (`text-agent-allowlist.js`) is the boundary. Just do not treat it as a guardrail.

## Step 2 — make the tool call the completion signal

1. `worker-loop.js`: dispatch readiness keys on the lease, not on `isAvailable()`. A turn is over when its lease is released by `done`; a turn is *stuck* when the lease outlives `leaseMs` (already 10 min, `lib/config.js:176`).
2. Keep `agent-liveness.js` and the three hooks, but only as the wedge probe. They become optional: a runtime with no hooks loses wedge detection, not dispatch.
3. Delete what the inversion strands: `pressEnterUntilSubmitted` and `SUBMIT_CONFIRM_*` (`tmux-io.js:69-83`, `text-agent-worker.js:43-45`) — retrying Enter because the idle flag has not flipped is meaningless once the lease is the signal.
4. Same treatment for the ops path in `inbox-watcher.js`: completion is the inbox file being deleted, which it already is.

## Step 3 — one `tmuxExists()` fix, because it is dangerous today

`tmuxExists()` is the universal "is the agent alive" oracle: `dashboard/routes/chat.js:31`, `dashboard/routes/queue.js:136,147,156`, `inbox-watcher.js:545,771`, `worker-loop.js:335,722`. One of those is destructive — `handleTmuxDeath()` (`inbox-watcher.js:771-786`) fails every leased job, calls `failVaTask`, deletes the inbox file, and clears the active-task marker on a single false negative.

Separate the two questions it is being asked: *is a terminal attached* (chat/queue UI) versus *is the agent processing* (dispatch). The second should come from the lease, per Step 2. Do this before anything else moves; it is a live bug independent of the port.

## Step 4 — collect runtime knowledge into one profile

Only after Steps 1-3. One small module, one object per runtime, `claude` as the only entry:

- `bin` and how to spell `--model` / `--effort` / skip-permissions
- `ready(paneText)` — currently `parseInputBuffer`
- `rateLimit(text)` — currently `rate-limit-patterns.js`
- `installSessionHome(osUser, configDir)` — currently `session-home.js`, hooks included
- `clearContext` — currently typing `/clear` (`worker-loop.js:507-516`) and `/compact` (`inbox-watcher.js:574`)

Add `runtime` to `instances.js` (`INTERCHANGEABLE_FIELDS`) and `lib/config.js`. Every one of the six files above then reads it from the profile instead of knowing it.

**No registry, no DI, no plugin loader.** An object literal per runtime in one directory, selected by a string. If it grows past that, it is wrong.

## Step 5 — prove the seam with a throwaway

Write a second profile against any other CLI, far enough to launch and take one turn. Do not productionize it. Its only job is to reveal which of the five fields is the wrong shape — an abstraction with one implementation is guesswork. Then delete it or keep it, depending on what it taught.

Only after Step 5 does adding a real second runtime, or a t5/t6 instance, make sense.

---

## Not in this plan

- **Headless dispatch / brain-as-a-service.** The right end state, and too big to start with. It also deletes far more than this plan does — `input-buffer.js` entirely, `paneIsMoving`/`detectWedge`/`recoverWedged` (`worker-loop.js:384-408,461-505`), the `/clear` and `/compact` typing, `trustSessionHome`/`canLogBackIn` (`session-home.js:163-219`), pane-scraped rate limits, and the redraw workarounds in `host.js:26-35,80-124`. Revisit once Step 4 has made the boundary obvious. Nothing here forecloses it.
- **Porting the ops agent.** Needs `pretool-scope-guard.js` re-homed below the harness and the protected-action gate made real first. Separate plan.

## Open questions

1. **Do the text agents need a terminal at all?** Every instance gets an xterm tab (`consoles.js:62-75`), but nobody watches them; `templates/text-agent-home/CLAUDE.md` says "nobody is watching this terminal." If the answer is no, Step 4 shrinks and the headless path opens for text agents alone, ops untouched.
2. **`host.js` has zero tests** — no coverage of `createAttach`, `attach`, `broadcast`, `recomputeSize`, `reset()`, `status()`. Neither does `emulator.js` or `dashboard/routes/chat.js`. This is the layer most likely to break silently and the least defended. Write characterization tests before Step 3, or accept the risk knowingly.
3. **Browser reconnect can spawn a second agent.** `dashboard/public/js/terminal.js:543-547` reconnects every 2s; each reconnect hits `ws/upgrade.js:193` → `host.ensureSession()` → `createTmuxSession` if absent (`host.js:191-199`). Two processes on one liveness dir. Does this happen today, or is it latent?
4. **Is `--effort` per-instance worth keeping** once `model` is runtime-specific? Not every CLI has the concept.

## Constraints (CLAUDE.md)

No inline comments (ESLint `no-comments`). `npm run lint` clean, hermetic tests, `npm test` is the merge gate. Stage explicit paths; branch from up-to-date `main`; let the agent-PR workflow open it.

## Acceptance criteria

1. `pretool-text-agent-readonly.js` enforces only rules the OS does not, and freeze/unfreeze is unreachable from the agent's own credentials.
2. A text-agent turn completes correctly with the liveness hooks disabled entirely.
3. No caller confuses "a terminal exists" with "the agent is working"; `handleTmuxDeath` cannot fire on a live turn.
4. Every file that names the `claude` binary, its flags, its TUI shape, its hooks, or its rate-limit strings reads them from one profile.
5. Net lines of code down, not up.
