# Laptop as terminal

outcome: agent sessions live on the buildboxes. The laptop runs a terminal and the Overdeck UI, nothing else.
status: IDLE — S1/S2/S3 landed on main (`7fda4515c`, confirmed present on `origin/main` at S4
  start — the prior "NOT YET LANDED" line was stale, a later session landed it). S4 (box
  sessions visible in the Overdeck web UI) landed 2026-08-15, see receipt below. Next: S5.
source request: owner 2026-08-14 — "expensive, annoying, and creating resource load that i cant handle anymore"; "is it reasonable to move the 'claudex' orchestrator sessions to run also from the cluster? i am connecting via tmux anyway, so what is the difference?". The destination was discussed and endorsed; only its prerequisites were ever written into plans.

## Why this exists

Pieces pointing at this destination are already landed or planned, but the destination
itself is not registered — so each lane optimizes its own hop and nobody owns arrival.
Already real:

- `cdx exec` offloads to rootless podman on debian1/2/3 (180 of 200 recent runs).
- Dispatch transport is a git-push delta, not a whole-tree rsync
  (`2026-08-14-execution-plane.md` S2). Measured 46s–2m15s where the tree copy timed out
  at 900s moving 68.7GB.
- `pi-remote` routes factory pi subprocesses to a box, opt-in per project.
- A no-workspace dispatch mode skips the mirror entirely for pure inference.

What is NOT registered is the last hop: the Claude/orchestrator session itself still runs
on the laptop. That is where the load actually is — session OOM kills come from
`agent.slice`, and reaping 26 idle language servers moved load from 52.9 to 12.2 in one
sweep. Dispatching work off the laptop while the sessions that spawn it stay resident
solves the smaller half.

## Acceptance

Owner-obtainable evidence: attach to a working session over tmux exactly as today, while
the laptop's load average stays low and `agent-sessions` shows the session's processes on
a box, not locally. The owner should not be able to tell the difference from the terminal.

## Constraints

- **The owner's access path does not change.** He already attaches over tmux. A session on
  a box must be reachable the same way, with the same keys and the same reattach behavior
  after a laptop sleep. A workflow that requires learning a new command has failed.
- **WIP must not be stranded.** The owner's open question — "how is the agent going to have
  access to all the WIP if its running in the podman?" — is the load-bearing risk. A
  box-resident session needs the repo state it is working on, and its uncommitted work must
  survive the box, the container, and a reap. The git-push transport is the answer for
  dispatch; a resident session needs the same guarantee for its own worktree.
- **Boxes are headless with one door.** No console, no IPMI; sshd on 2222 is the only
  access. Arm a deadman before touching anything that could sever it. A session that can
  wedge its own host is not acceptable.
- **The seat image must exist.** `harness-seat:2` is absent on all three boxes — the seat
  adapter path has never actually run. Provisioning is a prerequisite, not an assumption.
- **Observability comes with it, not after.** A session the owner cannot see is worse than a
  local one he can. Who/what/where must be visible in Overdeck for a box-resident session
  from its first slice.

## Slices

**S1 — the seat image exists on every box.** Evidence: `harness-seat:2` present on
debian1/2/3, and one real dispatch completing through the seat adapter path rather than the
python dispatcher. Enabling-only: it unlocks every later slice and its absence is why the
seat path has never run.

**S2 — one box-resident session, attachable.** A single Claude session started on a box and
attached from the laptop over tmux, doing real work on a real repo. Evidence: the owner
attaches, works, detaches, reattaches; `agent-sessions` shows it on the box.

**S3 — WIP survives.** The resident session's uncommitted work survives a detach, a
container restart, and a reap, recoverable the same way local WIP is (`od-wip`). Evidence:
kill the container mid-edit, recover the changes.

**S4 — the session shows up in Overdeck.** Host, session, and what it is working on visible
on the board, same as a local session. Evidence: the owner sees a box session on the page
without asking anyone.

**S5 — box-resident becomes the default.** New sessions start on a box unless the task
needs the laptop. Evidence: laptop load average during normal multi-session work, before
and after, as a number.

## Not in scope

k3s. The podman foundation is provisioned and proven; migrating the orchestration layer is
a separate question that does not block any slice here.

## Receipt — S2/S3 build (2026-08-15)

Built on `wt/laptop-terminal-s2s3`, not touching S1 (image provisioning / bootstrap — a
separate lane owns that; confirmed `harness-seat:2` already present on debian1 before
starting, so S2 did not block on it).

**What exists now**, all under `modules/harness/seat/`:

- `seat-resident-shell.sh` — in-container interactive launcher: seeds credentials into the
  tmpfs HOME (same manifest as one-shot seats), `cd`s into the workspace, `exec claude`.
  Runs via `podman exec -it`, so tmux's own pane pty becomes the container's stdin/stdout —
  no second reader, unlike `seat-run.sh`'s log-tail-only tmux window.
- `seat-resident-run.sh` — buildbox-side, idempotent. Creates (or restarts) a **persistent**
  container (`harness-resident-<slug>`, `--entrypoint bash … sleep infinity`, no `--rm`) with
  the same containment as a one-shot seat (`--read-only`, `--cap-drop=ALL`, capped
  memory/cpu/pids), then ensures a **host-side** tmux session (`resident-<slug>`) whose window
  execs into it. tmux lives on the box host, not in the container, so a laptop sleep or ssh
  drop never touches it — reattach is `ssh <alias> -t 'tmux new -A -s resident-<slug>'`,
  the owner's existing motion pointed at a different host. Distinct name prefix
  (`harness-resident-` vs `harness-seat-`) so it never counts against the 3-seat dispatch
  cap and the boot reconciler's `harness-seat-*` reaper never touches it.
- `seat-resident.mjs` (laptop) — `start --host <name> [--repo] [--slug]` pins one buildbox
  explicitly (a resident session needs a stable, known address — never the fleet's own
  load-based host selection), pushes the repo via the *same* git-push mirror transport
  dispatch already uses (`syncPush`), then runs `seat-resident-run.sh` on that host and
  prints the exact attach command. `recover --host <name> …` is S3: triggers an on-demand
  rescue (new `seat-reconcile.sh rescue <mirrorPath> <runId>` subcommand — the *same*
  `rescue_tree_locked` a reboot already uses, callable without a reboot) that commits the
  mirror's current dirty tree to `refs/harness-seat/<slug>`, then fetches it home with the
  *same* `fetchRescueRef()` a crashed one-shot dispatch already uses. Never merges or checks
  out — `git diff HEAD refs/harness-seat/<slug>` is the recovery handle, same language the
  seat README already documents.
- `seat-resident.json` — image/limits config, capped at 1 resident/host for now (separate
  budget from `seat-remote.json`'s 3-seat dispatch cap).
- `modules/workstation/claude/bin/agent-sessions` — extended (not rewritten) to probe every
  `reachable` registry host for `resident-*` tmux sessions / `harness-resident-*` containers
  and print them in the normal listing (`--json` adds `boxResident`; `--no-remote` opts out).
  This is the literal S2 acceptance line ("`agent-sessions` shows it on the box").
- `modules/workstation/claude/lib/remote-build.mjs` — exported `DEFAULT_REMOTE_CONFIG`
  (was a private const) so a host-pinned caller can build a correct `cfg` without
  reimplementing every field remote-build's internals assume are present.

**Why the resident tree survives a container kill without a rescue at all**: `/w` is a bind
mount of the box's real mirror directory, not container-local storage. Killing
`harness-resident-<slug>` does not touch the files — only `podman exec`'s pty and whatever
the tmpfs `/seat-home` held is lost. `recover` exists to get the mirror's state *back to the
laptop*, not to save it from the container.

**What did NOT get verified — named gap, not fabricated**: live end-to-end run of
`seat-resident.mjs start`/`recover` against a real buildbox. Live SSH to debian1 worked
early in this session (confirmed `harness-seat:2` present, `claude` credentials already
converged onto the box's real `$HOME`, `tmux`/`podman`/`node` on PATH) but stopped
responding partway through testing — every subsequent raw `ssh` to any buildbox alias
returned silently with no stdout/stderr, while `ping` to the same tailscale IP kept
succeeding and unrelated network calls (`curl`) kept behaving normally. Consistent with a
rate-limit/deadman tripping after repeated rapid connection attempts from this sandbox, not
a buildbox outage. Per the standing constraint ("if an action could sever access, stop and
report") I stopped retrying rather than hammering it. Also unverified: `cmdRecover`'s reuse
of `remote-seat.mjs`'s `fetchRescueRef()`/`mirrorUrl()`, which calls the existing
`loadRemoteConfig()` (spine-CLI-backed) — a `/usr/bin/node`-direct probe of that call hung
past 20s in this sandbox for reasons not isolated (possibly this environment lacking
whatever the `node` PATH shim normally arranges); it is unmodified, already-used-in-production
code, so the most likely explanation is a sandbox-probing artifact rather than a real defect,
but I did not get a clean run to confirm.

**Not done, explicitly out of this receipt's scope**: full `od-wip` CLI integration for
box-resident refs (owner can recover today via the printed `git diff HEAD
refs/harness-seat/<slug>` handle; `od-wip list`/`restore` do not yet enumerate resident
sessions). S4 (Overdeck UI surface) and S5 (resident-by-default) are untouched — this
receipt is S2 + the load-bearing half of S3 only.

**Next executable action**: once buildbox SSH access is confirmed open again (owner check,
or a fresh session), run `node modules/harness/seat/seat-resident.mjs start --host debian1
--repo <small test repo>`, attach with the printed command, make a real edit, `podman kill
harness-resident-<slug>` on the box, run `recover`, and confirm `git diff HEAD
refs/harness-seat/<slug>` shows the edit. That is S2 and S3's full acceptance in one pass.

## Receipt — landing blocked (2026-08-15, same session)

Work is committed locally: branch `wt/laptop-terminal-s2s3` @ `7fda4515c` in worktree
`.worktrees/laptop-terminal-s2s3`, NOT pushed to `origin/main`.

Every git operation that touches the network in this session hung or failed in this session:
`git fetch origin main` through both the `git` PATH shim and `/usr/bin/git` directly, `land
--dry-run`, and `ship-init.sh` Phase A. A verbose trace of the `/usr/bin/git fetch` isolated
the real cause: GitHub answered the HTTP/2 request with a clean **401 Unauthorized** — the
network path itself is fine (TLS handshake, cert chain, HTTP exchange all completed
normally; a plain `curl https://github.com` in the same session returned 200) — this
session's git credential is not authorizing. Every attempt that did not force
`GIT_TERMINAL_PROMPT=0` then hung past a 30s `timeout`, consistent with git falling
through to an interactive credential prompt that has nothing to read from and does not die
to `SIGTERM` cleanly in this sandbox. This is a session-local credential/environment gap,
not a code defect in the new files, and not the buildbox SSH issue logged above (this is
GitHub HTTPS auth; that was Tailscale SSH — two different doors, both closed in this
session, for what looks like two different reasons).

**Next executable action**: from a session with working GitHub credentials, land normally:
`bash ~/.claude/workflows/lib/ship-init.sh /home/user/Projects/overdeck --mode merge-to-main`
then `bash /home/user/Projects/overdeck/.claude/scripts/ship.sh land wt/laptop-terminal-s2s3
/home/user/Projects/overdeck/.worktrees/laptop-terminal-s2s3`. Nothing about the branch
itself needs redoing — the commit is real and complete.

**Superseded**: this landed later that same day (`7fda4515c` is on `origin/main`), confirmed
at the start of the S4 receipt below.

## Receipt — S4 (2026-08-15)

Built on `wt/laptop-terminal-s4`. Confirmed at start: `7fda4515c` (S2/S3) already on
`origin/main` — the blocked-landing receipt above was stale.

**pi-agent rc=1 finding**: not a probe bug. `command -v pi-agent` on debian1/2/3 all return
rc=127 ("command not found") — the binary is absent from `PATH` on every box, full stop.
Uniform rc=1 from three independent hosts was three honest absences, not one broken probe.
Not a blocker for S4 (box-resident detection reads `tmux`/`podman`, not `pi-agent`) and not
fixed here — named for whoever owns installing it.

**What exists now**:

- `collector/src/sessions/remote-probe.mjs` — the existing per-host SSH probe now also runs
  the same `tmux list-sessions … | grep '^resident-'` + `podman ps --filter
  name=harness-resident-` check `agent-sessions` (the CLI, landed in S2) already uses, and
  emits `{ ledger: [...], resident: [...] }` instead of a bare ledger array — one SSH round
  trip per host, not two (a prior session logged SSH going quiet under rapid repeated
  connections; this does not add connections).
- `collector/src/sessions/remote.ts` — `RemoteHostProbe` gained a `resident:
  BoxResidentSession[]` field, parsed from the new probe shape with the same per-host
  try/catch as the ledger path, so a probe failure degrades resident data the same way it
  already degrades ledger data (`hostProbes` status/error, never a silent drop).
- `collector/src/adapters/sessions.ts` — `SessionsPanelData` gained `boxResident:
  BoxResidentSession[]`, flattened from the cached probes every poll.
- `apps/web/src/lib/session-types.ts`, `session-hosts.ts` — mirrored the type; `hostBreakdown`
  now attaches each host's `boxSessions` (real container status string, never a fabricated
  state — box-resident sessions are NOT force-fit into the 5-value ledger `SessionState`
  union, deliberately kept as a separate list per advisor guidance).
- `apps/web/src/components/sessions/SessionHostsTable.tsx` — existing "Sessions by host" table
  (unchanged primitive layer, `@platform-modules/ui-primitives` — this is an existing page
  being extended, not a new one, so Astryx-first does not apply here) gained a "Box sessions"
  column: an honest count per host, 0 where none, hover shows each session's slug + verbatim
  container status.
- debian2 (missing `harness-seat:2` image) renders as an honest `0` under "Box sessions" with
  probe status `ok` — a missing seat image does not break `podman ps`, so this is a real
  observation, not an unknown. The "unknown" path is reserved for hosts where the probe itself
  fails (ssh timeout, bad JSON, etc.), which already inherits the pre-existing
  `hostProbes`/`CoverageCell` degrade the table has had since before this slice.

**Verified**: `collector` — `bun test` (879 pass, 2 skip, 1 pre-existing unrelated fail in
`test/adapters-wiring.test.ts` — a "delivery" adapter id drift predating this branch, confirmed
via `git log` on that file); `pnpm --filter web typecheck` (0 errors) and `pnpm --filter web
test` (465 pass) and `pnpm --filter web build` (succeeds); `pnpm --filter @overdeck/deck-ui
typecheck`/`test` (429 pass). Live SSH `command -v pi-agent` run against all three boxes for
the finding above.

**What did NOT get verified**: no live buildbox actually has a `harness-resident-*` container
running right now (S2/S3's own receipt never got a live end-to-end resident session up), so
the new "Box sessions" column has not been observed showing a real nonzero count against a
live box — only against the test fixtures and unit tests above. The column's zero-state (the
common case today) is what every reachable box currently shows in production once deployed.

**Next executable action (S5)**: once a live `seat-resident.mjs start` session exists on a box
(S2/S3's own unfinished verification step), confirm the "Box sessions" column shows it
nonzero on the deployed sessions page — that closes the loop this receipt leaves open. Then
begin S5: make box-resident the default for new sessions, measured by laptop load average
before/after.
