diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index 8e1a99057..d2b52312b 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -95,6 +95,13 @@ jobs:
needs: [build-binary]
steps:
- uses: actions/checkout@v7
+ # full history: the #1346 additive-invariants-gate diffs the enroll/guard
+ # set vs `git merge-base HEAD ` — a shallow (depth-1) checkout has no
+ # common ancestor, so the merge-base is empty and the gate (correctly)
+ # refuses to pass an UNVERIFIABLE AC1 under INVARIANT_STRICT=1. Full history
+ # lets it actually verify. (#1346)
+ with:
+ fetch-depth: 0
- uses: pnpm/action-setup@v6 # version pinned by packageManager in package.json
- uses: actions/setup-node@v7
with: { node-version: 20, cache: pnpm }
@@ -112,6 +119,16 @@ jobs:
run: node packages/extension/scripts/assert_packaged_cli.mjs
- name: fleet-gate — guard + tunnel template sane (#324)
run: bash packages/extension/scripts/assert_fleet_guard.sh
+ - name: additive-invariants-gate — peer studios stay additive (ADR 0027, #1346)
+ env:
+ INVARIANT_STRICT: "1" # CI must be able to verify AC1 — an unverifiable base is a hard fail
+ run: |
+ # AC1 diffs the enroll/guard set vs the MERGE-BASE with the base branch.
+ # A shallow checkout may not carry the base branch, so fetch it here —
+ # this also persists the ref for the later vitest subprocess in the
+ # `pnpm -r run test` step, which runs the same gate.
+ git fetch --no-tags --quiet origin "${GITHUB_BASE_REF:-feature/free-tier-fleet}" || true
+ bash packages/extension/scripts/assert_additive_invariants.sh
# Pasqal python provisioning gate, source lane: the SHIPPED connector
# assets must provision a working interpreter (real venv + pip + SDK
# import at the pinned version + validator env-guard smoke). Guards the
diff --git a/CONTEXT.md b/CONTEXT.md
index 63ea66c22..f074da2cd 100644
--- a/CONTEXT.md
+++ b/CONTEXT.md
@@ -175,6 +175,26 @@ source)
The per-machine stance for where Sessions are served from, in three values. `standalone` — this machine spawns and owns its own chat server, detached and survivable so it outlives an extension-host reload and is re-adopted rather than dying with the editor (the default; the only mode that ever spawns). `server` — this machine runs the Canonical Server as a system service and the panel attaches to it. `client` — this machine never serves; the panel attaches to the Canonical Server through a Managed Tunnel. Determined by `~/.amico/ops/fleet/fleet.json` (no file = standalone). Machine-scoped, never synced.
_Avoid_: profile, spawn vs attach (as concept names)
+**Capability**:
+An orthogonal, open tag describing what a machine is *for* — distinct from its serve-stance (Server mode). Two known, behavior-adjacent tags: `compute` (a solve-target hint — declared-but-inert until a fleet-peer executor exists, an explicit non-goal today) and `roaming` (a transport hint that defaults a machine to Tailscale). Any other tag is a free descriptive label with no behavioral meaning; it round-trips verbatim. A Capability never overrides the guard/tunnel/hub decision — Server mode stays the only serve-stance authority (ADR 0026).
+_Avoid_: role (that is Server mode), type, class
+
+**Roster**:
+The amicode-owned, fleet-wide device list on the Canonical Server (`~/.amico/ops/fleet/roster.json`) — one row per machine, each machine the single writer of its own row (a registry/heartbeat model). A row is `{ machine_id, name, server_mode, capabilities[], sshAlias, transport, last_report, health, device_type? }` with `health ∈ {reachable, degraded, down}`; the UI labels `server_mode` as "role" and `last_report` as "last-seen". Surfaced at `GET /amicode/roster` (read) + `POST /amicode/roster` (self-report), on the proxied `/amicode/*` namespace so a client sees it through the host proxy. Separate from `fleet.json` (untouched — one-parser invariant, ADR 0023) and from the per-machine projection (which carries only this machine's topology). ADR 0026.
+_Avoid_: fleet.json (a different, per-machine file), device table, inventory
+
+**Device identity**:
+The self-reported, human-facing half of a Roster row: the machine's friendly **name** (macOS `scutil` ComputerName / Linux `PRETTY_HOSTNAME` / an explicit `amicode.deviceName` override, else the prettified hostname) and its **device type** (`server | desktop | laptop`, an open set — macOS `system_profiler` model / Linux chassis / an `amicode.deviceType` override, else absent). Derived once by logic shared between the enroll producer and the sidebar self-row so a machine and its peers cannot disagree; absent/undefined values fall back honestly (type → `server_mode`), never fabricated. The Canonical Server self-registers its own device identity keyed by `machine_id = canonical.host`. Refreshed by re-enroll. ADR 0028.
+_Avoid_: hostname (the raw FQDN, not the friendly name), machine_id (the identity key, not the display name)
+
+**Machine**:
+One computer in the Fleet — the unit a Roster row describes and Enroll brings in.
+_Avoid_: device (a Device is a QPU or emulator behind a Connection), node
+
+**Focused machine**:
+The one Machine the window's working surfaces are scoped to, chosen in the FLEET section and defaulting to this machine; a UI selection, never a Roster change.
+_Avoid_: active machine, selected device
+
**Fleet config**:
The file at `~/.amico/ops/fleet/fleet.json` that declares this machine's fleet role and the canonical server's coordinates (`host`, `port`, `sshAlias`). No file on disk = standalone. The guard script, extension, and installer all resolve role from this file — never from a hardcoded hostname.
_Avoid_: fleet.toml, fleet settings (those are VS Code settings, a different thing)
@@ -184,13 +204,17 @@ The one chat server that owns the fleet's Session store — the single writer ev
_Avoid_: master, primary, host
**Fleet**:
-The user's machines acting as one logical studio: exactly one Canonical Server plus zero-or-more clients, all attaching to the same Session store.
+The user's machines acting as one logical studio. Two topologies, both "the Fleet": the **single-store** shape — exactly one Canonical Server plus zero-or-more clients, all attaching to the same Session store — and the **independent-peer** shape (ADR 0031/0034), where **Peer studios** each own their own Sessions and reach each other by Observe/Control rather than a shared writer. A keeper/canonical coordinate owns roster and bootstrap authority in both.
_Avoid_: mesh, cluster
**Go Standalone**:
-The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself permanently. Not an escape hatch: a first-class choice. Sessions made locally stay local. Re-enrollment in a fleet is a separate flow (Enroll, deferred).
+The user-invoked mode switch from `client` to `standalone` — the machine leaves the fleet and serves itself permanently. Not an escape hatch: a first-class choice. Sessions made locally stay local. Re-enrollment in a fleet is a separate flow (Enroll).
_Avoid_: local fallback, offline mode, degraded mode
+**Enroll**:
+The idempotent, verifiable flow that brings one machine into a fleet — the inverse of Go Standalone. Server-first, the standard join-token pattern: `amico fleet enroll --as-server` provisions the durable hub service, mints the Fleet token, and emits a **join token** (`{ canonical, fleet_token, transport_hint, pin_version }` — a secret, 0600, never logged). A client redeems it with `amico fleet enroll --join-token`: it rejects a pin-mismatched token before writing anything, then writes `fleet.json` (role + canonical only — capabilities go to the roster row), registers its roster row, sets the transport (`tailscale` when the machine roams, else the token's hint), runs the installer (which installs the never-fork guard), and **verify-attaches** — reporting success only after a live probe of the just-set transport passes. A re-run repairs in place; a failed verify surfaces the specific cause with its fix and never reports a false success. `amico fleet enroll` is a distinct verb from the session registry — it writes membership, not session records.
+_Avoid_: register (as the verb name), pair, handshake
+
**Fleet token**:
The shared secret authenticating a client to the Canonical Server's data routes — minted when the fleet server is enabled, stored at 0600, handed to clients during the ssh-based setup flow. The sibling of the per-boot server password (ADR 0002): that guards a spawned server its extension owns; this guards the service no extension spawns.
_Avoid_: API key, password
@@ -215,6 +239,30 @@ _Avoid_: keepalive, lock, busy flag
The interval after an extension-host teardown during which a detached standalone server stays alive awaiting re-adoption. On expiry with no adoption and no active-work pin the server self-exits and deletes its handshake; a reload re-adopts well within it, a genuine quit does not.
_Avoid_: timeout, linger period
+**Peer studio**:
+An independent serving peer whose Sessions it owns, observed or controlled from another machine's window. The independent-peer complement to the single-store Canonical Server: Peer studios do **not** share one Session store — each owns its own, and cross-machine access is by Observe/Control, never a shared writer (ADR 0031, 0034).
+_Avoid_: remote server (that is transport), replica, node
+
+**Observe (peer observation)**:
+Read-only visibility into a Peer studio's Sessions from another machine — its session list, one session's history and events. Unlocked by **trust** (a reader token seeded at Enroll), not by a Control grant. A peer-owned session's reads route to the owner with the owner's own reader token; a write never routes on the observation path (#1537).
+_Avoid_: view, mirror, follow, spectate
+
+**Control (peer control)**:
+The authority to *drive* a Peer studio's Session from another machine — send prompts, archive, delete. A strictly higher unlock than Observe, carried by a persisted per-peer **Control grant** (`control` scope) and re-armed each session (never auto-restored). Fails closed: no grant, revoked, transport-down, or observe-only → the write is denied, never served locally.
+_Avoid_: write access, remote admin, takeover
+
+**Enable control**:
+The explicit, per-session act that arms Control on a peer. For a **self-owned** peer with verified management access it is one click on the controlling machine, authorized at Enroll — no target-side prompt, since a headless peer has no window. For a **shared** peer it is the far side of a request→approve handshake routed to the peer's Lifecycle-admin authority.
+_Avoid_: grant (that is the record), connect, claim
+
+**Lifecycle-admin authority**:
+The machine/operator that may mint, revoke, and re-admit a Peer studio's grants — the `lifecycle-admin` scope, **not** a superset of Control. Seeded at Enroll (the enroller is recorded as the peer's `authorityIdentityKey`), so a headless peer's Control can be approved from a UI-bearing machine; the peer itself only *enforces* presented tokens, never rendering approval.
+_Avoid_: owner, root, keeper (keeper is roster/bootstrap coordination, a different authority)
+
+**Driving**:
+The live state in which one window is authoring turns on a Peer studio's Session under active Control, marked by a persistent "driving ``" banner so control is never ambient.
+_Avoid_: remote session, controlling (as a noun), piloting
+
### Surfaces
**Work Column**:
@@ -238,7 +286,7 @@ A sandboxed ES-module card rendered in an iframe within Home. Authored by the ag
_Avoid_: Card (ambiguous — the UI has many cards), tile (as the concept name — tile is a size class)
**Sidebar**:
-The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section (deferred). The sidebar is navigation chrome — it follows the active session's project binding but never drives session switching. Single-clicking a file opens it as a tab in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab.
+The webview in the VS Code activity bar container, showing project navigation and system status. Contains action buttons (open chat, create project), a session-aware unified project tree (Research Projects with lifecycle metadata expanding into file trees; Dev Projects as plain expandable folders), and a collapsible fleet section that is **read-only** toward the fleet — a glanceable list of Machines from the host-owned Roster (one row per machine: name, `server_mode` labeled "role" + capability chips, a `reachable`/`degraded`/`down` health indicator, and `last_report` labeled "last-seen"), marking the Focused machine. It offers navigation actions only — connect, focus, Troubleshoot, Add machine, Refresh — and never writes fleet config or Roster rows. The section refreshes on posture-change and shows an honest empty/degraded state when the roster host is down — never a fabricated list (#1321, ADR 0026). The sidebar is navigation chrome — it renders state and navigates, never mutating fleet config or driving session switching. Single-clicking a file opens it as a tab in Preview (the multi-document file workspace in the side panel); double-clicking opens a native VS Code editor tab.
_Avoid_: Explorer (VS Code's native file explorer is separate), Panel (the in-app dismissible drawer is a different concept)
**Preview**:
diff --git a/config/specs/spec-20260914-205414-daemonize-standalone-server.md b/config/specs/spec-20260914-205414-daemonize-standalone-server.md
deleted file mode 100644
index aa8c3abde..000000000
--- a/config/specs/spec-20260914-205414-daemonize-standalone-server.md
+++ /dev/null
@@ -1,159 +0,0 @@
----
-type: spec
-schema_version: "1"
-spec_id: spec-20260914-205414-daemonize-standalone-server
-task_type: implement-slice
-parent_issue: 1142
-feature_branch: jj/1142-server-survives-reload
-acceptance:
- - daemon_reparented_off_spawner == 1
- - daemon_survives_spawner_exit == 1
- - handshake_records_server_pid == 1
- - adopts_surviving_daemon == 1
- - stop_terminates_daemon == 1
- - stop_before_pid_known_frees_port == 1
- - health_gate_blocks_until_ready == 1
- - targeted_suites_pass == 1
- - typecheck_clean == 1
-invariants:
- - the server is spawned loopback-only with the injected OPENCODE_SERVER_PASSWORD — the daemonizer adds no new network exposure
- - a foreign process holding the port is never killed — the #1178 reclaim discipline (ours-only, verify-freed) is preserved verbatim, including in the by-port stop() fallback (D6)
- - the PID persisted in the handshake, checked by the adopt gate, and killed by stop() is ALWAYS the opencode server's PID (the reparented grandchild) — the intermediate launcher's PID is never persisted, never adopted, never killed
- - the daemonizer uses a Node intermediate (process.execPath + ELECTRON_RUN_AS_NODE), never a `setsid` shell binary, so it holds on macOS and the linux-x64/arm64/WSL release targets
- - the CI harness runs under plain `node` and proves the POSIX double-fork mechanism ONLY — the Electron-as-node launcher wrapper that ships is exercised solely by the human reload test; the launcher command string gets a construction smoke check, but Electron-as-node reparenting is not machine-proven in this slice
- - _ready and url are invalidated by the health/keepalive path, never by a child-exit event (there is no owned handle to emit one) — a crashed daemon must not read as ready
- - the true end-to-end proof — an in-flight turn surviving a real VS Code window reload — is human-gated (one Cmd+R after deploy) and is NOT claimed by any machine gate in this slice
-baseline: { value: "0% survival — server reaped on every window reload", source: "live Phase-2 test 2026-09-15: survivor pid 26497 dead after Cmd+R; handshake rotated to cold-spawn 29435; confirmed setsid (pgid==pid) yet reaped → PID-tree kill" }
----
-
-# Daemonize the standalone server so it survives a VS Code window reload
-
-## Context
-
-#1142 keeps the standalone opencode server alive across a VS Code window reload so
-an in-flight agent turn continues while the extension reloads with new code. All six
-original slices (#1144–#1149) plus the wiring fixes (#1178, #1181) landed on
-`jj/1142-server-survives-reload`, and Phase-1 is verified: cold-spawn writes a valid
-`0600` handshake and the adopt gate's preconditions all pass.
-
-**Phase-2 (the actual reload) failed.** Live test 2026-09-15: the survivor `26497`
-was dead after a plain Cmd+R; the handshake was rotated (cold-spawn `29435`), meaning
-the new window found `pidAlive == false` and cold-spawned. Forensics established that
-our code did not kill it (`deactivate()`→`detach()` never kills; kill-by-port runs only
-on the no-handshake path; keepalive only deletes the handshake). The server is spawned
-`detached:true` + `unref()` + stdio→file — the textbook recipe — and confirmed to be in
-its **own process group** (`pgid==pid`), yet it still died. Therefore VS Code reaps by
-**PID-tree / recorded-PID**, which a process-group detach does not escape.
-
-A same-machine proof (safe, no ports) showed the fix: while a mock parent stayed alive,
-a single-fork child kept `PPID==parent` (inside the reaped subtree) while a **double-fork
-grandchild reparented to `PPID==1` immediately** (outside the subtree). Removing the
-server from the ext-host process subtree — not detaching harder — is the fix.
-
-## Decisions
-
-- **D1 — Double-fork daemonize.** `ServerManager` (log-file path) spawns an intermediate
- launcher `detached`; the launcher spawns `opencode serve …` `detached` and exits
- immediately, so the server reparents to init/launchd (`PPID==1`) before any reload.
- The launcher is a Node intermediate run via `process.execPath` with
- `ELECTRON_RUN_AS_NODE=1` (portable; no `setsid` binary).
-- **D2 — PID by discovery, not by report.** After the port passes health, the server's
- PID is obtained from the listener via the existing #1178 `pidHoldingPort(port)` (lsof)
- primitive. This is the PID written to the handshake. The launcher's PID is never
- persisted. Rationale: the listener PID is provably the server; a launcher-reported PID
- could be the (already-exited) launcher — the exact "reads a field whose value is wrong"
- defect this codebase keeps hitting.
-- **D3 — Stop by PID.** With no owned `ChildProcess` handle, `stop()` terminates the
- server by its discovered PID (SIGTERM→SIGKILL, verify-gone), reusing the #1178
- kill-and-verify primitive. `detach()` becomes trivially correct: nothing to detach —
- the server is already `PPID==1`; `detach()` only stops the log tailer.
-- **D4 — Loss of the child-exit event is covered.** Without a handle there is no
- `child.on("exit")`. Server-gone detection already lives in the keepalive path
- (`server_keepalive.ts` deletes the handshake when pings stop answering); no new
- mechanism is added.
-- **D5 — Public contract unchanged.** `ServerManager` keeps `start()/stop()/detach()/
- onReady/url`; only the internals change from "own a child handle" to "daemonize →
- discover PID → kill by PID". D5 means the *observable behavior* is unchanged (stop()
- terminates the server and resolves once it is gone); the implementation changes. The
- legacy piped path (no logFile, used by tests) is retained unchanged for backward compat.
-- **D6 — stop() has a by-port fallback.** When stop() is called before the PID has been
- discovered (server spawned, health not yet passed) or on a start() health-failure,
- there is no server PID to kill. stop() then falls back to freeing the PORT via the
- #1178 kill-and-verify primitive, which kills ONLY when the holder is our opencode
- server (`isOpencodeServer`) and never a foreign process. This is the same discipline
- that guards the reclaim path.
-- **D7 — PID discovery, primary + fallback.** Primary: `pidHoldingPort(port)` (lsof) after
- health — the listener is provably the server, and the launcher (which never binds the
- port and has already exited) can never be returned. Fallback for lsof-absent minimal
- containers: the launcher writes the SERVER's PID (its own detached child's pid, which it
- knows) to a pidfile beside the handshake; the extension reads it after health. The
- pidfile holds the server pid, so the "launcher PID is never persisted" invariant holds.
- The two must agree when both are available; the lsof listener wins on disagreement.
-
-## Measurement Protocol
-
-A real-process harness (Node, ephemeral — no port 43117, no VS Code), runnable on the
-CI Linux runner and locally on macOS, establishes the machine-checked criteria:
-
-- `daemon_reparented_off_spawner == 1` — after a daemonized spawn, the server's PPID is
- neither the ServerManager-spawning process nor the intermediate launcher (both known
- pids). On macOS and a plain Linux runner this is `PPID==1`; under a Linux child-subreaper
- it is the subreaper — still off the spawner. The portable assertion is `ppid(server) ∉
- {spawnerPid, launcherPid}`, which is what defeats the ext-host subtree reap.
-- `daemon_survives_spawner_exit == 1` — the spawning process exits; the server PID is
- still alive afterward (1 = alive).
-- `handshake_records_server_pid == 1` — the handshake written on daemonized spawn records
- the **listener's** PID (== `pidHoldingPort(port)`), and `isPidAlive(recorded)` is true.
-- `adopts_surviving_daemon == 1` — after the spawner exits, `adoptOrSpawn(handshakePath,
- liveDeps)` against the surviving daemon returns `outcome == "adopted"` (1 = adopted).
- This is the strongest automatable proxy for survive-reload: a real daemon, the real
- gate, the real adopt path.
-- `stop_terminates_daemon == 1` — `stop()` on a daemonized server (PID known) leaves the
- PID gone.
-- `stop_before_pid_known_frees_port == 1` — `stop()` invoked before health/PID-discovery
- frees the port via the D6 by-port fallback (port has no listener afterward), and does
- not kill a foreign holder (harness plants a non-opencode listener and asserts it lives).
-- `health_gate_blocks_until_ready == 1` — `start()` does not fire `onReady`/write the
- handshake until the port answers health (guards the extension side of the "not ready"
- race).
-- `targeted_suites_pass == 1` — the mocked/injected vitest suites (server_manager,
- server_lifecycle, server_handshake, server_teardown, cold_spawn_handshake, overlay_cache,
- and the new daemonize suite) pass.
-- `typecheck_clean == 1` — `tsc` is clean.
-
-The harness kills every process it spawns on teardown and binds no fixed port. It NEVER
-touches the live session's server (port 43117) or the live handshake.
-
-## Non-goals
-
-- Automating the real VS Code window-reload survival (human-gated; one Cmd+R post-deploy).
-- The engine-side `/keepalive` self-shutdown route (deferred in #1147; unchanged here).
-- Any change to the adopt gate's four checks, the #1178 teardown, or #1181 activation
- wiring beyond passing the discovered PID and the by-PID stop.
-
-## Review (by hand — tooling=manual, `amico spec review` absent on this repo)
-
-No automated critic ran. Three independent lenses were applied by hand — a weaker claim
-than tool-run, perspective-isolated critics, and recorded as such. No blocking
-contradiction found (no two spec lines that cannot both be true). All findings are
-advisories, resolved into the spec (round 2) or carried as plan obligations.
-
-- **Lens A — cross-module vocabulary (the recurring "reads a field its schema doesn't
- carry" defect).** The PID must be the server's across {handshake write, adopt
- `pidAlive`, stop kill}. Resolved: D7 (discover the listener via lsof post-health; pidfile
- fallback holds the server pid, never the launcher's) + the explicit invariant. D5 wording
- clarified: "contract unchanged" = observable behavior, not implementation.
-- **Lens B — portability (CI Linux + WSL, not just macOS).** (B1, severe) `daemon_ppid ==
- 1` is fragile under a Linux child-subreaper → relaxed to `daemon_reparented_off_spawner`
- (ppid ∉ {spawner, launcher}). (B2) the CI harness proves the POSIX double-fork under
- plain node only; the Electron-as-node wrapper is human-reload-verified — stated as an
- invariant + a launcher-command construction smoke check. (B3) lsof-absent → D7 pidfile
- fallback.
-- **Lens C — regression / #1178 orphan safety.** (C1) stop() before PID-discovery / on
- health-fail → D6 by-port fallback + new criterion `stop_before_pid_known_frees_port`.
- (C2) the by-port fallback keeps the ours-only kill discipline (foreign holder untouched)
- — folded into D6 + the invariant + the criterion's foreign-holder assertion. (C3)
- _ready/url driven by health/keepalive, not a child-exit event — stated as an invariant.
-
-Verdict: **approved-mechanical (by hand)** — tier 1 clean, three lenses applied manually,
-all advisories resolved or tracked. Not equivalent to tool-run `approved`. Round 2.
diff --git a/docs/adr/0023-base-tier-fleet-projection.md b/docs/adr/0023-base-tier-fleet-projection.md
new file mode 100644
index 000000000..1bdf9a8e6
--- /dev/null
+++ b/docs/adr/0023-base-tier-fleet-projection.md
@@ -0,0 +1,75 @@
+# ADR 0023 — Base-tier fleet projection (a public floor under the amicissimo authority)
+
+- **Status:** accepted
+- **Date:** 2026-09-18
+- **Context refs:** ADR 0005 (managed fleet), the fleet rearchitect (#1068 P3b-1,
+ #1106 P3b-2, #1194), spec-20260913-114814 (amicissimo the fleet authority),
+ `spec-20260904-fleet-boundary-and-thin-client` (the split + thin client).
+
+## Context
+
+The fleet rearchitect made **amicissimo** the single parser: its Python
+`fleet_authority` package publishes a contract-v1 `projection.json`, and every
+amicode consumer (extension `fleet_topology.ts`, `tools/fleet/install.sh`, the
+`amico-opencode-fleet-guard`) reads **only** that projection — never the raw
+`fleet.json`. The publisher is entitlement-gated (`PREMIUM_CODE = "amicissimo"`):
+an unentitled or checkout-less machine hits the **bootstrap exception**
+(`FLEET_BOOTSTRAP_EXIT = 75`) and every consumer honestly degrades to
+base-standalone.
+
+That coupling has a consequence the rearchitect did not intend to be permanent:
+**the base (public) product cannot run a hardened fleet client at all.** The
+never-fork guard's `exit 1` fires only when the projection says `role: client`;
+with no amicissimo, the projection never says that, so a client machine's guard
+falls through and spawns a local server — the exact silent-fork the guard exists
+to prevent (the 2026-08-07 incident class, #1227). A base-path user with two
+machines (a hub + a roaming client) therefore has no enforced client.
+
+## Decision
+
+Add a **base-tier projection producer**: a TS-native path so that when the
+amicissimo authority is unavailable (no entitlement, or entitled but no
+checkout) **but the machine carries a `fleet.json` declaring a real role
+(`client`/`server`)**, `amico fleet status --projection` renders and caches a
+minimal contract-v1 `projection.json` **itself**, and returns success — instead
+of exiting 75.
+
+Invariants held:
+
+1. **One reader, unchanged.** Consumers still read only `projection.json`
+ through `@amicode/schema`'s `readProjection`. We add a *producer* under the
+ authority, never a second consumer-side raw read. `assert_fleet_guard.sh` and
+ the single-parser test stay green by construction.
+2. **amicissimo stays canonical.** Entitled + checkout present → the Python
+ publisher runs exactly as before (health, locks, program, org sections, the
+ provenance-stamped epoch). The base tier fills only `mode` + `topology`
+ (role + canonical) + a stable local epoch — the floor, not the rich surface.
+3. **Surgical, minimal blast radius.** The base tier engages ONLY for an
+ enrolled machine (`fleet.json` role `client`/`server`). An unenrolled machine
+ (no `fleet.json`, or `role: standalone`) keeps the existing bootstrap-75
+ behavior verbatim — all existing bootstrap tests are preserved.
+4. **Honest provenance.** The base projection stamps
+ `publisher.identity = "amicode-base-tier"` and section provenance
+ `source: fleet.json`, so a reader can always tell a base-tier floor from the
+ amicissimo authority. The verb's JSON carries `base_tier: true`.
+
+### Freshness
+
+The base tier has no hub epoch, so it mints a **stable per-machine epoch**
+persisted at `~/.amico/ops/fleet/base_epoch` (a UUID; reused across calls so
+`freshnessBetween` compares within one epoch and never loops on "unknown").
+The counter is persisted beside the epoch and seeded from the publish
+wall-second. Each publish atomically advances it, including when publishes land
+within one second or the wall clock moves backwards.
+
+## Consequences
+
+- A base-path client is now enforced: guard reads `role: client` → `exit 1` →
+ the panel rides the tunnel, never forks. The installer stamps guard + settings
+ + tunnel from the base projection's `canonical`.
+- The dual-role hub case (a workstation that is both hub and editor) becomes
+ expressible: the machine can carry a `client`/`server` role and the consumers
+ behave accordingly without amicissimo.
+- No change to the entitled path, the contract version, or the consumers.
+- When the base tier and amicissimo disagree, amicissimo wins (it runs whenever
+ present); the base tier is strictly the fallback floor.
diff --git a/docs/adr/0024-fleet-thin-client-pluggable-transport.md b/docs/adr/0024-fleet-thin-client-pluggable-transport.md
new file mode 100644
index 000000000..2df0256d1
--- /dev/null
+++ b/docs/adr/0024-fleet-thin-client-pluggable-transport.md
@@ -0,0 +1,135 @@
+# ADR 0024 — Pluggable transport under the thin-client fleet
+
+- **Status:** proposed
+- **Date:** 2026-09-18
+- **Context refs:** ADR 0005 (managed fleet — this extends it), ADR 0002 (server-route
+ seam, per-boot password, loopback graft), ADR 0020 (standalone server survives reload),
+ ADR 0023 (base-tier projection), #792 (PRD: Fleet thin client — local shell, remote data),
+ and the interface/server decoupling (the engine / `amicode_service` / iframe three-actor
+ split, #451/#822/#391/#955). Robustness incidents: #775 (hub wedges under direct fleet
+ reconnect), #777 (first-attach probe impossible at high RTT).
+
+## Context
+
+The interface/server decoupling split the app into three actors: a loopback engine (stock
+`opencode serve`, bound `127.0.0.1` by construction), an `amicode_service` that serves the
+UI shelf and owns the `/amicode/*` routes and reverse-proxies everything else, and a chat
+iframe that frames the *service* origin. The service already runs headless on a host
+(`bootAmicodeServiceRunner`) and its `HubProxy` targets an arbitrary upstream URL — the
+machinery for a thin client exists.
+
+**#792 owns the thin-client split** ("local shell, remote data") and this ADR does not
+restate it. But #792 explicitly **defers the transport**: *"the tunnel remains the transport
+under the relay."* It moves the UI local and leaves the single launchd SSH `-L` forward
+underneath, unquestioned. That tunnel is the shared cause of two filed incidents:
+
+- **#777** — on a ~750 ms-RTT link every request takes ~2.5 s against a 1.5 s attach budget,
+ so a healthy hub is *unattachable*. Everything rides one high-latency tunnel.
+- **#775** — the hub wedges under *direct* fleet reconnect; a front-door proxy is the
+ deployed mitigation, root cause open. Reconnection pattern, not request volume, is the
+ trigger.
+
+The transport also has structural weaknesses independent of those incidents: SSH `-L` wraps
+the inner HTTP/TCP flow in a second TCP stream (TCP-over-TCP collapse under loss); a network
+roam kills the tunnel and drops every stream; and the launchd agent is macOS-specific
+(a Linux host needs systemd — already flagged in ADR 0005's accepted costs).
+
+Two invariants constrain any transport. **Loopback-only binding** (ADR 0002/0005): the host
+binds `127.0.0.1`, and credential/solver **mutation** routes fail closed on any non-loopback
+bind — the guard is a positive loopback allowlist (`localhost`/`::1`/`127.0.0.0/8`/`::ffff:127.*`),
+so a Tailscale `100.x` address trips it. **Never-fork** (ADR 0005): a client holds no engine
+and no store.
+
+## Decision
+
+Make the client↔host transport a **pluggable provider behind one config knob**
+(`amicode.fleetTransport`), decoupled from the app. A provider yields a base URL + health +
+lifecycle; the `HubProxy` already targets whatever URL it is given, so the app needs no
+transport-specific code.
+
+Ship three providers:
+
+1. **`ssh` (default).** The current launchd `-L` forward, refactored behind the provider
+ seam; a systemd unit is the Linux implementation of the same provider. Zero new
+ dependency, works anywhere SSH + keys do. **This is the floor and the only required
+ provider.**
+2. **`tailscale` (opt-in).** The host runs `tailscale serve` fronting its **loopback**
+ service; the client points the `HubProxy` at the MagicDNS origin. WireGuard gives roaming
+ (connection migration), NAT traversal (DERP relay fallback), and low latency over UDP —
+ no TCP-over-TCP collapse. Because the app still binds `127.0.0.1` and Tailscale's on-host
+ proxy is the only thing that dials it, **the loopback bind-guard is untouched** — this is
+ why `serve` is chosen over binding the engine to the tailnet IP directly.
+3. **`direct` (a URL).** For a host already reachable on a VPN/LAN — the provider is just the
+ URL plus a health probe, no tunnel manager.
+
+**Tailscale is never mandatory.** SSH is the default; Tailscale is a recommended upgrade for
+the roaming-laptop case, chosen per-machine. This is the honest answer to "do all users have
+to run Tailscale" — no.
+
+The transport carries the full data plane to the host — engine routes, the `/amicode/*`
+surface (per the confirmed *host-owns-all-state* decision recorded against #792), the event
+stream, and WebSocket upgrades (the terminal). Those routing changes belong to #792; this
+ADR governs only the pipe they travel through.
+
+## Invariants held
+
+1. **Loopback-only bind + mutation refusal unchanged (ADR 0002/0005).** The host binds
+ `127.0.0.1`; every provider proxies *to* loopback (`ssh -L` onto localhost, `tailscale
+ serve` onto localhost, `direct` onto a host that itself binds loopback behind its own
+ edge). The `100.x` case that would trip the guard is deliberately not the shipped path.
+2. **Never-fork (ADR 0005).** The transport gives the client a data-plane connection, never
+ an engine — the client still holds no store.
+3. **One topology reader (ADR 0023).** The provider is new machinery *under* the installer;
+ the projection/guard/installer contract and `assert_fleet_guard.sh` are unchanged.
+4. **Standalone untouched.** The escape hatch and the local-files workflow both remain; the
+ transport seam engages only for a fleet client.
+
+## Consequences
+
+- The launchd tunnel stops being *the* transport and becomes the `ssh` provider's macOS
+ implementation; the per-platform installer story (launchd / systemd / Tailscale) becomes a
+ provider matrix rather than a special case.
+- The provider seam realizes ADR 0005's aspirational "extension-owned Managed Tunnel" and its
+ "Fleet token" (the hub token, `~/.amico/fleet-hub.json`) without the launchd duct tape.
+- #777's attach-budget fix and #775's front-door mitigation compose with, but are not
+ replaced by, this work — a better transport narrows the failure envelope both incidents
+ live in; it does not close either issue.
+- Posture/hysteresis detectors (`fleet_posture.ts`, `fleet_poll_hysteresis.ts`) gain a
+ per-provider dimension (a Tailscale roam and an SSH drop have different signatures).
+- **SSE reconnect is lossless only on the per-session stream (#1264, Slice 4).** The
+ transport carries the event stream, and a blip on it drops every event in the gap unless
+ the stream is resumable. Only the engine's per-session route
+ `/api/session/{id}/event?after=` is: it replays durable events after an aggregate
+ `seq`. The relay therefore tracks that `seq` **per session** and resumes via `?after=` on
+ reconnect, deduping the boundary (client-side, in `session_event_resume.ts` — no engine
+ change). The multiplexed `/event` (per-instance) and `/global/event` streams are
+ **deliberately out of scope**: they emit `id: undefined` (no cursor form) and
+ `/global/event` rides an in-memory `GlobalBus` with nothing to replay, so they are proxied
+ **as-is** (never given an `?after=`, never assumed resumable). Making them resumable is
+ separate engine work — emit SSE ids + a durable/vector-cursor live stream — coordinated
+ with the `GlobalBus` subscriber-lifecycle root cause (#775), not forked from it.
+
+## Flip conditions
+
+- If opencode upstream gains native remote-attach with identity + auth, adopt it and retire
+ the provider seam (inherits ADR 0005's flip condition).
+- If `tailscale serve` latency or its dependency proves worse in practice than a hardened SSH
+ path, keep `ssh` as default and mark `tailscale` experimental rather than recommended.
+
+## Accepted costs
+
+- Three providers to maintain — mitigated: `ssh` is the only required one; `tailscale` and
+ `direct` are additive and independently disableable.
+- `tailscale serve` adds an optional third-party dependency for the machines that opt in.
+
+## Considered
+
+- **Bind the engine directly to the tailnet IP (`100.x`)** — rejected as the default: trips
+ the loopback mutation guard (ADR 0002), forcing a security-invariant loosening. Available
+ as a documented power-user path, never the shipped model.
+- **Keep the single SSH `-L` tunnel (status quo, #792's deferral)** — rejected: TCP-over-TCP
+ collapse, roam-fragile, macOS-specific, and the shared cause of #775/#777.
+- **Mandate Tailscale for all fleet users** — rejected: forces a dependency on everyone incl.
+ non-mac; the pluggable seam delivers the robustness option without the mandate.
+- **A cloud relay / rendezvous** — rejected for now: adds a hop and a hosted dependency the
+ SSH and Tailscale paths do not need; revisit only if inbound-NAT hosts become common.
diff --git a/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md b/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md
new file mode 100644
index 000000000..9408fd0c0
--- /dev/null
+++ b/docs/adr/0025-remote-ssh-default-posture-thin-client-lifeboat.md
@@ -0,0 +1,141 @@
+# ADR 0025 — Remote-SSH as the default fleet posture; the thin client as the degraded lifeboat
+
+- **Status:** proposed
+- **Date:** 2026-09-19
+- **Context refs:** ADR 0005 (managed fleet — **this ADR partially supersedes its Remote-SSH
+ rejection**), ADR 0024 (pluggable transport under the thin client — **this ADR recasts, does
+ not retire, that work**), ADR 0002 (loopback graft / mutation refusal), ADR 0020 (standalone
+ survives reload). Program: #792 (thin-client PRD, merged), #1258 (durable hub, merged), #1260
+ (pluggable transport, merged), #780 (posture, merged), #1267 (FileSystemProvider host mount).
+- **Supersedes:** ADR 0005 lines 24–27 and 56 (Remote-SSH "rejected as the primary pattern" /
+ "wrong workflow") — overturned as a *direction*, gated as a *rollout* (see Sequencing).
+
+## Context
+
+ADR 0005 rejected Remote-SSH "as the primary pattern" with one specific reason (`0005:24-27`):
+
+> "Remote-SSH moves the user's whole editing context to the server (rejected as the primary
+> pattern — 'chat on the laptop about the laptop's files' breaks)"
+
+and listed it again under Considered (`0005:56`): *"Remote-SSH pivot (zero fleet code, wrong
+workflow)."* That objection assumed a **laptop-centric** workflow: the editing context is local,
+so moving it to the server would split chat (local) from files (remote).
+
+**The thin-client program inverted that assumption.** The merged work (#792/#1262: host-owns-all-
+state; #1258: durable host service; #1260: the transport carries the *full* data plane per
+`0024:69-72`) makes the product **host-centric**: chat, sessions, `/amicode/*`, the event stream,
+and the terminal all resolve to the host. The workflow is now "work *on the host* from a thin
+panel" — which is exactly the workflow Remote-SSH is built to serve, and it delivers natively what
+the thin client cannot: language servers, the integrated terminal on the host, debug, source
+control, native search, and the **native Explorer showing host files** (the split reported by the
+user; #1267 is the thin-client's partial answer, and an honestly hollow one — a virtual-scheme
+mount carries files but none of that tooling).
+
+**The honest counter, which gates this decision.** ADR 0024 documents the fleet link as genuinely
+fragile (`0024:26-35`): at ~750 ms RTT a healthy hub is *unattachable* against a 1.5 s budget
+(#777); SSH `-L` collapses under loss (TCP-over-TCP); a network roam drops every stream. Remote-SSH
+puts the *entire* editor on that link, and when the link degrades it freezes everything (the
+extension host is on the far side), which is **worse** than the thin client — whose whole reason to
+exist is to keep the local shell alive on a bad link. So Remote-SSH is the better default **only
+when a degraded link falls back to the thin client**, and that fallback is not yet buildable
+(`extensionKind: ["workspace"]` puts the switch sensor on the dead far side of the link).
+
+## Decision
+
+1. **Adopt Remote-SSH as the *target* default fleet posture** for a client attaching to a host —
+ the "as if working on the host locally" experience (native Explorer, LSP, terminal, debug, git,
+ search on the host).
+2. **Recast the merged thin-client program as the degraded / roaming lifeboat**, not the default
+ and not waste: it is the posture the product falls back to when the link cannot carry a live
+ Remote-SSH session. Everything merged for #792/#1258/#1260/#780/#1264 serves the lifeboat.
+3. **Gate the default flip behind its prerequisites (Sequencing).** Until they land, **Remote-SSH
+ is opt-in and the thin client remains the shipping default** — this ADR sets direction and
+ sequence, it does not flip the default on merge.
+
+## Sequencing (the flip is earned, not declared)
+
+- **P1 — the lifeboat shows host files (#1267, filed).** The `amico-host://` FileSystemProvider +
+ honest capability label, so dropping to the thin client keeps host files in the native Explorer.
+ Ships first; independent of the default flip.
+- **P2 — a UI-kind extension split.** A companion component that runs on the *client* even under
+ Remote-SSH (where `extensionKind: ["workspace"]` otherwise puts everything on the host), giving
+ the link sensor an invariant local home. Prerequisite for any auto-switch; the critics' blocking
+ finding was that without it the auto-DOWN sensor sits on the dead far side of the link.
+- **P3 — posture-driven switch.** Auto-DOWN to the lifeboat on sustained link loss; prompt-UP to
+ Remote-SSH on sustained recovery. Reuses the merged link-health detector (`fleet_posture.ts`,
+ founding case "750 ms plane wifi"), retuned for a window-reopen (not a status-bar popup), with a
+ switch-frequency floor, a dirty-editor guard, and cross-scheme editor-URI carry. The chat is
+ continuous across the reopen because it is host-owned (#1262); the host-file view is continuous
+ because both postures show host files (native `file:` under Remote-SSH, `amico-host://` under the
+ lifeboat).
+
+**Only when P1–P3 are in does the default flip to Remote-SSH.** If P2 proves infeasible, the flip
+does not happen (see Flip/abort).
+
+## Invariants held
+
+1. **Loopback-only bind + mutation refusal (ADR 0002/0005).** Unchanged in both postures: under
+ Remote-SSH the host runs the engine/service on `127.0.0.1` (VS Code's remote channel is the
+ transport); under the lifeboat the transport providers proxy to loopback per `0024:76-79`.
+ Remote-SSH does **not** introduce a non-loopback bind.
+2. **Never-fork (ADR 0005).** A client still holds no engine and no store in either posture.
+ Remote-SSH runs the engine on the *host*, adopting the durable hub service (#1258); it does not
+ spawn a client-side shard.
+3. **One topology reader (ADR 0023).** The posture machinery reads the projection; this ADR adds no
+ second reader.
+4. **Window-mode is a separate axis from link-health posture.** `FleetPostureMode`
+ (fleet/degraded/standalone) is single-writer, transition-only; "which window mode am I in"
+ (Remote-SSH vs lifeboat) takes its **own** field — never an overload of the link-health mode.
+5. **No silent fallback.** An unreachable host is the honest degraded posture; the switch to the
+ lifeboat is an explicit, surfaced transition, never a silent reroute.
+
+## Consequences
+
+- **ADR 0024 is recast, not retired.** The pluggable transport (ssh/tailscale/direct), the durable
+ hub, the SSE resume, and the `/amicode/*` proxy become the lifeboat's machinery. `tailscale`
+ gains importance: a roaming client that wants to *stay* in Remote-SSH on a changing network is
+ best served by WireGuard connection migration, so the transport seam serves Remote-SSH too, not
+ only the lifeboat.
+- **A new dependency on VS Code Remote-SSH** as a first-class, tested posture: the durable hub
+ (#1258) must be reachable as a Remote-SSH target; the extension must behave correctly when it
+ relocates to the host (it already declares `extensionKind: ["workspace"]`, as devcontainer/WSL
+ already exercise).
+- **The chat webview rides the Remote-SSH channel** in the default posture: on a good link this is
+ fine; on a bad link it is the reason P3 exists. This ADR does not claim Remote-SSH makes chat
+ *faster* on a bad link — it does not; it claims the *default* experience is richer on a good link
+ and that the product degrades honestly to the lifeboat on a bad one.
+- **The reported file-surface split is closed twice over:** natively under Remote-SSH (the default),
+ and via the FSP mount under the lifeboat (#1267).
+
+## Flip / abort conditions
+
+- **Abort the default flip if P2 (the UI-kind split) proves infeasible.** Remote-SSH then remains a
+ documented opt-in posture and the thin client stays the default — the lifeboat becomes the whole
+ story, and this ADR's direction is not realized. This is a real possible outcome, stated up front.
+- **Retire the provider seam if opencode upstream gains native remote-attach with identity + auth**
+ (inherits ADR 0005's and ADR 0024's flip condition — adopt rather than maintain).
+- **Keep `ssh` as the lifeboat floor** if `tailscale serve` proves worse in practice (inherits
+ `0024:116-117`).
+
+## Accepted costs
+
+- Two first-class postures to test and surface, plus the switch between them — mitigated by
+ sequencing (P1 ships alone; the default flip waits for P2/P3) and by reusing the merged detector
+ rather than forking one.
+- Remote-SSH's own connect/reconnect UX and host-side server install become part of the supported
+ surface.
+- The merged thin-client program is demoted from "the remote story" to "the degraded story" — an
+ honest re-scoping of recent work, recorded here rather than smuggled.
+
+## Considered
+
+- **Keep the thin client as the default; Remote-SSH opt-in only (the review's recommendation).**
+ Rejected as the *direction* per the human decision to pursue the richer host-native default — but
+ retained verbatim as the **abort state** if P2 is infeasible, and as the shipping state until
+ P1–P3 land. The disagreement between this ADR and the review is therefore about sequencing and
+ end-state, not about what ships first (#1267 ships first either way).
+- **Flip the default to Remote-SSH now.** Rejected: without P2/P3 a bad link freezes the editor with
+ no lifeboat — the exact fragility (`0024:26-35`) the thin client was built to survive.
+- **A virtual FS (#1267) as the whole answer, no Remote-SSH.** Rejected as the end-state: a
+ custom-scheme mount is a hollow host surface (no LSP/terminal/debug/git/search); it is the
+ lifeboat's file bridge, not the host-native experience.
diff --git a/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md b/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md
new file mode 100644
index 000000000..b614a8229
--- /dev/null
+++ b/docs/adr/0026-generalizable-machine-capabilities-and-host-owned-roster.md
@@ -0,0 +1,141 @@
+# ADR 0026 — Generalizable machine capabilities + a host-owned fleet roster
+
+- **Status:** accepted
+- **Date:** 2026-09-20
+- **Context refs:** ADR 0005 (managed fleet — defines `Server mode`), ADR 0023 (base-tier
+ fleet projection — the one-parser invariant), ADR 0025 (Remote-SSH default / host-owns-all-
+ state), ADR 0002 (loopback bind / mutation refusal). Program: #792 (thin-client, merged),
+ #1258 (durable hub, merged), #1260 (pluggable transport, merged), #1262 (`/amicode/*`→host
+ proxy, merged), #780 (posture, merged).
+- **Supersedes:** nothing. Additive to the fleet contract.
+
+## Context
+
+Today a machine's role in the fleet is exactly its **Server mode** — the closed union
+`standalone | server | client` (ADR 0005) that says *where sessions are served from*. That
+union is load-bearing: the never-fork guard, `tools/fleet/install.sh`, three TypeScript
+consumers, and the external `amicissimo` parser all branch on it, and ADR 0023 makes
+`amicissimo` the single parser of `fleet.json`.
+
+Users want to say more than serve-stance. The motivating asks — "this laptop is Amicode on the
+go," "this machine is the server," "this box runs solve jobs" — decompose into three *different*
+kinds of statement: a serve-stance (`client`/`server`, which `Server mode` already covers), a
+descriptive intent (`roaming`), and a compute capability that **has no backing infrastructure**
+(a solve executes `local` or `remote`=Harmoniqs Cloud; a fleet *peer* as a compute target does
+not exist). Overloading one enum to carry all three conflates orthogonal axes and forces a
+cross-repo `amicissimo` contract change for every new label.
+
+Separately, there is **no fleet-wide device roster** anywhere in the contract. The projection's
+`topology` section describes only *this* machine's `{role, canonical}`; the `devices[]` list in
+the `health` section is a **hardcoded bash array** in `ops/fleet-status.sh`, not derived from
+membership. A "show me my fleet" surface has no roster to read.
+
+## Decision
+
+1. **Two axes, not a widened enum.** Keep `Server mode` (`standalone|server|client`) as the
+ serve-stance, unchanged and still the sole driver of the guard/tunnel/hub-service behavior.
+ Add an **orthogonal, open set of capability tags** per machine. Tags come in two classes:
+ - **Known, behavior-driving** — `compute` (see #4) and `roaming` (a hint that defaults a
+ machine's transport to `tailscale`).
+ - **Descriptive** — any free tag or human nickname, with zero behavioral meaning.
+
+2. **The capabilities live in an amicode-owned roster, not in `fleet.json`.** Introduce
+ `~/.amico/ops/fleet/roster.json` on the **Canonical Server**. `fleet.json` is **untouched**
+ (role + canonical only), so ADR 0023's one-parser invariant holds and this needs **zero
+ `amicissimo` changes**. Each machine owns its own roster row (a registry/heartbeat model, no
+ dual-writer conflict); the authoritative per-machine role remains its own `fleet.json`, and
+ the roster row is the reconciled self-report.
+ - Row shape: `{ machine_id, name, server_mode, capabilities[], sshAlias, transport,
+ last_report, health }`; schema-versioned. `health ∈ { reachable, degraded, down }` (a
+ per-device reachability tri-state — distinct from the *link-health* posture vocabulary
+ `ok/degraded/hub-down`, which is about this machine's link to the hub, not a peer's
+ reachability). The UI renders `server_mode` as "role" and `last_report` as "last-seen" —
+ same fields, display labels.
+ - Rows are written by the machine itself (a self-report over the authenticated `/amicode/`
+ data plane — the accept-both service/engine mint of #822, to a loopback-bound host, NOT the
+ ADR-0005 Fleet token, which guards SSH enrollment) or by the `/create-a-fleet` orchestrator
+ acting for it during enroll. A write only ever touches the reporting machine's own row. The
+ reachability status job (`ops/fleet-status.sh`) probes per roster row, retiring its
+ hardcoded device array.
+
+3. **Surface the roster via a host route on the proxied `/amicode/` namespace — not the
+ per-machine projection, and NOT under `/amicode/fleet/*`.** The projection is published
+ *locally* per machine from that machine's own `fleet.json`, so it is the wrong vehicle for
+ fleet-wide state. Instead the host serves the roster at **`GET /amicode/roster`** (read) and
+ accepts the self-report at **`POST /amicode/roster`** (write), and every client obtains the
+ identical fleet-wide roster through the already-merged `/amicode/*`→host proxy (#1262).
+ **Critically, the roster must live OUTSIDE `/amicode/fleet/*`:** that prefix is the client's
+ own *local* honesty surface (its posture/mode/staging), which `shouldProxyAmicodeToHost`
+ (`amicode_service/server.ts:250`) deliberately refuses to proxy so a client's own status can
+ never be masked by the host's — verified by `fleet_client_relay.test.ts` (the host never sees
+ `/amicode/fleet/*`). A roster route under that prefix would 404 on every client. Every OTHER
+ `/amicode/*` path IS proxied to the host (`server.ts:251` returns `true`), so `/amicode/roster`
+ is carried unchanged — the read as a GET proxy, the write as a proxied mutation to the
+ loopback-bound host (the accepted-mutation path `fleet_client_relay.test.ts` covers). This is
+ consistent with the host-owns-all-state model ADR 0025 adopts, and adds no second topology
+ reader (ADR 0023 stays green).
+
+4. **`compute` is declared-but-inert.** The capability model, the enroll flow, and the Fleet
+ Manager UI all carry `compute`, but it **drives no solve routing**: a fleet-peer executor
+ (SSH-dispatch a solve to a compute-tagged machine + artifact sync-back + tier/warrant
+ integration) is a greenfield subsystem and an explicit **non-goal** of this work, named for a
+ future issue. The UI shows `compute` with an honest "not yet wired" affordance. `compute` is
+ the first known behavior-driving tag; it proves the open-capability pattern without forcing
+ the subsystem.
+
+## Approaches considered
+
+- **Widen the `Server mode` enum** (add `cluster`, `laptop`, …) — rejected: conflates
+ serve-stance with capability, breaks the closed union the guard + `amicissimo` + three
+ consumers depend on, and forces an `amicissimo` contract change per new value. (It also walks
+ into the glossary's `Fleet` `_Avoid_: cluster`.)
+- **Put `capabilities[]` in `fleet.json`** — rejected: cleanest single source per machine, but
+ forces a cross-repo `amicissimo` parser change (or capabilities vanish on machines that *have*
+ `amicissimo`), which can block the UI on external work.
+- **Capabilities in VS Code machine settings** — rejected: fully amicode-owned, but each machine
+ sees only itself; there is no cross-fleet roster, so a manager view cannot show peers.
+- **Roster in an additive projection section** — rejected in favor of the host route: the
+ projection is locally published, so it cannot carry fleet-wide state to a client without a new
+ aggregation path; the host route + #1262 proxy already exists.
+- **A roster route under `/amicode/fleet/*`** — rejected: that prefix is the one namespace
+ `shouldProxyAmicodeToHost` (`server.ts:250`) refuses to proxy (the client's own local honesty
+ surface), so it would 404 on every client. The roster is host-owned fleet-wide state, the
+ opposite of a local honesty surface, and belongs on the proxied `/amicode/roster` path.
+
+## Invariants held
+
+1. **One topology reader (ADR 0023).** The roster is a *new amicode-owned artifact*, not a second
+ `fleet.json` parser; the guard-assert gate stays green.
+2. **Never-fork (ADR 0005).** Enroll on a client installs the guard (`role: client` → `exit 1`);
+ no client spawns an engine. Capabilities never change serve-stance behavior.
+3. **Loopback bind + mutation refusal (ADR 0002/0005).** The roster self-report write rides the
+ authenticated `/amicode/` data plane (the accept-both service/engine mint, #822) to a
+ loopback-bound host — the same inject-the-credential, keep-loopback model #1262 uses; the new
+ write route calls the `bind_host` loopback check like the other mutation routes
+ (`solver_mode.ts`, `connections.ts`).
+4. **No silent fallback (ADR 0024/0025).** Roster health reflects honest reachability; nothing
+ reroutes silently.
+5. **Server mode is the only serve-stance authority.** Capabilities are orthogonal metadata; a
+ tag never overrides the guard/tunnel/hub-service decision.
+
+## Consequences
+
+- The generalizable role model the product wanted lands without a cross-repo contract change; the
+ first two useful tags (`roaming`, `compute`) ship immediately, one live and one honestly inert.
+- The fleet gains a real device roster; `ops/fleet-status.sh` stops hardcoding membership.
+- A future fleet-peer executor can make `compute` live without re-opening this ADR — it only adds
+ behavior behind an already-modeled tag.
+- `amicissimo` may later enrich the roster; amicode owns the public floor, mirroring ADR 0023.
+
+## Non-goals
+
+- The fleet-peer solve executor (the subsystem that would make `compute` live).
+- Any change to `Server mode`'s union or the `fleet.json` contract.
+- `amicissimo` parser changes.
+
+## Source
+
+- Design of record: the fleet-setup PRD-parent, #1316 (this branch's
+ `feature/free-tier-fleet` design-of-record), which decomposes into **five** slice sub-issues:
+ capability model + roster (#1318) · enroll primitive (#1319) · `/create-a-fleet` orchestrator
+ (#1320) · sidebar section (#1321) · Fleet Manager tab (#1322).
diff --git a/docs/adr/0027-peer-fleet-studios-and-compute-federation.md b/docs/adr/0027-peer-fleet-studios-and-compute-federation.md
new file mode 100644
index 000000000..2738990cc
--- /dev/null
+++ b/docs/adr/0027-peer-fleet-studios-and-compute-federation.md
@@ -0,0 +1,189 @@
+# ADR 0027 — Peer fleet studios, with compute federation as the declared end-state
+
+- **Status:** accepted — Decision #1 (§D1, "Additive peer topology") superseded by ADR 0029
+- **Date:** 2026-09-20
+- **Context refs:** ADR 0005 (managed fleet — defines `Server mode`, the never-fork guard), ADR 0023
+ (base-tier projection — the one-parser invariant), ADR 0024 (pluggable transport), ADR 0025
+ (Remote-SSH default / host-owns-all-state), ADR 0026 (generalizable capabilities + host-owned
+ roster), ADR 0002 (loopback bind / mutation refusal). Program: #1316/#1323 (fleet setup +
+ generalizable roles + Fleet Manager, merged on `feature/free-tier-fleet`).
+- **Spec:** `config/specs/spec-20260920-213918-peer-fleet-compute-federation.md` (reviewed:
+ tier-1 mechanical `approved-mechanical`; judgment critics run by-hand — see that spec's Review).
+- **Supersedes:** nothing. **Additive.** It amends ADR 0025's *single-canonical-hub* premise to
+ "one hub **or** many peers," and leaves the hub path (enroll, guard, Remote-SSH) byte-unchanged.
+- **Amended by:**
+ - ADR 0028 (#1368) — lifts this ADR's byte-freeze of the enroll verb
+ (`fleet_enroll_verb.ts`) so it can produce self-reported device identity on the roster row.
+ The additive-invariants gate's AC1 `FROZEN` set drops the enroll verb accordingly; the
+ never-fork guard freeze and the one-parser / single-writer / no-client-stance invariants
+ (AC2 / AC3 / AC4 + `assert_fleet_guard.sh`) remain in force.
+ - ADR 0029 (accepted) — retires Decision #1's "additive, two topologies" framing: there is
+ **one fleet**, and `serving` is a per-device capability, not a topology or role. Decision #1's
+ **mechanism** (independent studios, the multiplexing service, the §D2–§D8 substrate seams) is
+ retained and generalized to the default; only its hub-vs-peer **framing** is superseded.
+
+## Context
+
+A fleet today is a **star**. `amico fleet enroll` (#1316/#1323) makes exactly one machine
+`role=server` and every other machine a `role=client` whose never-fork guard
+(`amico-opencode-fleet-guard`, `exit 1`) forbids it from running an engine — it tunnels to the one
+hub and shares the hub's database. ADR 0025 reinforces this (Remote-SSH default, host owns all state).
+
+Two observations reframe the goal:
+
+1. **"Host a server" has two senses.** Running an engine *for yourself* is already universal: no
+ `fleet.json` ⇒ `standalone`, and a standalone machine spawns/adopts its own loopback engine.
+ Serving *for others* is the designated `role=server`, capped at one, with clients barred from
+ serving.
+2. **The north star is server-to-server agent spooling** — engines dispatching agents/subagents to
+ one another and streaming results back. This is *impossible on one host by definition* (one
+ engine, nothing to spool), and the star path provisions the wrong substrate for it (never-fork
+ clients that cannot accept a dispatched agent).
+
+The merged branch already built a **topology-agnostic foundation** this ADR reuses: the host-owned,
+fleet-wide `roster.json` (`GET/POST /amicode/roster`), the open `capabilities[]` axis
+(`compute`/`roaming`; `compute` shipped "declared but inert"), `fleet_discover.ts` (roster +
+`~/.ssh/config` + tailnet), the roster-driven `ops/fleet-status.sh`, the Fleet Manager tab, the
+`amicode_service` reverse proxy (`shouldProxyAmicodeToHost`, `server.ts:242`), and `fleet_transport.ts`.
+
+## Decision
+
+**Two horizons, one substrate.** Ship **peer studios** (Horizon 1) as an additive mode built on that
+foundation, and design its substrate deliberately for **compute federation** (Horizon 2), which this
+ADR declares but does not build.
+
+1. **Additive peer topology.** Peers coexist with the hub/star mode; the hub `enroll`, the never-fork
+ guard, and ADR 0025 remain for the thin-client→server case. A peer stays `standalone` and
+ *advertises*; it never takes the `client` stance, so the guard is never triggered and never relaxed.
+
+ > **Superseded by ADR 0029 (accepted).** One fleet; `serving` is a per-device capability, not a
+ > topology. The independent-studios mechanism (§D2) is retained and becomes the default; the thin
+ > client survives as an explicit `hosted-only` capability, not a topology. See ADR 0029 for the
+ > authoritative model.
+
+2. **Independent studios + a multiplexing `amicode_service`.** Each peer runs its own engine on its
+ own DB (single-writer-per-DB preserved *per machine* by the existing adopt-or-spawn gate). The
+ loopback service holds N keyed upstreams and re-targets by a **three-way resolver** generalizing
+ `shouldProxyAmicodeToHost`: the local honesty surface → local; `/amicode/roster` → the **keeper**
+ (§4); every other `/amicode/*` + engine + SSE → the **attached server**. **When the attachment
+ pointer is empty (a fresh standalone peer), the studio branch routes to the local engine** — the
+ attached branch is never undefined.
+
+3. **Attach/switch is a first-class action.** A **switch-control pointer** (its state and its
+ never-proxied local endpoint) is owned in one place; the resolver and the switch consume it.
+ **Switching** flips the pointer and **resets the SSE cursor** (a different server is a fresh
+ stream, never a `?after=` resume). An **attach/detach action** — an `amico`/service verb *and* a
+ Fleet Manager "Attach" control on the existing device rows — adds/removes an upstream and drives
+ the pointer, with the roster as the candidate list. (The baseline Fleet Manager reads
+ `server.current` but never switches; this is the decision that adds switching.)
+
+4. **Directory keeper, resolvable and decoupled from the hub.** Reuse the host-owned `roster.json`
+ and its routes; the "keeper" is a *registry* role, **not** "the only server" — every peer still
+ serves and can be attached to. The keeper's coordinate is carried by an explicit **keeper
+ bootstrap pointer** (a resolvable address, sibling to the switch pointer), **not** discovered from
+ the very `roster.json` the keeper hosts (which would be circular). Peers self-report their row to
+ the keeper (single-writer upsert, unchanged).
+
+5. **Serving advertisement = a placement-ready capability.** A peer advertises its already-running
+ engine via a roster `serving` tag plus reach coordinates, designed as a **placement descriptor** a
+ future scheduler reads (reachable? serving? headroom?), not a display-only chip. It rides the open
+ `capabilities[]` axis (ADR 0026); it does **not** change `server_mode` (still the sole serve-stance
+ authority) and needs **zero** `fleet.json`/`amicissimo` change.
+
+6. **Per-attachment transport — SSH default, engine-reachable, no forced Tailscale.** Reuse
+ `fleet_transport.ts`. Each transport is an authenticated, **engine-reachable channel**
+ (bidirectional-capable) so Horizon 2's engine↔engine RPC reuses it. SSH is the universal default;
+ `roaming`→tailscale and direct are opt-in. `fleet_discover.ts` already treats `~/.ssh/config` as a
+ first-class source, so no one is forced onto a tailnet.
+
+7. **The two recorded federation seams (this ADR is where they are decided):**
+ - **Peer-trust credential — a NEW peer token.** Horizon 1 injects the existing UI client mint
+ (#822/#1262) per attachment. For Horizon 2, peer-to-peer engine RPC is a *distinct* trust act
+ (server A dispatching arbitrary agent compute to server B) and gets its **own** credential: a
+ per-peer, separately-mintable and **separately-revocable** *peer token*, keyed by the machine's
+ roster `machine_id`. It is deliberately **not** the ADR-0005 Fleet token (which guards SSH
+ *enrollment*, a different act) and **not** the UI client mint (which authorizes a human's window,
+ not a peer engine). Recording this now means Horizon 2 needs no credential re-plumbing.
+ - **Event-relay seam — the multiplexing service's SSE path.** Horizon 1 exercises only the
+ cursor-reset-on-switch (§3). For Horizon 2, the relay point is the same service SSE path: a
+ remote child's events carry the **originating session id** and are **merged into that session's
+ stream** over the per-attachment channel. The named consumer is the Horizon-2 executor's
+ result/event path. Recording this now means the stream model does not need re-shaping later.
+
+8. **Horizon 2 is declared, not built.** The compute-federation executor — server-to-server dispatch
+ RPC, a placement scheduler (reading the §5 descriptor), artifact/result sync-back, inter-server
+ trust made live (the §7 peer token), and event relay/merge (the §7 seam) — is the end-state this
+ ADR names and an explicit **non-goal** of the Horizon-1 build. It makes `compute` live behind the
+ tag ADR 0026 already ships. **Dispatched work is stateless work owned by the originating session**
+ — the executor never grows a shadow session the originator must reconcile, so independent studios
+ (§2) stay intact.
+
+## The five substrate seams (Horizon-1 obligations for Horizon 2)
+
+1. **Engine-reachable transport** — §6.
+2. **Peer-trust credential** — §7 (the new peer token).
+3. **Placement-ready capability descriptor** — §5 (`serving`/`compute`).
+4. **Spawn-path placement target** — an optional target on `amicode_session`/Task dispatch, defaulting
+ to `local`, so Horizon 2 populates it without re-plumbing the spawn path.
+5. **Federatable event stream** — §7 (the SSE relay seam).
+
+## Approaches considered
+
+- **Keep one host (star only)** — rejected: server-to-server spooling is impossible on one engine,
+ and the never-fork client is the wrong substrate for it.
+- **Replace the star with peers** — rejected: discards the merged `enroll` flow and the thin-client
+ case ADR 0025 targets. Additive keeps both.
+- **Widen `Server mode` to carry "serving"** — rejected: conflates serve-stance with capability and
+ breaks the closed union the guard + `amicissimo` depend on (the same reasoning ADR 0026 used). The
+ advertisement is a capability tag, not a new role.
+- **Un-pin the webview's native multi-server switcher (client-side N origins)** — rejected for the
+ data plane: it bypasses the `amicode_service` `/amicode/*` ownership, credential translation, and
+ the single-origin CSP/auth model. The switcher UI is reused as the front-end that drives the pointer.
+- **Force Tailscale for multi-peer reach** — rejected: SSH is the universal default; tailscale is
+ opt-in via `roaming`.
+- **Reuse the ADR-0005 Fleet token (or the UI mint) for peer RPC** — rejected: each conflates a
+ distinct trust act; Horizon 2 gets its own revocable peer token (§7).
+
+## Invariants held
+
+1. **One parser of `fleet.json` (ADR 0023).** Everything new is amicode-owned (roster + service
+ registry + switch pointer). Zero `amicissimo` changes.
+2. **Never-fork + single-writer-per-DB (ADR 0005).** The guard and `enroll` are byte-unchanged; peers
+ never take the `client` stance; adopt-or-spawn keeps ≤1 writer per machine's DB. Attaching to a
+ peer is a proxy operation that spawns no engine. (Amended by ADR 0028: the guard stays
+ byte-unchanged; the `enroll` verb's byte-freeze is lifted for device-identity production — its
+ never-fork / single-writer semantics remain enforced by the guard freeze + AC2/AC3/AC4.)
+3. **Loopback bind + mutation refusal (ADR 0002).** The multiplexer keeps every upstream credentialed
+ and loopback-bound; the roster self-report and the switch-control write ride the authenticated
+ `/amicode/` plane to a loopback host.
+4. **Local honesty surface.** `/amicode/fleet/*` and the switch-control pointer never proxy, so a
+ peer's own posture/attachment can never be masked by a keeper's or a peer's.
+5. **`Server mode` remains the only serve-stance authority.** The `serving` advertisement is orthogonal
+ capability metadata.
+
+## Consequences
+
+- The product's "any capable machine can serve, and I can hop between them" lands as an additive mode;
+ the thin-client→hub case and ADR 0025 keep working. *(ADR 0029 reframes: the "additive mode" framing
+ is retired — there is one fleet; `serving` is a per-device capability, own-engine is the default.)*
+- The merged roster/discovery/status/UI are reused as the directory and front-end; the near-term build
+ is a multiplexing service + a switch pointer + an Attach control, not new plumbing.
+- The Horizon-2 executor can make `compute` live without re-opening this ADR — the transport, the peer
+ token, the placement descriptor, the spawn-path target, and the event-relay seam are all designed for
+ it here.
+
+## Non-goals
+
+- The Horizon-2 compute-federation executor itself (dispatch RPC, placement scheduler, artifact
+ sync-back, inter-server trust made live, event relay/merge). Named here; specced/built later.
+- Making `compute` live (stays "declared but inert").
+- Any change to `FleetConfig`/`fleet.json`, the hub `enroll` flow, the never-fork guard, or the
+ `amicissimo` contract. (Amended by ADR 0028: the `enroll` verb's device-identity *producer* is
+ a sanctioned change; the never-fork guard and `fleet.json`/`FleetConfig` contract stay frozen.)
+- Replacing the hub/star mode or ADR 0025's Remote-SSH posture.
+
+## Source
+
+- Design-of-record spec: `config/specs/spec-20260920-213918-peer-fleet-compute-federation.md`
+ (15 falsifiable acceptance criteria, Horizon-1 slice order with dependency edges).
+- Program precedent: #1316/#1323 (merged foundation), ADR 0026 (the capability + roster substrate).
diff --git a/docs/adr/0028-self-reported-device-identity.md b/docs/adr/0028-self-reported-device-identity.md
new file mode 100644
index 000000000..1940cf247
--- /dev/null
+++ b/docs/adr/0028-self-reported-device-identity.md
@@ -0,0 +1,163 @@
+# ADR 0028 — Self-reported device identity (friendly name + device type) in the roster
+
+- **Status:** proposed
+- **Date:** 2026-09-21
+- **Context refs:** ADR 0026 (host-owned roster + capability model — defines the row and the
+ single-writer self-report), ADR 0023 (one-parser invariant — `fleet.json` untouched),
+ ADR 0025 (host-owns-all-state). Program: #1318 (roster + capability model, merged),
+ #1319 (enroll primitive, merged), #1321 (sidebar section, merged), #1359 (`device_type`
+ field + type pill, merged), #1363 (client peer visibility — proxy-read + server-node
+ synthesis, merged).
+- **Supersedes / amends:** ADR 0027's byte-freeze of the enroll verb. ADR 0027 (#1346)
+ froze `packages/amico-run/src/fleet_enroll_verb.ts` (+ its test) byte-for-byte to prove
+ *its* peer-studio work was additive; producing self-reported device identity requires the
+ enroll producer to write `name`/`device_type` onto the roster row, so this ADR lifts that
+ whole-file freeze — the additive-invariants gate's AC1 `FROZEN` set drops the enroll verb.
+ ADR 0027's *actual* safety invariants are untouched: the never-fork guard shims stay
+ byte-frozen, and one-parser / single-writer / no-client-stance remain enforced (the gate's
+ AC2 / AC3 / AC4 + `assert_fleet_guard.sh`). Otherwise additive to the roster contract.
+
+## Context
+
+The roster row (ADR 0026, since enriched by #1359) already carries a `name` and an optional
+`device_type`, and the sidebar already renders them (`typeLabel = device_type ?? server_mode`).
+But nothing **produces** meaningful values: the enroll verb writes a row with
+`name = hostname()` (the raw FQDN, e.g. `JVs-MacBook-Pro.local`) and no `device_type` at all.
+
+The consequence is a split identity. Each machine already labels *itself* nicely on its own
+sidebar — the extension synthesizes a local self-row from `scutil --get ComputerName` (the
+user's chosen name, e.g. "Test Laptop") and a `system_profiler`-derived form factor. But
+that derivation lives only in the extension, so it never reaches the **roster row a peer reads**.
+A machine and its peers therefore disagree about the machine's name and type: the server sees
+`JVs-MacBook-Pro.local / client` while the laptop calls itself `Test Laptop / laptop`.
+
+The disagreement is symmetric. A client never sees a friendly server either: the client
+synthesizes the canonical-server node (#1363) from `fleet.json`'s `canonical.host`, so it renders
+the raw host string (`Desktop.local`) with a hardcoded `server` type — because the
+server does not self-register a roster row at all.
+
+## Decision
+
+1. **One derivation, shared by producer and consumer.** The name+type derivation becomes a
+ machine's **self-report**, computed by logic shared between the enroll producer
+ (`amico-run`) and the extension self-row — so a machine and its peers cannot disagree by
+ construction. The **pure** classification lands in `@amicode/schema` beside
+ `KNOWN_DEVICE_TYPES` (the vocabulary it targets): `classifyMacModel` (the rehomed existing
+ classifier), `classifyLinuxChassis`, `normalizeDeviceName`, and `isWslKernel` (the pure core
+ of the proven `rebuild/host_matrix.ts` WSL check). Every one is pure and unit-testable on any
+ OS from string inputs.
+
+2. **The impure shell stays per-package; `@amicode/schema` stays `child_process`-free.** The
+ `execSync` calls (`scutil`/`system_profiler` on darwin, `hostnamectl`/`/sys` DMI on linux)
+ live in a thin (~25-line) platform-branching caller in *each* node package (extension host +
+ `amico-run`), each delegating all judgment to the shared pure functions. Schema must not gain
+ `child_process`: `sidebar_fleet_section.ts` is browser-bundled and deliberately node-import-
+ free, and a `child_process` import in a shared contract module risks poisoning that bundle.
+
+3. **Cross-platform, honest where detection is unreliable.** macOS uses the existing commands;
+ Linux uses `hostnamectl --pretty` (the `PRETTY_HOSTNAME`, the Linux analog of the macOS
+ ComputerName) for the name and, for the type, `hostnamectl` chassis **first** then
+ `/sys/class/dmi/id/chassis_type` **second** then abstain — mapping
+ `laptop|notebook|portable → laptop`, `desktop|tower → desktop`, `server|rack → server`.
+ `hostnamectl` absent (minimal container / no systemd) ⇒ prettified hostname + omitted type.
+ **WSL is detected and abstains** to an undefined type — the WSL VM's chassis does not reflect
+ the physical machine. Resolution precedence for both fields: **explicit setting → OS detection
+ → prettified hostname (name) / `undefined` (type)**. The override reads **one namespace both
+ producers share**: `amicode.device.name` (already read by the extension self-row) plus a new
+ `amicode.device.type`; naming a *different* key on the producer would reintroduce split
+ identity on the override path, so this is a load-bearing choice, not a detail. An undefined
+ type is honest: the sidebar already falls back to `server_mode`. The friendly `name` is
+ derived on a seam **kept separate from the `canonical.host` derivation** — a friendly name
+ must never leak into `canonical`/the minted join token.
+
+4. **The server self-registers, keyed by its canonical host — and its own self-row agrees.**
+ `enroll --as-server` POSTs its own roster row to the loopback `/amicode/roster` **after
+ verifying the hub is reachable** (retry-or-honest-fail, like the client verify-attach path —
+ the pin probe's `dev` fallback tolerates an unreachable hub and is skipped when a version is
+ injected, so it is *not* proof of reachability), reusing the validated `parseRosterRow` +
+ single-writer upsert path. The server's row uses **`machine_id = canonical.host`** — the same
+ identity a client references it by — so the client's existing `maybeAddCanonicalServer`
+ de-dupe (`d.machineId === server.machineId`) collapses the synthesized node against the real
+ row with **zero client-side change**. But the client view is only half of it: the server's
+ OWN self-row is keyed by `os.hostname()`, which differs from `canonical.host` under a
+ non-default `--host` or macOS FQDN drift (the live fleet's `Desktop.local` is
+ exactly this case), so the server would render *itself* twice. Slice 2 therefore also
+ reconciles the server's self-row identity to `canonical.host` (or de-dupes the self-row
+ against the roster on it). `machine_id` need only match what *other* machines use to reference
+ a machine; clients keep `machine_id = hostname()`. `canonical.host` is treated as **immutable
+ after first server enroll** — changing it orphans clients' synthesized node and requires
+ re-enrolling clients, not just the server.
+
+## Approaches considered
+
+- **Duplicate the classifier in `amico-run`, leave the extension's copy** — rejected: the two
+ producers of a machine's identity would silently drift, which is precisely the split-identity
+ bug this ADR closes.
+- **Put the whole detector (pure + impure) in `@amicode/schema`** — rejected: schema is kept
+ side-effect-free and `child_process`-free for hygiene. (Note the honest nuance: schema already
+ imports `node:os`/`node:path`, so it is not browser-safe regardless, and the browser sidebar
+ module `sidebar_fleet_section.ts` re-declares the roster vocabulary rather than importing
+ schema — so the risk is not that schema "poisons" that specific module today, but that adding
+ `child_process` erodes the contract package into a runtime-effect package. "One derivation,
+ two consumers" is really two *node* consumers: `amico-run` and the extension host.)
+- **Enrich the join token / `canonical` with the server's name+type instead of a server row** —
+ rejected: it stretches the secret join-token contract to carry display data and leaves the
+ server a non-citizen of the roster, against ADR 0026's "each machine owns its own row."
+- **De-dupe the synthesized server node by `server_mode == "server"` instead of id-alignment** —
+ rejected: it adds client-side matching logic with edge cases (multiple server-role rows) to
+ avoid a one-line identity rule on the producer.
+- **Ship a Linux `hostnamectl`/DMI auto-detector with no override, and detect WSL chassis** —
+ rejected for WSL type: unreliable from inside the VM; the settings override is the honest
+ escape hatch. The Linux path itself is kept; only the WSL type auto-guess is dropped.
+- **A periodic self-report heartbeat** so identity refreshes without re-enroll — deferred: a
+ new subsystem, not needed to fix the reported bug. Re-enroll (idempotent upsert) is the
+ documented refresh path; the heartbeat is a named follow-up.
+
+## Invariants held
+
+1. **One topology reader (ADR 0023).** `fleet.json` is untouched; the roster remains the only
+ new artifact. No new parser.
+2. **Single-writer self-report (ADR 0026).** A machine writes only its own row; the server's
+ self-registration is its own row, keyed by its canonical host.
+3. **`@amicode/schema` stays side-effect-free.** Only pure functions are added; the impure shell
+ stays in the node packages. The browser bundle is unaffected.
+4. **No silent fallback (ADR 0024/0025).** An undetectable type is an honest `undefined` that
+ falls back to `server_mode`; a name with no friendly source falls back to the prettified
+ hostname; a hub that is not reachable at server self-register time is an honest failure, never
+ a false success. Nothing is fabricated.
+5. **Never-fork (ADR 0005).** No engine spawn is added on any path.
+6. **The friendly `name` never feeds `canonical.host`** or the minted join token — the display
+ seam and the canonical-host derivation are kept separate.
+
+## Consequences
+
+- A machine and its peers agree on its name and type by construction — the split-identity bug is
+ closed on both halves (client-as-seen-by-server and server-as-seen-by-client).
+- The server becomes a first-class roster row, making ADR 0026's "each machine owns its own row"
+ literally true and letting the client render the real server (`Test Desktop / desktop`)
+ instead of a synthesized raw-host node.
+- Linux/WSL clients get real detection with an explicit override; the extension's existing
+ `classifyDeviceType` is rehomed to schema and imported back, removing a soon-to-drift copy.
+- Already-enrolled machines keep their old rows until they **re-enroll once** (idempotent) — the
+ documented refresh path until a self-report heartbeat lands.
+- ADR 0027's additive-invariants gate (`assert_additive_invariants.sh`) drops the enroll verb from
+ AC1's `FROZEN` set (the never-fork guard shims stay frozen; AC2/AC3/AC4 unchanged). The enroll
+ verb's never-fork / single-writer semantics are still enforced — by the guard freeze and the
+ one-parser / single-writer / no-client-stance checks — so relaxing the whole-file byte-freeze does
+ not weaken any real invariant.
+
+## Non-goals
+
+- The periodic self-report heartbeat (auto-refresh without re-enroll) — a named follow-up.
+- A Linux chassis auto-guess under WSL (the override covers it).
+- Any change to `Server mode`, the `fleet.json` contract, `amicissimo`, or the roster schema
+ (the `name`/`device_type` fields already exist).
+
+## Source
+
+- Design of record: **#1368** (this ADR's paired GitHub issue), decomposed into two vertical
+ slices — the client-row producer (+ the shared schema derivation) and the server
+ self-registration (+ the `machine_id = canonical.host` reconciliation).
+- Prior art in-tree: `packages/schema/src/fleet_roster.ts` (the row + vocabulary),
+ `packages/amico-run/src/fleet_enroll_verb.ts` (the producer), and the extension's
+ `sidebar_view.ts` / `sidebar_fleet_section.ts` (the existing derivation + the consumer).
diff --git a/docs/adr/0029-unified-fleet-serving-capability.md b/docs/adr/0029-unified-fleet-serving-capability.md
new file mode 100644
index 000000000..e11d87360
--- /dev/null
+++ b/docs/adr/0029-unified-fleet-serving-capability.md
@@ -0,0 +1,177 @@
+# ADR 0029 — One fleet, differentiated by a `serving` capability (retire the hub-vs-peer topology split)
+
+- **Status:** accepted
+- **Date:** 2026-09-21
+- **Context refs:** ADR 0005 (managed fleet — `Server mode`, the never-fork guard), ADR 0023
+ (base-tier projection — the one-parser invariant), ADR 0025 (Remote-SSH default / thin-client
+ lifeboat / host-owns-all-state), ADR 0026 (generalizable capabilities + host-owned roster —
+ `server_mode` is the *closed* serve-stance union, `capabilities[]` the *open* axis), ADR 0027
+ (peer fleet studios — chose "additive, two topologies"), ADR 0028 (self-reported device
+ identity in the roster). Program: #1316/#1319/#1341–#1346 (#1353, merged), #1369 (the doc PR
+ that documents the current two-topology reality this ADR proposes to unify).
+- **Amends:** **ADR 0027 Decision #1 (§D1).** ADR 0027 shipped peer studios as a *second topology
+ additive alongside* the hub/star, leaving "am I a hub or a peer?" as a user-facing distinction.
+ This ADR reframes that distinction out of the product: there is **one fleet**, and whether
+ others may attach to a device is a **per-device `serving` capability**, not a topology or a
+ role. It changes the *model and the surface*, not the merged mechanism — the never-fork guard,
+ `enroll`, and single-writer-per-DB are preserved (see Invariants).
+
+## Context
+
+After ADR 0027 the fleet has **two** things a user must distinguish:
+
+- **Hub / star** — one `role=server`; the rest are never-fork `role=client` machines that tunnel
+ to the hub and share its DB (ADR 0005/0025).
+- **Peer studios** — each machine `standalone`, running its own engine, *advertising* `serving`,
+ attachable from the Fleet Manager (ADR 0027).
+
+The distinction is the wrong **user-facing** seam. "Is this a hub fleet or a peer fleet?" is a
+question about our implementation history, not about what the user wants ("some of my machines
+should host, some shouldn't"). It is also actively misleading: the stale docs that described
+only the hub/star topology led a fresh read of the tree (and this very session) to conclude peer
+support did not exist — the fix for which is #1369, but the *framing* is what made the error
+possible.
+
+Two facts make the unification tractable rather than a rewrite:
+
+1. **The substrate is already capability-shaped.** ADR 0026 gives every device a row on a
+ host-owned roster with an **open** `capabilities[]` axis; `serving` is already a known tag
+ (`fleet_roster.ts`), and `placementDescriptor(row)` already answers "reachable + serving."
+ ADR 0028 makes a device's identity (name, type — and, naturally, its capabilities)
+ **self-reported** into that row. "Which devices can host" is therefore already expressible as
+ data, without a new topology.
+2. **"Hub" was never really a role — it was two capabilities wearing one hat.** A hub is a device
+ that (a) others may attach to (`serving`) and (b) holds the roster (the **keeper**, ADR 0027
+ §D7). Both are already separable facts. The only thing genuinely distinct about the old
+ `client` is that it runs **no local engine at all** — a real capability difference, not a
+ topology.
+
+## Decision
+
+**One fleet. Every device is an independent studio. A capability says who may host for others.**
+
+1. **No hub/peer topology in the model.** A fleet is a set of devices sharing one roster. The
+ words "hub fleet" and "peer fleet" are retired from the product surface, docs, and the Fleet
+ Manager. What remains is a device list where each device advertises what it can do.
+
+2. **Own-engine is the default (this resolves the sharp question).** Every device runs its own
+ engine on its own DB by default — the ADR 0027 §D2 "independent studios" invariant, generalized
+ from "peer mode" to "the default." A device is therefore resilient by default: **no single
+ device's loss stops the fleet.** The old never-fork thin client is *not* the default for
+ non-hosting machines; it is a distinct, explicit opt-in (§4).
+
+3. **`serving` (can-host) is the attachability capability.** Whether others may attach to a
+ device is exactly its `serving` tag on the roster row — nothing else. The old "hub" is just a
+ device that is `serving` **and** is the current keeper. Attach targets **any** `serving`
+ device in the roster (the ADR 0027 attach path generalized); the hub tunnel becomes one case
+ of "attach to a serving device," not a privileged mode.
+
+4. **The thin client survives as an explicit capability, not a topology.** ADR 0025's lifeboat —
+ a weak or locked-down machine that *should not* run an engine — is preserved as an explicit
+ **`hosted-only`** device stance: it runs no local engine and attaches to a designated
+ `serving` device (its `canonical`), guarded by the unchanged never-fork guard. It is honestly
+ rare and never the default; choosing it is opting *out* of the own-engine default, not the
+ fallback for "can't host."
+
+5. **`server_mode` stays the closed guard decision; attachability leaves it.** ADR 0026 keeps
+ `server_mode` as the *closed* serve-stance union that the never-fork guard and `amicissimo`
+ depend on. This ADR does **not** widen it. The guard's local question — *do I fork an engine?*
+ — is still answered by the closed union (`standalone`/`server` fork; `hosted-only`/`client`
+ does not). What moves off `server_mode` is the **product-facing** question "can others attach
+ to me," which becomes the `serving` capability. `server_mode` becomes an implementation-level
+ guard input, not a user-chosen topology.
+
+6. **The keeper is an assignment, not a role.** Exactly one `serving` device holds the roster,
+ named by the keeper bootstrap pointer (ADR 0027 §D7). Any serving device can be the keeper; it
+ is a designation, not a special kind of machine. "Hub" ≡ "the serving device that is currently
+ keeper."
+
+7. **`fleet.json` is reinterpreted, not re-parsed.** The one parser (ADR 0023) is untouched. The
+ existing `role` values map onto the new model with no schema change: `standalone` ⇒ own-engine
+ device (advertise `serving` or not); `server` ⇒ own-engine + `serving` + keeper; `client` ⇒
+ the `hosted-only` stance (needs `canonical`). Existing hub/client fleets keep working
+ verbatim — this is a relabelling of what those values *mean* to the user, plus the surface
+ changes in §8, not a migration.
+
+8. **The surface collapses to one flow.** Fleet Manager becomes a single device list; each row
+ shows the device's identity (ADR 0028) and a **serving** toggle + an **Attach** control — no
+ "enroll as hub vs client" mode choice. `enroll` becomes "add this device to the fleet and set
+ its capabilities" (the same primitive, minus the hub/client fork in the *interview*);
+ `Go Standalone` becomes "drop `hosted-only`" (run your own engine). The `create-a-fleet` skill
+ loses its "exactly one server, the rest clients" step in favour of "which devices should be
+ `serving`."
+
+9. **The live-attach enabler is named, not built here.** Uniform "attach to any serving device
+ and drive its engine" needs the live proxy re-target that #1353 left intentionally unwired
+ (`server.ts` byte-identical; `resolveAmicodeTarget` has no live caller). This ADR **depends on**
+ that follow-up for the full attach UX but does not build it — the model and surface changes
+ here stand on their own (own-engine default + capability advertisement are already real).
+
+## Approaches considered
+
+- **Keep two topologies (ADR 0027 status quo)** — rejected: "hub or peer?" is an
+ implementation-history question, not a user goal, and its framing is what made peer support
+ invisible in the docs (the bug behind #1369).
+- **Retire the thin client entirely (pure peer mesh, every device runs an engine, no exceptions)**
+ — rejected: ADR 0025's locked-down / weak-machine lifeboat is a real, supported case; dropping
+ it to get a tidier model removes a capability people rely on. Demote it to an explicit
+ capability instead (§4).
+- **Widen `server_mode` into the open capability axis (fold `serving` into the role union)** —
+ rejected for the same reason ADR 0026/0027 rejected it: the never-fork guard and `amicissimo`
+ depend on a *closed* serve-stance union. Keep the guard's closed decision; move only
+ attachability to the open `capabilities[]` axis.
+- **Rename in docs only, leave the two-mode artifacts** — rejected: that is #1369 (which honestly
+ documents *today's* reality). The point of this ADR is to make it one fleet **in the model and
+ the surface**, so a user never picks a topology — not merely to relabel the prose.
+
+## Invariants held
+
+1. **One parser of `fleet.json` (ADR 0023).** No schema change and no new parser; the existing
+ `role` values are reinterpreted (§7). Everything capability-shaped rides the roster row
+ (ADR 0026) and the keeper/attachment pointers (ADR 0027).
+2. **Never-fork + single-writer-per-DB (ADR 0005).** The guard and `enroll` mechanism are
+ unchanged; `hosted-only` is exactly today's `client` stance (no engine spawn), own-engine
+ devices adopt-or-spawn one writer per machine. Attaching to a serving device is a proxy op that
+ spawns nothing.
+3. **`Server mode` remains the closed serve-stance authority (ADR 0026).** It is not widened;
+ attachability moves to the `serving` capability, orthogonal to it.
+4. **The thin-client lifeboat is preserved (ADR 0025).** It becomes an explicit `hosted-only`
+ capability rather than the meaning of "non-hosting device" — no supported case is dropped.
+5. **No silent fallback (ADR 0024/0025).** A non-serving, non-`hosted-only` device runs its own
+ engine honestly; a `hosted-only` device with an unreachable `canonical` is an honest failure,
+ never a fabricated attach.
+6. **Additive to ADR 0028.** A device's capabilities are part of its self-reported roster
+ identity; this ADR adds no new identity producer, it reuses that seam.
+
+## Consequences
+
+- The user never chooses a topology. They add devices to one fleet and toggle which ones are
+ `serving`; the "hub" is just whichever serving device is keeper. This is the model your machines
+ already physically have (own engine each) minus a misleading label.
+- **No single point of failure by default** — every device is an independent studio; a machine
+ opts *into* dependence (`hosted-only`) rather than being made dependent by being a "client."
+- Existing hub/client fleets keep working unchanged; migration is opt-in relabelling, not a break.
+- The Fleet Manager, `enroll` interview, `create-a-fleet` skill, and the fleet README all
+ simplify to one flow (device list + `serving` toggle + Attach).
+- The full "attach and drive any serving device's engine" UX is gated on the #1353 live-re-target
+ follow-up; this ADR makes that the single named enabler instead of one of two topologies.
+
+## Non-goals
+
+- **Wiring the live proxy/SSE re-target** (`server.ts` / `resolveAmicodeTarget`) — named as the
+ enabler (§9), specced/built separately (the #1353 follow-up).
+- **Horizon-2 compute federation** — unchanged from ADR 0027; `compute` stays inert.
+- **Any change to the never-fork guard mechanism, the `fleet.json` schema, or the `amicissimo`
+ contract** — this is a model/surface reframe over the merged substrate.
+- **Forced migration** of existing hub/client fleets — they keep working; adoption is opt-in.
+
+## Source
+
+- Prior art in-tree: `packages/schema/src/fleet_roster.ts` (`serving` tag + `placementDescriptor`),
+ `packages/amico-run/src/fleet_enroll_verb.ts` (the enroll primitive + role writer),
+ `packages/extension/src/amicode_service/{attachment_pointer,keeper_pointer,attach_action}.ts`
+ (the attach substrate), the Fleet Manager (`packages/app/src/pages/session/fleet-manager.ts`),
+ and `tools/fleet/README.md` (the two-topology reality documented by #1369).
+- Design-of-record follow-ups (if approved): a spec decomposing (1) the surface collapse (Fleet
+ Manager + enroll interview + skill), (2) the `hosted-only` capability rename over `client`, and
+ (3) dependence on the #1353 live-re-target enabler.
diff --git a/docs/adr/0030-connect-to-fleet-device-from-sidebar.md b/docs/adr/0030-connect-to-fleet-device-from-sidebar.md
new file mode 100644
index 000000000..e98a55fdd
--- /dev/null
+++ b/docs/adr/0030-connect-to-fleet-device-from-sidebar.md
@@ -0,0 +1,203 @@
+# ADR 0030 — Connect to fleet device from the sidebar
+
+- **Status:** proposed (reviewed: round 2, `approved-mechanical` — three manual critics, no tooling)
+- **Date:** 2026-09-21
+- **Context refs:** ADR 0026 (host-owned roster — device rows, sshAlias, transport),
+ ADR 0027 §D3 (attach/switch as a first-class action, the switch-control pointer),
+ ADR 0025 (Remote-SSH default — the hub-only `connectRemoteSsh` command),
+ ADR 0029 (unified fleet serving — `serving` capability replaces topology roles)
+- **Review:** round 1 returned 1 contradiction + 15 advisories (manual, three critics,
+ one lens each). This revision addresses the contradiction (boot-time pointer recovery)
+ and folds in the advisories that change the design surface.
+
+## Context
+
+The fleet sidebar (ADR 0026, #1321) renders device rows from the roster but they are
+read-only — clicking a device does nothing. The existing remote-connection paths are
+indirect: Remote-SSH (`amicode.fleet.connectRemoteSsh`) connects only to the hub,
+and the attach API (`POST /amicode/fleet/attach`) is reachable only through code or
+the CLI, not the sidebar.
+
+Users see their devices listed but have no gesture to *work on* one. A researcher
+with a Mac Studio (server) and a MacBook (client) should be able to click the Studio
+in the sidebar and start viewing its sessions, without leaving the Amicode window or
+remembering the CLI.
+
+**Boot-time recovery gap.** ADR 0029 §9 explicitly states that boot-time recovery of
+the attachment proxy from the on-disk pointer is "named, not built." After `POST
+/amicode/fleet/attach` writes the pointer and the window reloads, the D3 resolver in
+`server.ts` never enters the `attached` branch because no `HubProxy` is live — the
+user would see their own local sessions, not the target device's. This gap makes the
+Thin Client path inoperative without new boot-time wiring.
+
+**Synthesized canonical-server row.** On a CLIENT machine, the hub row in the sidebar
+is synthesized from `fleet.json`'s `canonical` field, not from the roster. The attach
+API (`attachActionResponse`) refuses `unknown_machine` when no roster row matches.
+The primary use case — a client clicking the hub — fails the roster gate.
+
+## Decision
+
+### §D1 — Device rows become clickable
+
+A left-click on any device row in the fleet sidebar posts a `connect-to-device`
+message through the existing typed bridge. This is a navigation action (like the
+existing Manage and Troubleshoot buttons), not a roster write — ADR 0026's read-only
+contract for the *roster* is preserved. The sidebar's header comments are updated to
+reflect the accurate contract: "navigation actions only, no roster-write path."
+
+A one-shot click guard prevents rapid double-clicks from stacking Quick Picks,
+matching the existing file-tree debounce pattern in `sidebar_webview.ts`.
+
+### §D2 — Quick Pick with two connection modes for remote devices
+
+On click, the host shows a VS Code Quick Pick titled "View sessions on [device name]
+remotely?" with two items:
+
+| Option | Label | Mechanism |
+|---|---|---|
+| **Thin Client** | "Thin Client (Poor Connection)" | `POST /amicode/fleet/attach` with the target's `machine_id` + credential provisioning → window reload. Reuses ADR 0027 §D3's attach/switch action and §D9's per-attachment transport. |
+| **Remote SSH** | "Remote SSH (Strong Connection)" | Generalise ADR 0025's `resolveRemoteSshTarget` to accept any device's `sshAlias` → `vscode.openFolder` in a new Remote-SSH window. |
+
+**Preconditions (disable with inline note when unmet):**
+
+| Condition | Disables | Note shown |
+|---|---|---|
+| Target lacks `serving` capability (ADR 0029) | Thin Client | "Not running a server" |
+| Target `health === "down"` | Thin Client | "Device unreachable" |
+| Target has no `sshAlias` | Remote SSH | "No SSH alias configured" |
+| `ms-vscode-remote.remote-ssh` extension absent | Remote SSH | "Requires Remote-SSH extension" |
+
+A confirmation dialog precedes the window reload for Thin Client ("This will reload
+the window and connect to [device]. Any in-progress chat will restart. Continue?"),
+matching the existing `goStandalone` confirmation pattern.
+
+### §D3 — Boot-time pointer recovery (the blocking prerequisite)
+
+This feature **includes** wiring boot-time pointer recovery as part of its
+implementation scope. Specifically:
+
+1. On extension activation, read the attachment pointer from
+ `~/.amico/ops/fleet/attachment.json` (via `resolveAttachmentPointer`).
+2. When a valid, non-empty pointer is found and this machine is not a hub-connected
+ `client` (i.e., it's a standalone or peer with an attachment), spin up the
+ per-attachment transport (§D9 of ADR 0027) for the pointed-to device.
+3. Register the resulting `HubProxy` on `fleetPlane.attached` so the D3 resolver
+ enters the `attached` branch on the next request.
+
+This is the minimum wiring that makes `attach → reload → proxied sessions` work
+end-to-end. It does NOT build the live re-target (switching without reload) that
+ADR 0029 §9 defers to #1353 — the reload is still required.
+
+### §D4 — Two-branch device resolution (roster + topology)
+
+The host-side handler for `connect-to-device` resolves device coordinates through
+**two sources**, because the canonical-server row on a client is synthesized from
+`fleet.json`, not from the roster:
+
+1. **Roster lookup** — `roster.rows.find(r => r.machine_id === machineId)`. Carries
+ `sshAlias`, `transport`, `capabilities`, `health`, `server_mode`.
+2. **Topology fallback** — when the roster lookup misses, read `fleet.json`'s
+ `canonical` for the `sshAlias` and derive `serving = true` (the canonical server
+ is serving by definition). The `transport` field defaults to `"ssh"` (the
+ canonical's `sshAlias` implies SSH transport — the universal floor per ADR 0027 §D9).
+
+The attach API (`attachActionResponse`) is amended to accept the canonical server as
+a valid target: when `machine_id` is not in the roster but matches the topology's
+canonical, resolve coordinates from the canonical instead. This closes the
+synthesized-row gap.
+
+### §D5 — Credential provisioning on Thin Client attach
+
+The sidebar's Thin Client path includes `base_url` and `token` in the attach request
+body — not just `machine_id`. The handler resolves these from:
+
+1. **Roster row** — `base_url` from the roster's transport-resolved URL.
+2. **Existing credential** — if a per-attachment credential for the target already
+ exists (from prior enrollment or Fleet Manager attach), reuse it.
+3. **Topology canonical** — for the canonical server, `base_url` is derived from
+ `canonical.host` + `canonical.port`.
+
+When no credential can be resolved, the Thin Client option is disabled with the note
+"No credentials available — use the Fleet Manager to connect."
+
+### §D6 — Separate write seam (not FleetSectionDeps)
+
+The existing `FleetSectionDeps` interface is read-only by documented contract. The
+connect-to-device handler is wired through a **separate injectable** — either a new
+`FleetConnectDeps` interface or a method on the `SidebarViewProvider` — injected from
+`extension.ts` where the service URL, auth headers, and roster are all available.
+This keeps the read-only fleet section contract intact.
+
+### §D7 — Local device shows "Go Standalone" with hub-aware warning
+
+Clicking the "(This Machine)" row offers the existing `amicode.fleet.goStandalone`
+command. When this machine is the hub (topology role is server AND other devices exist
+in the roster), the confirmation dialog is upgraded to warn: "This machine is the hub
+— going standalone will disconnect [N] other device(s). Continue?"
+
+### §D8 — Remote SSH resolver generalised, not forked
+
+A new `resolveDeviceRemoteSshTarget(sshAlias, workspacePath?)` entry point accepts a
+raw alias; the existing hub path (`connectToHubOverRemoteSsh`) calls it internally.
+One resolver, two callers. Before showing the Remote SSH option, the handler probes
+`vscode.extensions.getExtension("ms-vscode-remote.remote-ssh")` — absent means
+disabled.
+
+## Approaches considered
+
+- **Context menu (right-click)** — rejected: webview context menus are unreliable
+ across platforms; the webview `contextmenu` event is suppressed by VS Code on some
+ platforms. A left-click Quick Pick is the standard VS Code pattern.
+- **Inline button per row** — rejected: adds visual clutter to a section whose
+ strength is its clean device list. A click on the row itself is discoverable via
+ cursor change.
+- **Full per-device proxy (thin client to any device simultaneously)** — rejected for
+ now: ADR 0027's attach pointer is single-target by design. The re-attach path gets
+ the job done without new proxy infrastructure.
+- **Defer to #1353 for boot-time recovery** — rejected: shipping the sidebar gesture
+ without a working Thin Client path would be a dead button. The minimum wiring
+ (read pointer at boot, spin up transport, register proxy) is scoped here; the live
+ re-target (switch without reload) stays with #1353.
+- **`server_mode != "client"` as Thin Client gate** — rejected: ADR 0029 establishes
+ `capabilities.includes("serving")` as the authoritative predicate for attachability.
+
+## Invariants held
+
+- **ADR 0026 §read-only roster** — the sidebar emits no roster-write message. The new
+ message is a navigation action. The `FleetSectionDeps` read-only contract is
+ preserved; the write path uses a separate seam (§D6).
+- **ADR 0027 §D3 single-writer pointer** — the Thin Client path drives the existing
+ attach API; no second pointer writer.
+- **ADR 0027 §D3 roster-as-sole-candidate — amended.** §D4 adds the topology canonical
+ as a second trusted coordinate source when the roster lookup misses. The security
+ property ("the caller cannot forge a coordinate") is preserved: coordinates are
+ resolved from `fleet.json` (a trusted local config file), never from the request body.
+- **ADR 0025 §Remote-SSH authority** — Remote-SSH opens via the same
+ `vscode-remote://ssh-remote+/` URI; the generalisation adds no new authority
+ scheme.
+- **ADR 0029 §serving capability** — the Thin Client gate uses
+ `capabilities.includes("serving")`, not `server_mode`.
+
+## Consequences
+
+- Every device in the fleet sidebar becomes a first-class navigation target.
+- The Thin Client path makes the attach/switch action (ADR 0027 §D3) user-accessible
+ from the sidebar for the first time — previously it was CLI-only or Fleet Manager-only.
+- **Boot-time pointer recovery is wired** (§D3), unblocking this feature and any
+ future attach UX. The reload is still required; live re-target stays with #1353.
+- The generalised Remote-SSH resolver is independently useful for any future "connect
+ to device X" surface (Fleet Manager, command palette).
+- The attach API is amended to accept the canonical server as a valid target (§D4),
+ closing a gap that would also affect the Fleet Manager's attach gesture.
+
+## Non-goals
+
+- Multi-target attachment (connecting to two devices simultaneously as thin client).
+- Per-device workspace path configuration (uses the same `hubWorkspacePath` setting).
+- Live re-target (switching attached device without window reload) — stays with #1353.
+- Full credential lifecycle in the sidebar (enrollment, rotation) — the Fleet Manager
+ owns that; the sidebar reuses existing credentials or disables the option.
+
+## Source
+
+Implementing issue: #1409.
diff --git a/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md b/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md
new file mode 100644
index 000000000..9b556e43a
--- /dev/null
+++ b/docs/adr/0031-fleet-studio-in-window-multi-machine-sessions.md
@@ -0,0 +1,282 @@
+# ADR 0031 — Fleet Studio: in-window, multi-machine sessions and workspaces
+
+- **Status:** proposed (review: manual adversarial critics — `amico spec review` tooling absent on this machine; see the design-of-record PRD for the recorded review round)
+- **Date:** 2026-09-22
+- **Context refs:** ADR 0023 (one-parser projection), ADR 0024 (pluggable transport),
+ ADR 0025 (Remote-SSH default posture / thin-client lifeboat — **reframed here**),
+ ADR 0026 (host-owned roster; read-only fleet section — **amended here**),
+ ADR 0027 (peer fleet studios & compute federation — its **multi-target non-goal is
+ reversed here**, its §7 event-relay seam is **consumed here**),
+ ADR 0029 (unified serving capability), ADR 0030 (connect-to-fleet-device Quick Pick —
+ **superseded here**), ADR 0032 (peer-trust credential — the trust substrate this rides on).
+
+## Context
+
+The fleet today is a set of machines you **switch between**, not a studio you work
+*across*. Clicking a device (ADR 0030) shows a Quick Pick — "View sessions on X
+remotely?" — whose two options both **swap the whole window** at the target: Thin
+Client re-attaches the panel and reloads; Remote-SSH opens a new window. The app reads
+sessions from a **single** server connection, so you see one machine's sessions at a
+time, and a `Session.Info` record carries **no machine identity** — nothing says where a
+session runs.
+
+JJ's own fleet is two **`serving`-capable** machines (a MacBook and a Mac Studio) —
+exactly one of which is the keeper / Canonical Server at any time, per ADR 0029's model
+(`serving` is a many-valued capability; the keeper is one). The researcher wants what the
+product name already implies: **one window that sees and drives every session and workspace
+across the fleet, as if local** — watch a solve run on the Studio while chatting on the
+MacBook, open any machine's session in place, and always be able to tell which machine a
+session belongs to. (Session interaction is orthogonal to serving topology: a machine need
+only be reachable and hold a live engine, not be *the* Canonical Server.)
+
+This is the multi-target line ADR 0027 drew with its single, single-target attachment
+pointer, and that ADR 0030 named as a non-goal ("Multi-target attachment … rejected for
+now", ADR 0030 §Non-goals). This ADR decides to build the Horizon-1-usable core of it now.
+
+## Decision
+
+### §D1 — Multi-target, both live: a session-aware multiplexing proxy + SSE relay
+
+The local `amicode_service` becomes a **session-aware multiplexer**. Each *session-scoped*
+request — messages, prompt submit, tool-permission responses, the engine `file` API, and
+the SSE subscription — routes to the **machine that owns that session**, resolved by a
+session→machine map (from the §D2 fan-out). Concurrent per-peer SSE streams are **merged
+into the one event stream** the app consumes, each event tagged with its originating
+session id (ADR 0027 §7's event-relay seam, made live). The app keeps talking to its
+**single local origin** — the native multi-origin switcher stays rejected for the data
+plane (ADR 0027), because credential translation, CSP, and the accept-set (ADR 0032) all
+depend on the single-origin model.
+
+Multiple sessions from multiple machines are interactive **simultaneously**. This
+supersedes ADR 0027's multi-target non-goal for *interaction*; it does **not** introduce
+multi-target *attachment* of the whole panel — the single attachment pointer is retired
+for session routing (routing is now per-session, not one global pointer).
+
+The single-origin invariant is **purchased by** this multiplexer: because the app cannot
+itself tell a session on machine A from one on B (they share one origin), *all*
+cross-machine correctness lives in the service's per-session routing — which is only as
+sound as §D1a.
+
+### §D1a — The session→owner routing key (the load-bearing mechanism)
+
+The engine `file` API (`file.read` / `file.write` / `file.status`) — which the Preview tab
+and Files-changed use (§D7) — **carries no session id** in its request, and today's
+resolver routes by URL **path** to a **single** target (`/amicode/fleet*` → local,
+`/amicode/roster*` → keeper, everything else → the one attached pointer). So "routes to the
+owner" is **not free** and is **not** "the same plane the agent uses" without new work — a
+path-only `file.write` from two open remote sessions would hit the *same* single target.
+
+Decision: every session-scoped request must carry an **explicit owner key**, resolved as
+follows.
+
+1. **Session-pathed requests** (`/session//…`, prompt submit, permission replies) — the
+ owner is looked up in the session→machine map by the id already in the path; a map miss
+ triggers an on-demand fan-out refresh before routing.
+2. **Path-less engine requests** (`file.read`/`file.write`/`file.status`) — the app **must
+ attach the active session's owner `machine_id`** as a routing header/param, sourced from
+ the Work Column's bound session (the Work Column is session-scoped; it knows its owner).
+ The service's resolver is **extended** from the single path-only pointer to a per-request
+ owner lookup keyed on that header. Absent the header, the request resolves **local**
+ (fail-safe), never to an arbitrary attached target.
+
+Both legs — the engine router's key extraction and the app SDK call sites (the Preview
+`serverSDK` write in particular, which today carries **neither** a session id **nor** a
+directory) — are real changes and are explicit acceptance criteria of the multiplexer slice
+(PRD slice 3), not an assumed property of the existing plane.
+
+### §D2 — Fleet-wide session list, machine-badged, decoupled from focus
+
+A **session fan-out** reads `GET /session` from every `serving` roster peer over the
+heartbeat transport idiom (SSH `ssh curl ` / HTTPS `peer_origin`),
+authenticated with that peer's peer token (ADR 0032), and tags each session with
+`{ owner machine, directory }`. The app's session surfaces (the titlebar Sessions dropdown
+and the Chats rail) render the **fleet-wide** list, each row **badged by machine**. Session
+ids are globally unique, so merge-and-badge is safe (unlike folder-named surfaces — §D4).
+The list is **decoupled from the sidebar focus** (§D4): it always shows all machines, so
+cross-machine monitoring survives; a separate, optional per-machine **filter** is offered.
+
+### §D3 — Home base local; only sessions and their files are fleet-routed
+
+Identity and knowledge stay on **this** machine, always: the researcher profile, the vault
+mounts, the Library. Only a **session's runtime** (its agent, tools, `cwd`) and its
+**Work Column** (Files-changed, Preview, Context tree — which describe where it actually
+runs) follow the owning machine. Mental model: *"my window, remote-controlling agents that
+live on other machines."* A remote session's Files-changed reflects the **owner's**
+filesystem while the Vault panel reflects **yours** — those can legitimately disagree, and
+that is correct, not a bug.
+
+### §D4 — Focused machine: the sidebar working-surface selector
+
+A new UI concept, the **focused machine**, governs the *working* surfaces. The FLEET
+sidebar section becomes a single-select **focus selector** (default: this machine).
+Research, Development, and the Workspace browser show **only the focused machine's** roots.
+Focus is set by clicking a device in FLEET **or** by focusing a session tab (→ that
+session's owner, with the session's folder revealed in the now-focused view). A
+**project-roots fan-out** lets each machine report its Research/Dev roots so the sidebar
+can render a remote machine's projects. Collisions (two machines with an `amicode` repo)
+never arise: only one machine's roots are shown at a time, and the focus header names it.
+
+This **amends ADR 0026**: the fleet section gains a navigation/focus action. It writes **no
+roster row** — ADR 0026's read-only-roster invariant holds; focus is a UI selection, not a
+roster mutation. Working-surface focus is distinct from, and never re-homes, the local
+identity/knowledge of §D3.
+
+### §D5 — Fleet-wide workspace browser via `amico-host://`
+
+The `amico-host://` FileSystemProvider (the thin-client Explorer, single-host today) is
+**generalized to a per-machine authority** — `amico-host:///…` — routing by
+that authority to each peer's file client. The focused machine's workspace is browsable in
+the Explorer. For humans it is **read/review only**; its write routes stay unbuilt (issue
+#1267) — see §D7.
+
+This is **not** a small reuse of the existing routes: the current host client is built on
+the **vault-browser** contract (`/amicode/vaults`, `/amicode/vault-files`,
+`/amicode/vault-file`), which models *mounts* and reads **text only** (binary reports
+`not_text`). Extending it to arbitrary *workspace roots* is new host routes + provider
+re-keying + binary support. It therefore splits into (a) per-machine authority routing over
+the existing read contract, and (b) the workspace-root and binary read extensions —
+**binary is a follow-up**, not v1.
+
+### §D6 — Session provenance lives in the session header, not the titlebar
+
+The **session header** — the sticky session-title bar at the top of the Chat timeline
+(which already hosts the title, the context-usage ring, and Compact) — is the home for
+session provenance:
+
+- a **computer icon** to the left of the title on **remote** sessions (hover → machine
+ name); absent on local sessions (absence = local);
+- a **provenance caret** next to the title opening a menu: **Machine · Workspace · Branch**
+ (+ Open folder / Copy path). A remote session's branch/worktree is a git-status read
+ routed to the owner via the multiplexer.
+
+The **titlebar is left as implemented** (tabs, `+`, and the portalled Sessions/Status/
+Side-Panel controls stay). There is **no per-tab machine glyph** — machine denotation lives
+in the session header (active session) and the fleet-wide list badges (all sessions).
+For a **new** session, the machine picker is the **first** control in the composer's
+selector cascade — Machine ▸ Project ▸ Worktree/branch — defaulting to the focused machine,
+overridable to any machine.
+
+### §D7 — Remote workspace file mutation
+
+| How | Edit existing file content | Create / delete / rename files & folders |
+|---|---|---|
+| **Agent** (in a session) | ✓ in-place on owner | ✓ in-place on owner |
+| **Preview tab** (engine `file.write`, multiplexed to owner) | ✓ in-window, as if local | ✗ (content editor, not a file manager) |
+| **Native Explorer** (`amico-host://`) | ✗ read/review only (#1267) | ✗ read/review only |
+| **Remote-SSH** window | ✓ native | ✓ native |
+
+In-window human editing of remote file **content** is available through the **Preview
+tab**, which writes via the opencode engine `file` API — the *same engine plane the agent
+uses*, so it is governed by the same peer-trust credential and accept-set (ADR 0032) and
+needs no `#1267` host-FS write route. **Two honest caveats** the design must carry:
+
+- It is **not free routing**: the engine `file` API is path-less, so it depends entirely on
+ the owner-routing key of §D1a. "Same plane the agent uses" governs *authorization*, not
+ *routing*.
+- That plane permits **arbitrary absolute-path writes** on the owner (the engine handler
+ mkdir-creates files outside the workspace). So the "content editor, not a file manager"
+ containment is a **UI convention, not a plane property**, and a peer token therefore
+ confers **arbitrary-path write** on the owner (shell-equivalent) unless constrained.
+ Decision: the multiplexed **human** write path (Preview) is **gated server-side to
+ workspace-relative paths** (reject `isAbsolute`); the broader "no new blast radius" claim
+ is corrected to: *a peer token's write reach equals the agent's today, which is
+ arbitrary-path — so the human Preview surface is deliberately narrowed below it.*
+
+Human **structural** operations (new/delete/rename, folders) go to the agent (in-place) or
+Remote-SSH. Building a host-FS write plane over `amico-host://` (call it Option B) is **out
+of scope** — the Preview tab covers in-window content editing without it.
+
+### §D8 — Thin-client and Remote-SSH reframed as transport, not a choice
+
+The ADR 0030 "which connection mode?" Quick Pick is **removed**. In its place:
+
+- **Thin-client** stops being a user decision — it is the **background transport** the
+ multiplexer and the workspace browser use to reach peers; its sub-kind (SSH / tailscale /
+ direct) is per-machine config resolved by `fleet_transport.ts`, never a per-click prompt.
+- **Remote-SSH** is a **secondary, explicit per-device escape hatch** (a hover/overflow
+ action on the FLEET device row, mirrored in the provenance menu's *Machine* entry) for
+ native full-editor / structural / heavy work, and the **degraded fallback** when the
+ multiplexer cannot reach a focused machine in place. It opens a **separate VS Code
+ window** on the host (it inherently cannot be "backgrounded" — a window has one remote
+ authority), in which the extension runs on the host (a full second studio there).
+
+This **supersedes ADR 0030 §D2** and **reframes ADR 0025** (Remote-SSH is the escape
+hatch/lifeboat, not the default posture).
+
+**Disposition of ADR 0030 §D3–§D5** (so they are not silently orphaned): §D2's removal
+retires the "attach to view sessions" purpose, which §D1/§D2 now subsume. The single
+attachment pointer is retired **for session routing** (§D1a); its **boot-time recovery**
+(0030 §D3, already partly built) and **credential provisioning** (0030 §D5) are
+**subsumed by** the per-peer trust exchange (ADR 0032) and the per-session routing key
+(§D1a) — the recovered pointer no longer routes sessions. Fleet Studio keeps **global**
+`/amicode/*` reads (profile, problems, catalog) **local** (§D3, home base local), so no
+residual single-target pointer is needed for them either. Net: the 0030 pointer machinery
+is retired, not repurposed; the Remote-SSH resolver (0030 §D8) survives as §D8's escape
+hatch.
+
+### Staging (delivery order — foundation first, vertical thereafter)
+
+1. Peer-trust credential + accept-set (ADR 0032) — the readiness-gated `auth=open` close is
+ an explicit acceptance criterion.
+2. Fan-outs + fleet-wide session list + machine badges.
+3. Session multiplexer + SSE relay (routes session traffic **and** the engine file API).
+4. Focused-machine sidebar + fleet-wide `amico-host://` read/review browser.
+5. Session-header provenance + new-session machine picker + Preview-tab remote editing +
+ Remote-SSH escape hatch.
+
+## Approaches considered
+
+- **Keep per-device window swap (ADR 0030 as-is)** — rejected: it is the exact pain the
+ researcher named; one machine's sessions visible at a time, a reload to switch.
+- **Native multi-origin switcher (the app talks to N servers directly)** — rejected for the
+ data plane (as ADR 0027 already rejected): it bypasses `/amicode/*` ownership, credential
+ translation, and the single-origin CSP/auth the accept-set depends on.
+- **Single-target, seamless-switch (live re-target, one machine live at a time, #1353)** —
+ rejected as the target: it cannot stream a solve on one machine while you work on
+ another, which is the defining Amicode use. Kept only as the interim open behavior until
+ the multiplexer lands.
+- **Full host-FS read/write plane over `amico-host://` (Option B)** — rejected for now: the
+ Preview tab already gives in-window content editing on a governed plane; Option B is a
+ broader, security-sensitive write surface whose only added value is native-Explorer human
+ structural edits, which Remote-SSH and the agent already cover.
+- **Per-tab machine glyph** — rejected: it would modify the titlebar tab strip, which the
+ researcher chose to leave untouched; the session header + list badges denote machine.
+
+## Invariants held
+
+- **Single app origin (ADR 0027).** The app never opens N origins; the local service
+ multiplexes.
+- **One-parser `fleet.json` (ADR 0023).** Everything new is amicode-owned (routing map,
+ fan-outs, focus state).
+- **Read-only roster (ADR 0026).** The focus selector writes no roster row.
+- **Local honesty surface (ADR 0027 §4).** `/amicode/fleet/*` and the posture surfaces stay
+ local and never proxy.
+- **No silent local fallback.** A remote read/write that cannot reach its owner surfaces the
+ honest degraded outcome (HubDown / Unavailable), never a fabricated success or a local
+ read (the `host_file_client` guarantee).
+
+## Consequences
+
+- The fleet becomes one studio: every session and workspace is visible and (per §D7)
+ workable in one window.
+- Sessions gain durable, visible machine identity (badge + provenance header) — the
+ researcher's original ask.
+- The single attachment pointer is retired for session routing; routing is per-session.
+- Remote-SSH's role narrows to an explicit escape hatch + fallback; thin-client becomes
+ invisible transport.
+- New substrate seams are made live that ADR 0027 reserved: the multiplexing service, the
+ per-attachment transport (now per-peer, read + interactive), and the event-relay seam.
+
+## Non-goals
+
+- The Horizon-2 compute-federation executor (server-to-server dispatch RPC, placement
+ scheduler, artifact sync-back) — still ADR 0027's non-goal.
+- A host-FS write plane over `amico-host://` (Option B, §D7).
+- Multi-target *attachment* of the whole panel (routing is per-session; the panel is not
+ "attached" to two machines).
+- Moving a running session between machines (a migration, not in scope).
+
+## Source
+
+Design-of-record: the Fleet Studio PRD (drafted from the `brainstorming` → `grill-with-docs`
+session of 2026-09-22). Paired trust ADR: 0032.
diff --git a/docs/adr/0032-peer-trust-credential-and-accept-set.md b/docs/adr/0032-peer-trust-credential-and-accept-set.md
new file mode 100644
index 000000000..558ec2e85
--- /dev/null
+++ b/docs/adr/0032-peer-trust-credential-and-accept-set.md
@@ -0,0 +1,210 @@
+# ADR 0032 — Peer-trust credential and the accept-set: replacing server `auth=open`
+
+- **Status:** proposed (review: manual adversarial critics — `amico spec review` tooling absent on this machine; see the design-of-record PRD for the recorded review round)
+- **Date:** 2026-09-22
+- **Context refs:** ADR 0002 (per-boot server password), ADR 0005 (managed fleet / Fleet
+ token), ADR 0026 (host-owned roster), ADR 0027 §7 (the **declared, not-built** peer-trust
+ credential — D10b — **promoted to built here**), ADR 0031 (Fleet Studio — the consumer),
+ `#955` (the `auth=open` service posture on servers), `#1267` (the host write-route gap).
+
+## Context
+
+Today the fleet's trust boundary is the **transport**, not an application token. On a
+`server`, the amicode service runs with **`auth=open`** behind the SSH/tailscale tunnel
+(`#955`), and the engine itself validates a **single per-boot password**. The **Fleet
+token** (ADR 0005) is a **shared secret** — every machine authenticates to every peer with
+the same value — which means distrusting one machine requires **re-minting and re-enrolling
+the whole fleet**. There is no per-machine revocation, no least-privilege scoping, and no
+attribution: to a peer, every caller looks identical.
+
+ADR 0027 §7 anticipated this and **reserved** — but explicitly did **not build** — a
+per-peer, separately-mintable and separately-**revocable** *peer token*, keyed by
+`machine_id`, as the distinct trust act for peer-to-peer engine access (deliberately *not*
+the Fleet token, which guards *enrollment*, and *not* the UI client mint, which authorizes
+*a human's window*).
+
+Fleet Studio (ADR 0031) makes this necessary now: it drives **full session interaction**
+(read + write + tool execution) across every serving peer. The researcher chose to build
+the real thing — per-peer revocable trust, validated at the boundary — rather than reuse
+the shared secret.
+
+## Decision
+
+### §D1 — The peer-trust credential
+
+Each **serving** machine mints a **per-peer, separately-revocable token** granting access
+**to itself**, keyed by the requesting machine's `machine_id`, and records each issuance in
+a **revocable issued-token registry** (`~/.amico/fleet-peer-tokens.json`, `0600`, atomic
+via the shared credentials writer). Revocation = drop the registry entry. This is the
+credential ADR 0027 §7 (spec D10b) reserved — **not** the Fleet token, **not** the UI client
+mint. The **reader** side stores each target's token in a **dedicated peer-token store**
+(mirroring the minter registry), **not** the existing `attachment_credential.ts` store —
+whose own header explicitly disclaims the peer-trust identity ("NOT the Horizon-2
+peer-trust identity … explicitly NOT built here"). Overloading one store with two
+credential kinds of different lifecycle risks a revoke clearing the wrong entry; the two
+stores stay distinct (or `attachment_credential.ts` is generalized *and its contract note
+updated* so the kinds are distinguishable at read time).
+
+### §D2 — The accept-set replaces full `auth=open`
+
+The server's `/amicode/*` + engine boundary stops being fully open and instead validates
+every caller against an **accept-set**:
+
+```
+accept ⟺ caller presents (local service/engine mint)
+ OR (a non-revoked peer token from the issued-registry)
+ OR (the transitional hub credential) [until §D3 close]
+ OR (the enrollment nonce — the /…/peer-token MINT ENDPOINT only, §D4)
+```
+
+Every accepted credential is thus a **named** accept-set member — including the mint
+endpoint's enrollment nonce, enumerated here so the mint-honesty invariant below holds
+literally rather than by prose exception. The framed app authenticates with the local mint
+(its `?auth_token=`), so it is unaffected.
+
+**Two boundaries, one accept-set.** Peer traffic terminates at the loopback port the
+transport forwards to; whichever process owns that port — the extension `amicode_service`
+(`authorized()`) and/or the vendored engine's per-boot-password middleware — **both**
+implement the *identical* accept-set. A partial rollout (one boundary flipped, the other
+not) is the failure this clause forbids: an anonymous request to **each** boundary must be
+refused after close (an explicit AC).
+
+Validation is **constant-time and per-request**: the peer-token check iterates the issued
+registry with a constant-time compare and no early exit (preserving the existing
+timing-safe discipline), and reads the registry fresh enough that a revocation takes effect
+on the immediately following request (write-through to any in-memory accept-set, or a
+per-request read).
+
+### §D3 — Staged rollout: additive first, then a readiness-gated close
+
+The posture change ships in two steps so a half-migrated fleet can **never lock itself
+out**:
+
+1. **Additive** — the boundary begins **accepting** peer tokens *in addition to* `auth=open`.
+ Nothing breaks; peer-token auth starts working.
+2. **Close** — the boundary flips to **require** an accept-set member, gated on a
+ **precise, single-vocabulary, locally-observable readiness predicate**:
+
+ > Resolve every roster row with `capabilities ∋ serving` **and** `health = reachable` to
+ > its `machine_id`. Close is permitted **iff**, for every such `machine_id`, **both** this
+ > machine's issued-token registry **and** this machine's reader peer-store contain a
+ > non-revoked entry. A serving row whose `machine_id` cannot be resolved **refuses** close
+ > (never satisfies it vacuously). A serving peer not yet in the roster likewise **blocks**
+ > close (it is not stranded — it is waited for).
+
+ Both sides of the comparison are keyed on **`machine_id`** (the roster's alias/`peer_origin`
+ is resolved to `machine_id` first) — never a mixed-vocabulary join. The predicate is
+ **local**: it reads only this machine's two stores, so "closeable here" is a fact this
+ machine can prove without querying peers. (Fleet-wide mutual reachability is achieved by
+ *each* machine gating its own close on the same local predicate — not by one machine
+ asserting the whole mesh.) At close, the **transitional hub credential is withdrawn** in
+ the same step — after close, only the local mint and non-revoked peer tokens are accepted
+ (the mint endpoint's enrollment nonce excepted, §D4).
+
+Until the gate passes, the posture stays additive-open. A **documented rollback** re-opens
+the additive posture if a peer is stranded post-close. Both the readiness gate and the
+rollback are explicit acceptance criteria of the slice, not afterthoughts.
+
+### §D4 — Distribution: mutual exchange at enroll + heartbeat reconcile
+
+Peer tokens are exchanged **mutually at `amico fleet enroll`** and **reconciled on the
+heartbeat** when a new serving peer appears (via the existing `pushToPeers` transport
+dispatch), so the fleet converges with no manual step. Token material is exchanged **only
+over an authenticated, encrypted transport** (SSH / tailscale); a plaintext `direct` hop is
+refused for token exchange.
+
+**Bootstrap without a standing skeleton key.** A joining machine obtains its first peer
+token via a **short-TTL, single-use enrollment nonce** minted for that specific join — *not*
+the standing Fleet token. This closes the revocation hole: if the bootstrap were the
+long-lived, machine-scoped Fleet token, a **compromised** machine could simply re-mint a
+fresh peer token after `revoke`, defeating containment (the exact case §Consequences
+names). The mint endpoint therefore (a) accepts only an unexpired, unused enrollment nonce,
+and (b) **refuses to mint for a `machine_id` that appears on any peer's revocation list** —
+so revoking a machine also bars its re-enrollment until an operator explicitly re-admits it.
+
+### §D5 — Revocation and rotation surface
+
+`amico fleet revoke ` drops the entry (a revoked token 401s on its next
+request; the reader surfaces that peer distinctly as **revoked** — a `NoPermissions`
+outcome, never conflated with an unreachable peer's `HubDown`, so a suspected compromise
+reads as revocation, not an outage). A Fleet Manager control mirrors it.
+
+**Fleet-wide expulsion is a fan-out, not one write.** Because a machine is the single writer
+of the registry granting access **to itself** (invariant below), expelling machine X means
+**every** serving peer drops *its own* issued entry for X. `amico fleet revoke `
+therefore **fans the revocation out** to each peer (each peer applies it to its own
+registry; no machine writes another's), and X is added to the revocation list §D4's mint
+endpoint consults. The Consequences headline is read accordingly: "revoke a machine" is a
+fan-out of per-registry drops, not one atomic entry deletion.
+
+Rotation is **on demand / on re-enroll**; a scheduled rotation is not built for v1
+(revocation, not rotation cadence, is the load-bearing property).
+
+### §D6 — Scope: full session interaction, shaped for reuse
+
+The peer token grants **full session interaction** on the owner's engine — the *same plane
+the agent already uses* (`file.read`/`file.write`, prompts, tools, SSE). Its write reach is
+therefore the agent's: **arbitrary-path write** on the owner (the engine handler
+mkdir-creates outside the workspace). Fleet Studio's *human* Preview write path is
+deliberately narrowed **below** that by a server-side workspace-relative gate (ADR 0031
+§D7); the peer token itself is not so narrowed, so a leaked peer token is shell-equivalent
+on its owner — state it plainly rather than as "no new blast radius."
+
+The minted token carries an explicit **scope claim** shaped so the Horizon-2 engine↔engine
+RPC (ADR 0027 §7) can reuse the same credential without re-plumbing. **In v1 the scope claim
+is recorded but inert** — the accept-set (§D2) checks membership only and does **not** read
+it, so every non-revoked peer token grants the full plane regardless of the claim (the same
+"declared-but-inert" posture ADR 0026 gives the `compute` tag). Least-privilege is a
+**future** capability the claim enables, not a v1 control.
+
+## Approaches considered
+
+- **Reuse the Fleet token for peer RPC** — rejected (ADR 0027 §7 already rejected it): it
+ conflates enrollment trust with peer-interaction trust and has no per-machine revocation.
+- **Transport-layer revocation only (deauthorize the SSH key / tailscale ACL), keep
+ `auth=open`** — rejected: ADR 0027 §7 specifies an **app-layer** peer token as a distinct
+ trust act; transport-layer revocation lives outside Amicode (ssh/tailnet config) and
+ cannot express least-privilege or attribution.
+- **Build the token but defer the accept-set close** — considered as a smaller first cut;
+ rejected as the *end state* because without the close, revocation is not enforced at the
+ boundary. Retained only as the additive first step of §D3's staged rollout.
+
+## Invariants held
+
+- **Loopback bind + credentialed upstream (ADR 0002).** Every peer hop stays credentialed;
+ the accept-set is the credential check, not a new listener.
+- **Local honesty surface (ADR 0027 §4).** `/amicode/fleet/*` and posture surfaces stay
+ local; the accept-set never gates them behind a *peer* token (a machine must always be
+ able to report its own posture) — but an **external** caller still needs the local mint,
+ so "not gated behind a peer token" is not "anonymously readable" after close.
+- **No silent mint fallback (the D5 mint-honesty rule).** Every accepted credential is a
+ named accept-set member; an unrecognized token is refused, never silently allowed.
+- **Single-writer registry.** A machine is the sole minter/revoker of tokens granting access
+ to itself.
+
+## Consequences
+
+- Losing/retiring/compromising one machine → **revoke it** (a fan-out of per-registry drops
+ plus a mint-list bar, §D5), no fleet-wide re-enroll. Because the bootstrap is a single-use
+ nonce and a revoked `machine_id` is barred from re-minting (§D4), revocation **contains a
+ compromise** rather than being re-mintable around.
+- The peer relationship is **attributable** (per-machine token, not a shared secret).
+ Least-privilege is **enabled but inert in v1** (the scope claim is recorded, not enforced —
+ §D6).
+- A leaked peer token compromises **one** peer relationship — but on that owner it is
+ **shell-equivalent** (arbitrary-path write, §D6), so it is not a small credential; the
+ human Preview path is narrowed below it (ADR 0031 §D7), the raw token is not.
+- Servers stop being fully `auth=open` — a **security-invariant change** with fleet-wide
+ reach, which is why the close is readiness-gated and rollback-documented (§D3).
+- Horizon-2 engine RPC inherits this credential unchanged.
+
+## Non-goals
+
+- Scheduled/automatic rotation cadence (revocation is the property that matters for v1).
+- The Horizon-2 compute-federation executor itself (ADR 0027 non-goal).
+- A host-FS write plane over `amico-host://` (ADR 0031 §D7 non-goal).
+
+## Source
+
+Design-of-record: the Fleet Studio PRD (2026-09-22). Consumer ADR: 0031. Promotes ADR 0027
+§7 (D10b) from *declared* to *built*.
diff --git a/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md b/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md
new file mode 100644
index 000000000..a440b020c
--- /dev/null
+++ b/docs/adr/0033-fleet-studio-per-session-sse-fanout-and-resume.md
@@ -0,0 +1,112 @@
+# ADR 0033 — Fleet Studio: per-session SSE fan-out and lossless resume
+
+- **Status:** **accepted** (design gate #1450 resolved by human review 2026-09-24 — D1–D4 accepted as-is; the three open questions resolved A / A / B, see "Decision record" below; unblocks ONE implementation issue behind `AMICO_FLEET_MULTIPLEX`)
+- **Date:** 2026-09-23 (accepted 2026-09-24)
+- **Context refs:** ADR 0031 (Fleet Studio in-window multi-machine sessions — §D1 retires the single attachment pointer, which W1b/#1449 implemented), ADR 0027 (§7 event-relay seam — **consumed here**; single-origin invariant — **preserved here**), ADR 0032 (peer-trust credential — the per-peer auth this relay will need, see Open Questions), #1264 (global lossless-reconnect cursor — **generalized here**).
+- **Supersedes/blocks:** unblocks a future *implementation* issue for per-session SSE fan-out, to be opened only after this design is reviewed.
+
+## Context
+
+W1a (#1448) put the session multiplexer into dispatch behind `AMICO_FLEET_MULTIPLEX` (default OFF), exposing only `resolveTarget` — the AC4 structural guard forbids any SSE crossing the multiplexer. W1b (#1449) made per-session **request + upgrade** routing real: an owned session's non-stream requests route to `resolved.url`, an owned-but-unreachable peer degrades to a `FLEET_PEER_UNREACHABLE` 503 (never a silent local fallback, the #1382 invariant), and the `SessionOwnerMap` is fed by a timer.
+
+**What is still missing is the event stream.** "Interact with a peer's session as if local" is not real until the peer's *events* reach this window. The existing pieces do not compose:
+
+1. **Wrong stream.** The app's live feed is a **single global `/event` stream** opened once per window (`packages/app-bundle/overlay/packages/app/src/context/server-sdk.tsx:254-277`). The multiplexer's `openSseStream` (`packages/extension/src/amicode_service/session_multiplexer.ts:284-357`) connects instead to a peer's **per-session** `/api/session/{id}/event`. Nothing bridges per-session peer streams into the one global stream the app actually consumes.
+2. **No `res` adapter.** `openSseStream` returns an in-memory `SseStreamHandle` (`next()`/`close()`) designed for a test to pull events one at a time. It is **not** an HTTP response writer — it cannot emit SSE frames onto the global `/event` `res` the app is attached to.
+3. **`id:` is dropped.** The relay parses only the `data:` line (`session_multiplexer.ts:323`) and discards the `id:` line. The app's lossless reconnect (#1264) depends on the server id-ing every event and the client tracking `lastEventID` (`server-sdk.tsx:243-253`). A relay that drops `id:` silently breaks reconnect for peer-owned sessions.
+4. **A single scalar cannot track N peers.** The client cursor is one window-scoped string persisted in `sessionStorage` (`CURSOR_KEY = "amicode.sse.lastEventID"`). Peer A and peer B each have their **own** monotonic event id-space; one scalar cannot encode where we are in N independent streams. Merging N id-spaces into one cursor is the unsolved core.
+5. **No local arm.** `openSseStream` only connects when a peer URL exists. A single-machine user (fleet-of-one, the common case) would get **zero events** if dispatch ever routed the global stream through the multiplexer. The local machine's own event source must remain the spine.
+
+The single-origin invariant (ADR 0027) holds: the app keeps exactly one server connection. The question is what happens *behind* that origin.
+
+## Decision
+
+**Aggregate at the origin; keep the app on its single global `/event` subscription; make the cursor a namespaced composite.** Concretely, the origin's multiplexer fans **in** each owned session's peer event stream and the local event source into the one global `/event` response the app already reads. Four mechanisms, each independently testable:
+
+### D1 — Stream inversion: fan-in, not per-session subscription
+
+The app does **not** move to N per-session subscriptions. It keeps its single global `/event` GET. On the origin, the handler for `/event` becomes a **fan-in aggregator**: it opens (a) the local event source and (b) one upstream SSE connection per **reachable owner peer** that owns at least one session in the current `SessionOwnerMap`, and writes all of their frames onto the single downstream `res`. Peers are added/removed as the owner-map timer changes the owned-peer set. An unreachable owner contributes no frames and is surfaced honestly (a `FLEET_PEER_UNREACHABLE`-style comment frame, not a silent gap — mirrors the W1b request-path invariant).
+
+*Rationale:* preserves the single-origin model and the app's one-cursor client with the smallest app-side change; per-session subscription (the rejected alternative, see below) multiplies connections and rewrites the client's whole reconnect model.
+
+### D2 — `id:`-verbatim, namespaced relay + a real `res` adapter
+
+Replace the `data:`-only parse with a **frame-preserving relay**: read each upstream SSE frame and re-emit it onto the downstream `res` with its `event:`/`data:`/`retry:` lines intact, and its `id:` line **namespaced by owner**: `id: \u001f` (unit-separator-joined; the local arm uses a reserved `local` namespace). Introduce an `SseStreamHandle → res` adapter (a `pipeToResponse(handle, res)` that writes real SSE bytes and flushes), so the relay drives the actual global-stream response rather than a test's `next()`. The `data:` payload is passed through unmodified except for the existing `session_id` attribution tag, so the reconcile-based reducers are unaffected.
+
+### D3 — Per-peer cursor set (a composite cursor, parsed at the origin)
+
+Generalize #1264's scalar. On reconnect the client still sends **one** `?lastEventID=` (no client change beyond value opacity), but that value is a **composite** the origin parses into a per-namespace map: `local=;=;=`. The origin re-subscribes each upstream with **its own** resume param (`/api/session/{id}/event?lastEventID=` or the peer's equivalent) and replays the local arm from ``. The client's `trackEventID` (`server-sdk.tsx:243`) keeps overwriting the whole composite string as the latest namespaced `id:` arrives — the origin is the only component that needs to understand the composite's structure. Over-delivery on replay remains tolerated by the reducers, exactly as #1264 already assumes.
+
+### D4 — Local arm always present
+
+The aggregator **always** includes the local event source, whether or not any peer is owned. Fleet-of-one → the aggregate is byte-identical to today's single local `/event` (one namespace, `local`), so single-machine users are unaffected — this is the invariant the implementation's first test must pin.
+
+## Test plan
+
+Unit-testable on a single host (no fleet required):
+- **D2 relay:** feed a synthetic upstream frame sequence (with `id:`, multi-line `data:`, comments, `retry:`) through the relay; assert the downstream bytes preserve every line and namespace only the `id:`. Assert `pipeToResponse` writes well-formed SSE and flushes per frame.
+- **D3 cursor:** round-trip `format(parse(composite)) === composite`; assert a reconnect with a composite cursor issues the correct per-namespace resume params (spy the upstream opener); assert an id-less frame does not advance its namespace (matches #1264).
+- **D4 local arm:** with zero peers, assert the aggregate stream equals the local stream frame-for-frame (fleet-of-one byte-identity) and that `?lastEventID=local=` resumes the local arm.
+- **D1 fan-in membership:** drive the `SessionOwnerMap` timer; assert peers are added/removed from the upstream set as owned-session ownership changes, and that an unreachable owner emits the honest comment frame rather than a silent gap.
+
+Requires a real fleet (deferred, human-in-the-loop, needs the Mac Studio):
+- Actual cross-machine event delivery end-to-end; per-peer **authentication** of the upstream SSE connection (see Open Questions); reconnect across a real peer flap.
+
+## Alternatives considered
+
+- **Move the app to per-session subscriptions (N streams).** Rejected as primary: multiplies connection count, discards the single global-cursor model, and is a large app-side rewrite for the same observable behavior. Kept as the fallback if origin-side fan-in proves to have unacceptable head-of-line coupling.
+- **One cursor, last-writer-wins (ignore N id-spaces).** Rejected: silently loses the reconnect gap for all but one peer — reintroduces exactly the data-loss #1264 fixed.
+
+## Constraints & invariants
+
+- No SSE crosses the multiplexer until this design is reviewed and its implementation issue lands behind `AMICO_FLEET_MULTIPLEX` (still default OFF). The AC4 guard (`resolveTarget`-only) stays until then.
+- W1a/W1b must not regress the existing global lossless-reconnect cursor for the flag-OFF / fleet-of-one path — D4's byte-identity test is the guard.
+
+## Open questions (for the reviewer)
+
+1. **Per-peer auth (ADR 0032).** `ResolvedTarget` carries no token today; W1b's request path reuses the attached hub-mint credential (H1). The upstream SSE connections in D1 need per-peer credentials (H2) before this is real cross-machine. Does the fan-in aggregator read the peer token from the peer-store per upstream, or is a hub-mediated relay credential preferable?
+2. **`GET /amicode/fleet/focus` snapshot.** W4b (#1453) noted the machine picker cannot seed on a cold webview because focus is a best-effort push with no read-back. If this design adds a fleet-state channel, exposing current focus on connect would close that gap cheaply — worth folding in or keeping separate?
+3. **Head-of-line / back-pressure** across N upstreams sharing one downstream `res`: acceptable at expected fleet sizes (2–4 machines), or does D1 need per-namespace buffering bounds?
+
+## Decision record (human review, 2026-09-24)
+
+The reviewer accepted **D1–D4 as-is** and resolved the three open questions with **robustness under a poor network connection as the stated priority**. The core resume mechanism (D2 `id:`-verbatim relay + D3 composite cursor) is the lossless-reconnect foundation and stands unchanged.
+
+1. **Per-peer auth → Option A: the aggregator reads each peer's token from the peer-store per upstream.** Each upstream SSE authenticates *as itself* (H2), matching the independent-peer topology the rest of Fleet Studio adopts (#1477–#1487). Rejected Option B (hub-mediated relay credential): it makes the hub a single trust *and* connectivity chokepoint — a degraded link to the hub would kill every peer stream at once. With A, peer connections fail independently: one dark peer never takes down another's stream or the local arm.
+
+2. **Focus snapshot → Option A: fold a focus-on-connect snapshot into the fan-in channel.** Under a flaky link the webview reconnects often; folding the current focus/picker state into the connect snapshot makes each reconnect **self-healing** (the cold-seed gap W4b flagged closes on every reconnect), rather than leaving a stale picker after a drop. The snapshot is emitted as the first frame on the `local` namespace at connect/reconnect.
+
+3. **Back-pressure → Option B: per-namespace buffering bounds (the robustness-priority choice).** Each peer namespace gets its own bounded buffer on the shared downstream `res`; an overflowing (slow/flapping) peer degrades **in isolation** and resumes from its D3 cursor on recovery, instead of head-of-line-blocking the shared response and freezing every other session (including local). This is deliberately more machinery than the ADR's "defensible at 2–4 machines" Option A, chosen because the reviewer prioritized consistent behavior on a bad connection over v1 simplicity. Buffer-overflow drops are safe precisely because D3 replays the gap on reconnect — B and the composite cursor compose into graceful degradation + lossless catch-up.
+
+**Honest boundary:** this makes a bad connection *recover correctly* (no lost/duplicated events, no one peer freezing the rest), NOT *feel instant* — a peer on a poor link still lags and catches up on reconnect. The first implementation test remains D4's fleet-of-one byte-identity guard.
+
+## Source
+
+Part of #1436. W1c / #1450 (labeled `hitl`; the design gate). Blocked-by W1b (#1449, landed). This ADR is the design deliverable; the AC's "reviewed before implementation" gate is a **human** review — no implementation issue is opened by the overnight campaign.
+
+## Amendment 1 — base-observation fan-in is a separate, non-multiplexer path (2026-09-24, via ADR 0034 D6)
+
+ADR 0034 (base peer-studio control) pulls live event streaming onto the **observation path**, which raised an apparent conflict with this ADR: D-gate line 3 and the constraint at line 62 tie the fan-in to `AMICO_FLEET_MULTIPLEX` and forbid "SSE crossing the multiplexer until behind the flag," while ADR 0034 D6 wants a base-observation fan-in *not* behind the premium flag. This amendment resolves it:
+
+- The base-observation fan-in is a **new, separately-armed `/event` interception**, armed on **observation readiness** (holding `observe` on ≥1 reachable session-owning peer). It **reuses this ADR's mechanics as a library** (the `SseFanIn*` aggregator/relay/composite-cursor/per-namespace-buffering — D1–D4), but **not** the premium wiring.
+- It is **not behind the multiplexer** and **not** gated by `AMICO_FLEET_MULTIPLEX`. The existing premium `/event` fan-in (`server.ts`, `fleetMultiplexEnabled() && fleetPlane?.eventFanIn`) is **untouched**.
+- Therefore the "no SSE crosses the multiplexer until behind the flag" invariant (line 62) **still holds** — because this path does not cross the multiplexer at all. The flag remains the gate for the *premium/armed* fan-in only.
+- The fleet-of-one **byte-identity** invariant (D4 / #1264) is preserved by the aggregator's **zero-peer verbatim relay** (the §D4 path writes local frames byte-for-byte when no peer arm is active), and holds only when no focus-snapshot provider is installed (the aggregator emits a focus frame ahead of the local arm when one is). This is the first implementation test for the base-observation path. (Superseded detail: an earlier draft said the driver *declines at zero non-local owners*; #1565 made `handle()` always-accept so a peer can join mid-stream, and byte-identity is now the aggregator's verbatim relay, not a decline to the proxy.)
+
+Net: two independently-armed fan-in paths sharing one library — premium (flag + `fleetPlane`) and base-observation (readiness) — neither behind the other's gate.
+
+## Amendment 2 — the backpressure contract the original design under-specified (2026-09-28, #1617)
+
+Open question 3 and Decision-record item 3 chose **per-namespace bounded buffering (Option B)** for back-pressure, but under-specified the flow-control that arms and *re-arms* it. The relay implementation replaced the transparent engine proxy — whose one-line stream `pipe` managed backpressure for free — with a hand-rolled fan-in whose `flowing` flag flipped false on a `write()`-false but **never came back**: the aggregator's `resume()` (the only thing that re-arms `flowing` and drains the buffers) had **no production caller** — its sole call site was a unit test that faked the drain by hand. One large frame (a shell command or a write/edit tool call) tripped backpressure and the relay wedged: every subsequent frame — including the `session.status: idle` that ends a turn and clears the rail — was buffered indefinitely, healing only on a manual window reload. On `main` (no interception) the same stream fell straight through to the transparent `pipe`, so the wedge was structurally impossible. It bit **local** sessions because the fan-in always owns `/global/event` (#1565) and composite mode routes the local namespace through the same `flowing`-gated path once any peer is active.
+
+The contract this amendment adds (the two halves the transparent `pipe` gave implicitly, re-derived by hand):
+
+1. **Downstream `drain` → `resume()`.** The fan-in driver wires the downstream response's `drain` edge to the aggregator's `resume()`, so `flowing` re-arms and the per-namespace buffers flush **in arrival order**. This is the primary fix; the wedge needs *no overflow* — a single trip-frame plus one stranded frame behind it reproduces it.
+2. **Upstream pause/resume.** `SseFrameSource` gains optional `pause()`/`resume()`; the aggregator gains an `onBackpressure` callback the driver wires to **pause every live upstream** while the downstream is backpressured, resuming them on `drain`. This keeps the bounded per-namespace buffers from overflow-dropping — the overflow is otherwise **unrecoverable**, because the live global event route has no replay cursor (unlike a reconnect, which D3 resumes).
+
+Defense-in-depth for any *future* silent stall past the buffer bound:
+
+3. **The `amicode.sync.gap` frame.** When `resume()` observes `dropped(ns) > 0` for any namespace, it emits **exactly one** id-less synthetic SSE frame naming the affected namespaces and zeroes those drop counters. It is emitted **at resume time, never into a backpressured sink** (a signal written while the sink is full would itself buffer). Two **hard shape requirements** for the client interception (both met by the modeled `amicode.fleet.focus` frame): it MUST carry a `data:` line (the SSE parser drops a data-less frame) and its yielded JSON MUST NOT have a top-level `payload` key (the client routes on `"payload" in event`; a `payload`-less bare-JSON frame takes the non-legacy adapter path where the gap/focus type match lives). Being id-less, it never advances a namespace cursor.
+4. **Client forced bootstrap + broadened rail self-heal.** The client intercepts the gap frame where it intercepts the focus frame and drives a **forced bootstrap refetch that bypasses the #1289 reconnect-storm debounce** (the debounce keys on `server.connected`; the gap rides a distinct type in the unconditional-refetch branch). The rail self-heal (`shouldRefetchOnReconnect`, which fires only on the stream-status `false→true` edge) is broadened with `shouldRefetchOnResync` on a monotonic resync token bumped on the gap — because a wedge keeps the socket **nominally connected** and never produces the disconnect→connect edge.
+
+Invariants preserved: the zero-peer aggregate stream stays **frame-for-frame identical to the local source** (the #1264 byte-identity guard, unchanged — the fleet-of-one path never enters the `flowing`-gated relay); no frame is reordered; a dropped frame leaves its cursor at the last **delivered** id so a future replay resumes the gap rather than skipping it; and **no engine-side change** — the live event route is untouched, the whole fix lives in the extension-host relay and the app client. A **single** `flowing` flag remains correct (one downstream socket, backpressured as a whole); the bug was that it never re-armed, not that it was shared. Narrowing the interception so a genuinely solo/local stream keeps the transparent `pipe` (declining in `handle()` when no peer arm is active) is a real blast-radius improvement but a **separate follow-up** — it does not fix composite mode (the actual failure) and would regress the mid-stream peer-join #1565 introduced.
diff --git a/docs/adr/0034-base-peer-studio-control.md b/docs/adr/0034-base-peer-studio-control.md
new file mode 100644
index 000000000..f71115e48
--- /dev/null
+++ b/docs/adr/0034-base-peer-studio-control.md
@@ -0,0 +1,98 @@
+# ADR 0034 — Complete the base peer-studio; reserve premium for managed autonomy
+
+**Status:** **accepted** (design review with JJ, 2026-09-24 — the grill-with-docs pass in the B2b brainstorm resolved the seven open decisions D1–D7 recorded below; the proposed sketch stands as the intent, the decision record makes it buildable). Blocks the B2b implementation issue.
+
+**Date:** 2026-09-24
+
+**Context refs:** ADR 0031 (Fleet Studio in-window multi-machine sessions — the independent-peer/keeper shape this completes), ADR 0033 (per-session SSE fan-in — **consumed here** for the live-events plane; its `AMICO_FLEET_MULTIPLEX` gate is reconciled in D6), ADR 0026/0028 (Roster, Device identity), ADR 0002/0005 (per-boot password, Fleet token). Substrate PRs: #1480 (identity-bound Observe bootstrap), #1482 (`ControlGatedResolver`), #1484 (`evaluateRemoteWriteGate`), #1486 (grant lifecycle state machine), #1537 (the observation read plane — the read half B2b extends).
+
+Fleet Studio is completed by base-enabling and finishing the dormant #1455/#147x slices, not by rebuilding them. Independent serving peers own their Sessions; a keeper/canonical coordinate owns roster and bootstrap authority. Existing N-peer projection, owner routing, provenance, focus, picker, and write-confirmation foundations remain the substrate. Base Amicode owns verified peer observation and explicit peer control; Amicissimo remains an additive tier for managed autonomy, governance, premium methods, tuned routing, and managed fleet infrastructure.
+
+Trust unlocks remote Session observation. A persisted, explicit per-peer Control grant unlocks interaction only after a mechanical readiness gate verifies stable identity, managed peer transport, bilateral token state, projection, owner routing, and SSE. The grant fails closed on revocation, identity change, transport loss, or disable; remote file writes remain per-action confirmed.
+
+## Considered options
+
+- **Keep the existing premium data-plane gate** — rejected: it prevents self-owned peer control while the underlying security primitives already live in base code.
+- **Rebuild Fleet Studio from scratch** — rejected: #1455 already landed the majority of the correct substrate; replacing it would waste tested work and create drift.
+- **Automatic control after roster discovery** — rejected: peer reachability is not authority.
+
+## Consequences
+
+- The work decomposes into base activation, peer lifecycle, peer transport/identity hygiene, SSE/interactive completion, and UI/E2E wiring (the slices in the Source section — five tracked sub-issues, plus docs that ride slice 1).
+- Existing premium staging becomes a managed-overlay policy mechanism rather than the sole route-mount gate for self-owned peer control.
+- The glossary reconciles its single-store Fleet language with the independent-peer/keeper model — see the `CONTEXT.md` `Fleet & serving` additions (Peer studio, Observe, Control, Enable control, Lifecycle-admin authority, Driving) that ship with this ADR.
+
+## Decision record (grill-with-docs, 2026-09-24)
+
+The B2b brainstorm resolved the following, in dependency order. Each decision is grounded against the substrate code cited.
+
+### D1 — Scope: control **and** live streaming, together
+
+B2b delivers remote prompt / archive / delete **and** live event streaming of the remote response. Rationale (the reviewer's, verbatim in spirit): a prompt you cannot watch land is not control. Live events are part of the control experience, not a follow-on — so ADR 0033's fan-in is pulled into B2b's boundary rather than deferred.
+
+### D2 — Path: extend the observation path into the base peer-studio (no premium arming)
+
+Control + streaming are added to the **observation path** (`baseStudioActivates`, `observationOnly`), not the armed premium multiplex. The observation path already carries grant-free reads (#1537). We keep those unchanged and add a write plane and a fan-in aggregator beside them.
+
+- **Rejected — arm the premium multiplex:** contradicts this ADR's own first rejected option, and undoes the reason the #1537 read fix exists (the observation path routes *around* the premium gate).
+- **Rejected — unify read + write onto `ControlGatedResolver`:** that resolver requires an active grant (observe scope minimum) or returns `unavailable`; unifying regresses the working **grant-free** read path the moment no grant exists (which is the default — the grant store is absent). Reads stay reader-token-based; only writes require a grant.
+
+Two trust levels, two mechanisms: **trust / reader-token unlocks Observe; an explicit `control` grant + per-action confirm unlocks writes.**
+
+**Grant-key correction (deliberate review):** `evaluateRemoteWriteGate` reads `grantReader(ownerMachineId)`, but `fleet_control_lifecycle` persists/reads grants keyed by **`requesterMachineId`**. In the self-owned case the controlling machine is the *requester* and the driven session's owner is the *target*, so a naïve `grantReader(ownerMachineId)` returns `undefined` and every write denies `no-control-grant`. Resolution: the write plane's `grantReader` resolves the controlling machine's own **`control`** grant by **`targetMachineId === ownerMachineId`** (a `LifecycleGrant` already carries `targetMachineId`) — i.e. it composes `readAllLifecycleGrants().find(g => g.targetMachineId === ownerMachineId && g.scope === "control" && g.state === "active")`, not a `requesterMachineId`-keyed lookup. #1541 delivers this owner/target-resolvable read and persists the issued grant (incl. its `token`) on the controlling machine; #1542 presents that `token` via its own `proxyToPeer` (see D6-adjacent note in #1542), **not** through `ControlGatedResolver`, whose adapter drops the peer credential today.
+
+### D3 — Establishment: self-owned fast-path + shared handshake; authority seeded at Enroll
+
+- **Self-owned peer** (the operator owns both machines): Control is armed by **one explicit "Enable control" act on the controlling machine**, authorized by *verified management access* established at Enroll. This is a **net-new** control-scoped bootstrap decision (`evaluateControlBootstrap`, structurally mirroring `evaluateObserveBootstrap`) — **not** an extension of `evaluateObserveBootstrap`, which *explicitly refuses* a control outcome ("there is NO control outcome here — Control scope is #1486; a natural Control seam is … deliberately NOT implemented"). *Verified management access* must be a **defined predicate**, not the bare `managementVerified` boolean the observe path takes on trust (that boolean is set true only in tests today): #1541 names the identity/transport/token check that establishes it. Not automatic (an explicit act; and re-armed each session), so "reachability is not authority" holds.
+- **Shared peer** (a different operator's machine): the full **request → approve handshake**, routed to whoever holds that peer's **Lifecycle-admin authority**.
+- **`lifecycle-admin` authority is NET-NEW persistence, seeded at `amico fleet enroll`:** today `authorityIdentityKey` exists *only* as an injected parameter to the pure `isLifecycleAuthority` predicate — there is **no store, no writer, no resolver**. #1541 therefore defines (a) a persisted authority record, (b) a writer invoked at Enroll that records the enroller as the target's authority, and (c) a resolver answering *who holds authority for machine X* from another machine. This is what makes a **headless** peer approvable — the approval act runs on a UI-bearing authority machine; the headless peer only **enforces** the presented token (`enforceScopeForRequest`) and never renders an approval prompt.
+
+### D4 — Confirmation: authorize at the service, confirm with the closest existing UI
+
+The write gate (`evaluateRemoteWriteGate`) **authorizes** (active `control` grant + reachable transport); **human-intent confirmation is rendered by the UI closest to the human**, so a write is never double-confirmed.
+
+- **Enable control** is the one net-new confirmation surface — a VS Code **native modal** — plus a persistent **"driving ``" banner** so control is never ambient. Per-session, re-armed (`fleet_headless_rehydration.ts` never auto-restores control).
+- **prompt** flows freely under enabled control (no per-message confirm).
+- **archive** takes **no discrete confirm** — it is reversible (an unarchive action sits beside it).
+- **delete** reuses the **existing local confirmation** (the dropdown's arm→confirm / the timeline's modal). Note the existing delete surfaces are **local-only** today (a peer/remote row renders no delete affordance, and the timeline delete calls the local SDK keyed on `sessionID`), so #1544 must *build* the remote-delete surface and route it by owner — "reuse" means reuse the arm→confirm *interaction*, over a new owner-routed action.
+
+**Confirmation is a consumer policy, not a gate output (deliberate review):** `evaluateRemoteWriteGate` returns `requiresConfirmation: true` for *every* remote write with an active grant — it does not branch on action-kind. So the gate is not "reused unchanged to get prompt-free / delete-confirmed"; instead the gate **authorizes**, and the **consumer applies confirmation policy keyed on the `action` the gate already carries** in `WriteConfirmationContext` (prompt → none under enabled control; archive → none; delete → the existing arm→confirm). The gate's `requiresConfirmation` flag is therefore *advisory metadata* the consumer interprets, not a mandatory second modal.
+
+**Reason-vocabulary source of truth (deliberate review):** three modules emit overlapping-but-different reason strings — notably `evaluateRemoteWriteGate` **collapses `revocation-pending` → `grant-revoked`**, while `remote_session_state` / `control_gated_routing` keep them distinct. The UI's single source of truth for the fail-closed chip is **`remote_session_state.ts`** (the only session-UI-state type); the raw write-gate reason is not surfaced directly.
+
+This is a deliberate reading of "remote file writes remain per-action confirmed": the per-session control-enable is the authorization for the prompt stream; discrete confirmation is reserved for the destructive, irreversible act (`delete`), rendered once by the UI that already owns it.
+
+### D5 — Fail-closed surface
+
+The UI honors the existing `read-only` / `unavailable` states (`remote_session_state.ts`, `control_gated_routing.ts`) with their named reasons (`no-control-grant`, `revocation-pending`, `transport-down`, `insufficient-scope`). Write affordances (composer→peer, delete, archive) are **disabled with a visible reason chip** when control is not held, with an **Enable control** (self-owned) / **Request control** (shared) affordance when eligible. **Never** a live button that 500s; **never** a silent local fallback (the #1382 invariant, already upheld on reads).
+
+### D6 — Live events on the observation path; `AMICO_FLEET_MULTIPLEX` reconciled
+
+ADR 0033's fan-in mechanics (D1 aggregator · D2 `id:`-namespaced relay · D3 composite cursor · D4 always-local arm · per-namespace buffering) are **reused as a library** (`SseFanIn*`) on a **NEW, separately-armed `/event` interception** on the observation path. This is *not* a re-gate of the existing fan-in: the only wired `/event` fan-in today is `server.ts` `fleetMultiplexEnabled() && this.fleetPlane?.eventFanIn` (premium flag + premium plane), and the observation branch deliberately wires none ("NO SSE fan-in — that stays flag-gated/premium"). The new interception is armed on **observation readiness** — *holding `observe` on ≥1 reachable session-owning peer* — and the premium `server.ts` gate is left untouched. Each upstream authenticates **as itself** (ADR 0033 Decision 1, Option A). Because this path is **not behind the multiplexer at all**, it does not violate ADR 0033's "no SSE crosses the multiplexer until behind the flag" invariant — see **ADR 0033 Amendment 1**, which records this reconciliation. Fleet-of-one byte-identity is guarded by the **driver declining at zero non-local owners** (not merely "an always-present local arm"), and holds only when **no focus-snapshot provider is installed** (the aggregator emits a focus frame ahead of the local arm when one is); that guard is the first implementation test.
+
+### D7 — Terminus & documentation
+
+This ADR (accepted) + the `CONTEXT.md` `Fleet & serving` glossary additions are the durable record; the implementation is tracked by a GitHub issue decomposed into **five** sub-issues (#1541–#1545); the sixth "slice" (docs) is this ADR + `CONTEXT.md`, which ride slice 1's PR rather than a separate issue. The app-surface glossary (`packages/app-bundle/overlay/packages/app/CONTEXT.md`) keeps the sidebar fleet section **read-only**; the control affordances land on the **session surface** + the **Fleet Manager** tab and are documented there when the UI slice (slice 4) lands.
+
+## Constraints & invariants
+
+- **Fail-closed, never local:** a known remote owner whose grant/transport/scope forbids a write returns a named denial, never a local execution and never a silent local read fallback (#1382).
+- **Grant-free reads preserved:** Observe continues to work with the reader token and no lifecycle grant; the write plane's grant requirement must not regress it.
+- **Control is never auto-restored:** every service restart / reconnect leaves control suspended until an explicit re-enable (`fleet_headless_rehydration.ts` AC2).
+- **Byte-identity for fleet-of-one / flag-off:** with zero non-local owners the driver declines and `/event` stays frame-for-frame the local stream (guarded in the driver, and only absent a focus-snapshot provider); the write plane is inert for local-owned and unowned sessions.
+- **Headless peers enforce only:** a peer with no UI never renders approval; authority to mint/approve lives on a UI-bearing Lifecycle-admin machine, seeded at Enroll.
+- **`lifecycle-admin` ≠ `control`, even for the holder:** the route matrix authorizes `lifecycle-admin` only for mint/revoke/re-admit — an authority holder cannot drive a peer's session. To drive, the holder must **self-issue a `control` grant** separately. (`control` does subsume `observe` routes at the matrix; `lifecycle-admin` does not.)
+- **Fan-in is not behind the multiplexer:** the observation `/event` interception is separately armed on observation readiness; the premium `server.ts` fan-in gate (`fleetMultiplexEnabled()` + `fleetPlane`) is untouched (ADR 0033 Amendment 1).
+
+## Source
+
+Part of the Fleet Studio line (#1436, #1455). Grounds the B2b implementation issue **#1540**. Slices (vertical tracer bullets):
+
+1. **Grant foundation** — Enroll seeds `lifecycle-admin`; self-owned `control` issuance; per-session re-arm. (unit-testable, no UI)
+2. **Observation write plane** — gated writes route to the owner with the control token; honest deny otherwise; enable→delete/archive end-to-end for a self-owned peer.
+3. **Fan-in** — ADR 0033 D1–D4 on the observation `/event` aggregator (the largest; may sub-split per-D).
+4. **UI** — Enable control + driving banner + fail-closed states + write affordances (session surface); Fleet Manager grant management.
+5. **Shared handshake** — request→approve routed to the Lifecycle-admin authority; pending-request surface.
+6. **Docs** — this ADR + `CONTEXT.md`, landed with slice 1 (not a separate issue; a single-artifact deliverable). The tracked implementation is **five** sub-issues, #1541–#1545.
+
+Sequencing option (reviewer to confirm at decomposition): slices **1 → 2 → 4** ship self-owned control (prompt/delete/archive, results seen on session re-fetch) *before* the fan-in weight of slice 3, which then upgrades it to live streaming — a smaller first PR that briefly trades away D1's "streaming is part of control" until 3 lands.
diff --git a/docs/fleet/p2-companion-go-no-go-1274.md b/docs/fleet/p2-companion-go-no-go-1274.md
new file mode 100644
index 000000000..52ed84383
--- /dev/null
+++ b/docs/fleet/p2-companion-go-no-go-1274.md
@@ -0,0 +1,125 @@
+# P2 go/no-go — UI-kind always-local companion (extension split) (#1274)
+
+> **STATUS: DECISION PENDING — the kill-switch is not yet thrown.** This is the ADR 0025
+> **P2 feasibility gate** and its recorded outcome (AC4). The three *unit-provable* halves
+> are **PROVEN in CI** and cited below. The one *runtime* half (survives a LIVE
+> main-extension relocation under Remote-SSH) is **PENDING a live remote**, and the
+> **final feasible / abort decision is PENDING a human** — it is deliberately left
+> unmarked. Do not read a "feasible" verdict into the proven proofs: P2 is feasible only
+> when the live proof is observed AND a human signs the verdict at the bottom.
+
+- **Issue:** #1274 — "P2: UI-kind always-local companion (extension split) — feasibility
+ spike + go/no-go" (part of #1269).
+- **Design of record:** ADR 0025 (Remote-SSH default posture; thin client as lifeboat).
+ This slice is the **load-bearing feasibility gate for the entire default-flip**: the
+ auto-switch's link sensor must run on the CLIENT and survive the host going unreachable,
+ but the main extension declares `extensionKind: ["workspace"]` and relocates to the host
+ under Remote-SSH — a single manifest runs in exactly one location. If P2 is infeasible,
+ the flip never happens and Remote-SSH stays opt-in.
+- **The artifact under test:** a SEPARATE `ui`-kind extension, `packages/companion`
+ (`amicode-companion`, `extensionKind: ["ui"]`). It bundles its own probe (`src/probe.ts`)
+ and reopen driver (`src/reopen.ts`); it holds **no engine and no store** (never-fork,
+ ADR 0025 inv. 2). It is a NEW workspace package with working `build` / `typecheck` /
+ `test` scripts, green in `pnpm -r`.
+
+## The Approach, restated (so the verdict is against the right claim)
+
+A separate always-local `ui`-kind companion — NOT a manifest change to the existing
+extension (a `["workspace"]` manifest runs in exactly one place; you cannot make one
+manifest run on both sides). "Reuse the existing detector in place" was **rejected**:
+under Remote-SSH it runs on the host — the dead side of the link. The companion is the
+live side.
+
+## Feasibility proofs
+
+Three proofs are unit-provable and one is not. Each unit proof cites the test that
+establishes it, in `packages/companion/test/companion.test.ts` (25 tests, all green;
+`pnpm --filter amicode-companion test` → `25 passed`).
+
+### (a) Activates on the CLIENT — **PROVEN (unit)**
+
+- **Claim:** a `ui`-kind companion activates on the client — `activate()` runs, registers
+ its command, and wires its probe + reopen.
+- **Proof:** `describe("activate (AC1 — client-side activation)")` — `activate` registers
+ `amicode.companion.reopenWindow` and pushes a disposable to `context.subscriptions`; the
+ returned API wires the client-side probe and both reopen directions; `deactivate` is a
+ safe no-op. `extensionKind: ["ui"]` in `packages/companion/package.json` is what places
+ that activation on the client under Remote-SSH.
+- **Not covered here:** that the activation *remains running* through a LIVE relocation —
+ that is the runtime proof below.
+
+### (b) Executes a probe from the CLIENT SIDE, independent of any host-side instance — **PROVEN (unit)**
+
+- **Claim:** the companion runs its own probe, reading only the client-configured hub URL
+ and an injected/global fetch — never the host-side running instance, posture file, or
+ projection.
+- **Proof:** `describe("probeHubHealth (AC2 — client-side probe)")` — it dials EXACTLY the
+ client base URL + `/global/health` and nothing else (`f.hits` assertion), treats any
+ answered status (incl. 5xx) as reachable, a transport error / timeout / empty URL as an
+ honest unreachable, and never throws. That the only inputs are the client URL and the
+ injected fetch **is** the host-independence.
+- **Seam for #1275:** the full unified detector bundles ON TOP of `probeHubHealth`; this
+ slice ships the minimal probe + the seam only.
+
+### (c) Programmatically triggers a Remote-SSH↔local window reopen — **WIRING PROVEN (unit); LIVE reopen pending**
+
+- **Claim:** the companion can programmatically drive a window reopen in BOTH directions.
+- **Proof (wiring):** `describe("resolveRemoteSshReopenTarget …")`,
+ `describe("resolveLocalReopenTarget …")`, and `describe("reopenWindow …")` — the pure URI
+ builders produce `vscode-remote://ssh-remote+/` (local→remote) and
+ `file://` (remote→local), reject a blank alias / relative path (never a
+ half-window), and `reopenWindow` fires `vscode.commands.executeCommand("vscode.openFolder",
+ …)` **exactly once** with the resolved URI + `forceNewWindow`, or opens NOTHING and
+ surfaces the honest message on an unresolvable target. The activation tests further show
+ the wired `reopen()` picks direction from `vscode.env.remoteName` (a remote window flips
+ to local, a local one to remote).
+- **Pending (runtime):** the actual window reopen — VS Code tearing down and re-opening the
+ window under a real Remote-SSH authority — is not unit-testable; it is part of the live
+ proof below.
+- **Duplication note (judgment call):** the ~10-line pure Remote-SSH URI builder is
+ DUPLICATED from the main extension's `src/fleet_connect_remote_ssh.ts`
+ (`resolveRemoteSshTarget`) rather than imported — the companion is a separate package and
+ must not take a (cyclic) dependency on the workspace extension. The contract fields
+ (`authority` / `path` / `uri`) are kept identical so #1275 can extract a shared pure
+ helper trivially if the surface grows.
+
+### (LIVE) Survives the main extension relocating to the host under Remote-SSH — **PENDING a live remote (runtime / HITL)**
+
+- **Claim:** the companion, activated on the client, **remains running** while the main
+ extension (`["workspace"]`) is relocated to the host under Remote-SSH — i.e. the client
+ side of the link stays alive when the host side moves away.
+- **Why not unit-testable:** it requires a real VS Code client, a real Remote-SSH window
+ onto a live hub, and observation that the `ui` extension host on the client keeps the
+ companion alive across the relocation. No unit test reaches this.
+- **Manual check (fill on a live run):** open a workspace locally (companion active on the
+ client — confirm via its command in the palette and an Output line); connect that window
+ to the hub over Remote-SSH (`Amicode: Connect to Hub over Remote-SSH`, #1271); confirm the
+ MAIN extension is now host-side (`extensionKind: ["workspace"]`) while the COMPANION is
+ still active on the client (its command still resolves; a `probeHub()` still executes from
+ the client). Then invoke `amicode.companion.reopenWindow` and confirm it drives the window
+ back to local.
+ - [ ] PASS [ ] BROKEN — Notes: `__________`
+ - Environment: client OS = `__________`, hub host = `__________`, extension version =
+ `__________`, `vscode.env.remoteName` in the connected window = `__________`.
+
+## Release-matrix flag (for release owners — NOT wired here)
+
+The companion is a **second VSIX** — a NEW release-matrix artifact. Per the Key Decision and
+this slice's scope, it is **deliberately NOT wired into `release.yml`, the main `amicode`
+VSIX, or the packaging scripts**, and is inert to `vsix-gate` / `packaging.test.ts` (those
+test the main VSIX only). Before the default-flip ships, **release owners must decide** how
+`amicode-companion` is built, versioned, and published (its own VSIX on the Marketplace vs.
+bundled distribution) and add it to the release matrix. This is a follow-up, gated on the
+verdict below being "feasible".
+
+## VERDICT (fill and sign — the ADR 0025 P2 kill-switch)
+
+- Unit proofs green: **(a) activates client-side ✓, (b) client-side probe ✓, (c) reopen
+ wiring ✓** (cited above; `pnpm --filter amicode-companion test` → 25 passed).
+- Live proof observed: [ ] yes [ ] no (from the LIVE section above).
+- **Decision — PENDING human:**
+ - [ ] **P2 FEASIBLE → proceed** to the classified sensor (#1275) and the switch
+ orchestration (#1276–#1278); flag the second VSIX to release owners.
+ - [ ] **P2 INFEASIBLE → ABORT per ADR 0025** — the default-flip does not happen;
+ Remote-SSH stays opt-in (no flip). Record why here: `__________`.
+- Decider: `__________` Date: `__________` Commit/branch under test: `__________`.
diff --git a/docs/fleet/relocation-audit-1270.md b/docs/fleet/relocation-audit-1270.md
new file mode 100644
index 000000000..844caea3e
--- /dev/null
+++ b/docs/fleet/relocation-audit-1270.md
@@ -0,0 +1,153 @@
+# Relocation audit — Remote-SSH host-side attach to the durable hub (#1270)
+
+> **STATUS: TEMPLATE — awaiting live results.** This is the HITL review artifact for
+> issue #1270 (ADR 0025). It is a checklist to be **filled by a human** running a real
+> VS Code Remote-SSH window onto a live durable-hub host. **No result below is filled
+> in.** Every checkbox is unchecked by construction — checking one is a claim about an
+> observed live run, and this document contains none yet. Do not mark a box PASS from
+> reasoning; mark it only from the observed output of the stated manual check.
+
+- **Issue:** #1270 — "Attach the editor to the durable hub over Remote-SSH — host-side
+ adoption, never-fork at the handshake seam" (part of #1268).
+- **Design of record:** ADR 0025 (Remote-SSH default posture; thin client as lifeboat) —
+ invariants 1 (loopback-only bind), 2 (never-fork; adopt the durable hub), 5 (no silent
+ fallback). Also ADR 0005 (managed fleet / never-fork), ADR 0020 (standalone server
+ survives reload — the adopt-or-spawn machinery), ADR 0023 (one topology reader).
+- **What automated tests already prove (so the live audit can focus on runtime):**
+ `packages/extension/test/adopt_live_server.test.ts` → `#1270 host-side relocation`
+ composes the three seams and pins, against a live loopback survivor, that the
+ host-side path **adopts** a live durable-hub engine at the handshake (no second
+ engine/store) and **surfaces** an unadoptable handshake as `foreign-error` (never a
+ silent cold-spawn). The live audit below is the part a unit test **cannot** reach: a
+ real Remote-SSH window, real host activation, and the real surfaces.
+
+## Preconditions for the live run
+
+Record the environment so a BROKEN result is reproducible.
+
+- [ ] Durable-hub host is an SSH target with key trust (the fleet `ssh` transport's
+ existing host + key — **no new credential handling**; #782 is out of scope).
+- [ ] The host runs the durable-hub service (#1258): `amico fleet status` on the host
+ shows role **server**, and the launchd/systemd-user unit is loaded (RunAtLoad +
+ KeepAlive).
+- [ ] The host's engine is live on the canonical port bound to loopback:
+ `lsof -nP -iTCP:43117 -sTCP:LISTEN` shows the opencode server on `127.0.0.1:43117`.
+- [ ] The host's adoption handshake exists: `~/.amico/ops/server/standalone.json` is
+ present and readable (mode 0600), recording that server's port/pid/password.
+- [ ] Fill in: hub host = `__________`, client OS = `__________`, extension version =
+ `__________`, `vscode.env.remoteName` observed in the window = `__________`
+ (expected to start with `ssh-remote`).
+
+## Surface-by-surface audit
+
+Each surface: the **host-correct** expectation, the **exact manual check**, and one
+box. `PASS` = the check's stated output was observed; `BROKEN` = it was not (write what
+happened instead in Notes).
+
+### 1. Extension activation host-side — **AC1 (runtime / HITL — not unit-testable)**
+
+- **Host-correct:** opening a Remote-SSH window onto the hub host activates the Amicode
+ extension **on the host** (`extensionKind: ["workspace"]` puts the extension host on
+ the far side), not on the client.
+- **Manual check:** open the Remote-SSH window onto the hub → Command Palette →
+ "Amicode: Open Amicode Terminal" resolves, and the Output → Amicode channel shows boot
+ lines. Confirm host-side execution: in that terminal `hostname` returns the **hub's**
+ hostname, and the extension's boot log is being written on the host
+ (`~/.amico/ops/server/server.log` grows on the host).
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 2. Engine — adopt vs spawn — **AC2 (adopt; no second engine, no second store)**
+
+- **Host-correct:** the host-side activation **adopts** the running durable-hub engine
+ at the canonical handshake — it spawns **no** second engine and opens **no** second
+ store. The Amicode channel prints `[boot] ADOPTED surviving server on port 43117 …`
+ (never `[boot] … cold-spawning`).
+- **Manual check (one canonical writer):**
+ - Amicode channel shows the `ADOPTED` line above (not a cold-spawn line).
+ - `lsof -nP -iTCP:43117 -sTCP:LISTEN` still shows **exactly one** listener, and its PID
+ equals the pid in `~/.amico/ops/server/standalone.json` (the pre-existing hub, not a
+ new one).
+ - `pgrep -af "opencode.*serve"` on the host shows **exactly one** engine process.
+ - The chat DB / store has a single writer: no second SQLite file opened, no
+ `database is locked` errors in `server.log`.
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 3. Engine — unadoptable handshake is surfaced — **AC3 (surfaced, not silent cold-spawn)**
+
+- **Host-correct:** if the running hub does **not** present an adoptable handshake
+ (stale/foreign recorded password, or protocol mismatch), the failure is **surfaced**
+ (an error notification + an Amicode-channel line naming the port/pid conflict) and the
+ extension spawns **no** rival engine on the occupied port.
+- **Manual check (destructive — do on a scratch hub, then restore):** with the hub live
+ on 43117, corrupt the recorded password in `~/.amico/ops/server/standalone.json` (or
+ point it at a foreign occupant), reload the Remote-SSH window, and confirm: a VS Code
+ error notification appears ("Amicode: …occupied…"), the Amicode channel logs the same,
+ and `pgrep -af "opencode.*serve"` still shows **one** process (no rival spawned).
+ Restore the handshake afterward.
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 4. Durable-hub service — the #1258 runner it adopts
+
+- **Host-correct:** the adopted engine **is** the durable-hub service's engine; adoption
+ did not orphan or duplicate the service. After the window attaches, the service is
+ still the process holding 43117 (KeepAlive did not restart a fight).
+- **Manual check:** `launchctl list | grep amico` (macOS) or
+ `systemctl --user status ` (Linux) on the host shows the hub unit running
+ with the **same** PID as the 43117 listener; the service log shows no restart storm
+ coinciding with the window attach.
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 5. Native Explorer — host files (the reported split, closed natively)
+
+- **Host-correct:** under Remote-SSH the native VS Code Explorer shows the **host's**
+ files via real `file:` (not the `amico-host://` lifeboat FSP, which is the thin-client
+ bridge). Opening/editing/saving a host file works with full tooling.
+- **Manual check:** the Explorer root is a host path; open a host file, edit, save →
+ the change lands on the host (`stat`/`git status` on the host reflects it). Confirm the
+ scheme is `file:` (not `amico-host:`) via the editor's file URI.
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 6. Integrated terminal — on the host
+
+- **Host-correct:** the integrated terminal is a shell **on the host**; `amico` /
+ `amico run` resolve, and it talks to the same adopted engine.
+- **Manual check:** open the integrated terminal → `hostname` = the hub; `which amico`
+ resolves on the host; `amico fleet status` shows role server; `pnpm sync --check` runs
+ against the host checkout.
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 7. Runs — the host's run artifacts
+
+- **Host-correct:** the Run Inspector and `~/.amico/runs/…` resolve to the **host's**
+ runs (one canonical runs root, the adopted engine's), not an empty client-side root.
+- **Manual check:** the Run Inspector lists the host's existing runs; a new solve writes
+ under the host's `~/.amico/runs/default//` (verify the dir appears on the host).
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+### 8. Chat / sessions / event stream — host-owned, continuous
+
+- **Host-correct:** chat sessions and the `/amicode/*` event stream are the **host's**
+ (host-owns-all-state, #1262); a session opened on the host is visible in the
+ Remote-SSH window, and turns stream from the adopted engine.
+- **Manual check:** the Sessions list shows the host's sessions; send a message and
+ confirm the turn streams; confirm the session persists in the host's chat DB (survives
+ a window reload — the adopt-on-reload path, ADR 0020).
+- [ ] PASS [ ] BROKEN — Notes: `__________`
+
+## Invariant spot-checks (ADR 0025)
+
+- [ ] **Loopback-only bind (inv. 1):** the engine binds `127.0.0.1:43117` on the host;
+ Remote-SSH is the transport, not a non-loopback bind. Verify: `lsof` shows
+ `127.0.0.1`, never `0.0.0.0`/`*`.
+- [ ] **Never-fork (inv. 2):** exactly one engine and one store across the whole attach
+ (covered by surfaces 2 + 4).
+- [ ] **No silent fallback (inv. 5):** any degradation (unreachable hub, unadoptable
+ handshake) is a **surfaced** transition, never a silent reroute or cold-spawn
+ (covered by surface 3).
+
+## Verdict (fill after the live run)
+
+- Surfaces host-correct: `__ / 8`. Invariants held: `__ / 3`.
+- Overall: [ ] host-correct (ready to seed S2/S3) [ ] broken (file follow-ups; list
+ the broken surfaces and their issue numbers here: `__________`).
+- Auditor: `__________` Date: `__________` Commit/branch under test: `__________`.
diff --git a/ops/fleet-status.sh b/ops/fleet-status.sh
index 3648055cd..e24d4f4b0 100755
--- a/ops/fleet-status.sh
+++ b/ops/fleet-status.sh
@@ -8,9 +8,48 @@ OUT="$HOME/.amico/ops/fleet-status.json"
TMP="$OUT.tmp"
DB="$HOME/.local/share/opencode/opencode-dev.db"
SYNCLOG="$HOME/.amico/sync.log"
+ROSTER="$HOME/.amico/ops/fleet/roster.json"
-# --- devices: alias|host pairs (edit here as the fleet grows) ---------------
-DEVICES=("mini:127.0.0.1" "macbook:macbook" "erlich:erlich")
+# --- devices: derived from the host-owned roster (#1318, ADR 0026) ----------
+# The fleet's device list is the roster's rows (each machine self-reports its
+# own row), NOT a hardcoded array — a machine tagged/enrolled shows up here
+# without editing this script. Each device is `name:host`, host = the row's
+# sshAlias (the ssh target the probe uses), falling back to loopback. When the
+# roster is absent/empty the job probes only this machine (a loopback self-row),
+# never an invented peer.
+roster_devices() {
+ if [ -f "$ROSTER" ] && command -v python3 >/dev/null 2>&1; then
+ python3 - "$ROSTER" <<'PY'
+import json, sys
+try:
+ doc = json.load(open(sys.argv[1]))
+ rows = doc.get("rows", []) if isinstance(doc, dict) else []
+except Exception:
+ rows = []
+for r in rows:
+ if not isinstance(r, dict):
+ continue
+ name = r.get("name") or r.get("machine_id") or "unknown"
+ host = r.get("sshAlias") or "127.0.0.1"
+ # keep the pair delimiter clean (name:host is split on the first colon)
+ print(f"{name}:{host}")
+PY
+ fi
+}
+
+# The derived device pairs, with a loopback self-row fallback when the roster
+# yields nothing (honest "this machine only", never the retired hardcoded peers).
+DEVICE_PAIRS="$(roster_devices)"
+if [ -z "$DEVICE_PAIRS" ]; then
+ DEVICE_PAIRS="this-machine:127.0.0.1"
+fi
+
+# --emit-devices: a dry-run seam (the AC5 test surface) — print the derived
+# device list and exit BEFORE any ssh/sqlite/curl/launchctl work.
+if [ "${1:-}" = "--emit-devices" ]; then
+ printf '%s\n' "$DEVICE_PAIRS"
+ exit 0
+fi
# Bounded ssh probe — no GNU timeout on macOS, and ConnectTimeout alone does
# NOT bound an in-band stall (observed 2026-08-08: Tailscale SSH to erlich
@@ -27,7 +66,8 @@ ssh_probe() {
}
dev_rows=""
-for pair in "${DEVICES[@]}"; do
+while IFS= read -r pair; do
+ [ -z "$pair" ] && continue
name="${pair%%:*}"; host="${pair#*:}"
if [ "$host" = "127.0.0.1" ]; then
reachable=true; detail="this machine"
@@ -39,7 +79,9 @@ for pair in "${DEVICES[@]}"; do
fi
fi
dev_rows="$dev_rows{\"name\":\"$name\",\"reachable\":$reachable,\"detail\":\"$detail\"},"
-done
+done <,
+ id: string,
+ source: DiscoverySource,
+ fields: {
+ name: string;
+ sshAlias?: string;
+ address?: string;
+ preferAddress?: boolean;
+ inRoster?: boolean;
+ enrolled?: boolean;
+ serverMode?: string;
+ health?: RosterHealth;
+ },
+): void {
+ const existing = acc.get(id);
+ if (existing === undefined) {
+ acc.set(id, {
+ id,
+ name: fields.name,
+ sources: [source],
+ sshAlias: fields.sshAlias,
+ address: fields.address,
+ inRoster: fields.inRoster ?? false,
+ enrolled: fields.enrolled ?? false,
+ serverMode: fields.serverMode,
+ health: fields.health,
+ });
+ return;
+ }
+ if (!existing.sources.includes(source)) existing.sources.push(source);
+ existing.sshAlias ??= fields.sshAlias;
+ if (fields.address !== undefined && (fields.preferAddress || existing.address === undefined)) {
+ existing.address = fields.address;
+ }
+ // Roster facts (only the roster fold supplies these, and it folds first, so
+ // these are normally set at creation). Never downgrade a fact already set.
+ if (fields.inRoster) existing.inRoster = true;
+ if (fields.enrolled) existing.enrolled = true;
+ existing.serverMode ??= fields.serverMode;
+ existing.health ??= fields.health;
+}
+
+/**
+ * Enumerate the candidate machines from the three discovery sources, deduped by
+ * normalized machine name, mutating nothing. Read-only: the returned candidates
+ * are fresh objects; the inputs are never touched.
+ */
+export function discoverFleetCandidates(input: DiscoverInput): FleetCandidate[] {
+ const acc = new Map();
+
+ // Fold in canonical source order so a candidate's `sources` list is already
+ // ordered and its display name/reach hints prefer the richer roster source.
+ for (const row of input.roster ?? []) {
+ fold(acc, normalizeIdentity(row.name), "roster", {
+ name: row.name,
+ sshAlias: row.sshAlias || undefined,
+ inRoster: true,
+ // The honesty bar (#1319): enrolled ONLY when verify-attach passed, which
+ // the roster mirrors as health "reachable". A `down`/`degraded` row is a
+ // machine that has a row but is not yet trustworthy — not enrolled.
+ enrolled: row.health === "reachable",
+ serverMode: row.server_mode,
+ health: row.health,
+ });
+ }
+ for (const host of input.sshConfig ?? []) {
+ fold(acc, normalizeIdentity(host.alias), "ssh-config", {
+ name: host.alias,
+ sshAlias: host.alias,
+ address: host.hostName,
+ });
+ }
+ for (const peer of input.tailnet ?? []) {
+ fold(acc, normalizeIdentity(peer.hostName), "tailnet", {
+ name: peer.hostName,
+ address: peer.tailscaleIP,
+ preferAddress: true,
+ });
+ }
+
+ for (const c of acc.values()) c.sources.sort((a, b) => SOURCE_ORDER.indexOf(a) - SOURCE_ORDER.indexOf(b));
+ return [...acc.values()].sort((a, b) => a.id.localeCompare(b.id));
+}
diff --git a/packages/amico-run/src/fleet_enroll_verb.ts b/packages/amico-run/src/fleet_enroll_verb.ts
new file mode 100644
index 000000000..91fc10c51
--- /dev/null
+++ b/packages/amico-run/src/fleet_enroll_verb.ts
@@ -0,0 +1,805 @@
+// `amico fleet enroll` (#1319) — the FLEET ENROLL PRIMITIVE. A NEW subcommand,
+// DISTINCT from the session-registry `amico fleet` verbs (fleet_verb.ts) and
+// their "write verbs don't write the record" invariant: enroll writes the
+// machine's `fleet.json` membership record + a host-owned roster row, a
+// different domain entirely. It is the idempotent, verifiable way to bring one
+// machine into a fleet, and the primitive the `/create-a-fleet` orchestrator
+// (#1320) drives.
+//
+// Two paths, the standard server-first join-token pattern (k3s/tailscale/swarm):
+// · `--as-server` — provision the durable hub service (the installer, role
+// server), mint the Fleet token (ADR 0005), and EMIT a join token bundling
+// { canonical{host,port,sshAlias}, fleet_token, transport_hint, pin_version }.
+// The join token is a SECRET, written at 0600, NEVER logged.
+// · client redeem (`--join-token ` | `--join-token-json `):
+// 1. PIN CHECK first — probe the host version and reject a token whose
+// pin_version disagrees (reusing the pure versionSkewVerdict) BEFORE any
+// file is written (AC4).
+// 2. write fleet.json (role + canonical ONLY — capabilities live on the
+// roster row), register the roster row via POST /amicode/roster (#1318),
+// set amicode.fleetTransport (tailscale when this machine's capabilities
+// include `roaming`, else the token's hint), run the installer (which
+// installs the never-fork guard + the client tunnel).
+// 3. VERIFY ATTACH — probe the just-set transport (GET /global/health,
+// expecting 200 + a pin matching the host). Success is reported ONLY
+// after verify-attach passes; on failure the specific cause is reported
+// with its fix, the roster row's health reflects the failure, and
+// success is NOT reported (ADR 0024/0025 no-silent-fallback).
+// Re-runnable: a second enroll REPAIRS in place — the roster POST is a
+// single-writer upsert (no duplicate row), the installer + token mint are
+// idempotent (no duplicate tunnel/unit).
+//
+// Never-fork (ADR 0005): enrolling as a client installs the guard and spawns NO
+// engine — this verb has no engine-spawn path at all; the `spawnEngine` dep is a
+// tripwire the tests assert is never touched.
+//
+// Async: the verb does real HTTP (roster POST + health probes). The `fleet` verb
+// router (fleet_verb.ts) already returns `VerbResult | Promise` and
+// both callers (amico.ts, mcp_serve.ts) await it.
+import * as fs from "node:fs";
+import * as path from "node:path";
+import { homedir, hostname } from "node:os";
+import { randomUUID } from "node:crypto";
+import {
+ versionSkewVerdict,
+ writeFleetConfig as schemaWriteFleetConfig,
+ recordLifecycleAuthority as schemaRecordLifecycleAuthority,
+ fleetTopologyPath,
+ classifyMacModel,
+ classifyLinuxChassis,
+ normalizeDeviceName,
+ isWslKernel,
+ type FleetConfig,
+ type LifecycleAuthorityRecord,
+ type RosterRow,
+ type RosterHealth,
+} from "@amicode/schema";
+import type { VerbResult } from "./verbs.js";
+
+// ── the join token contract (a secret — 0600, never logged) ───────────────────
+export interface JoinTokenCanonical {
+ host: string;
+ port: number;
+ sshAlias: string;
+}
+export interface JoinToken {
+ canonical: JoinTokenCanonical;
+ fleet_token: string;
+ transport_hint: string;
+ pin_version: string;
+}
+
+/** Parse a raw join token — tolerant Result (null on malformed), so a bad token
+ * is refused as data before any side effect. */
+export function parseJoinToken(raw: string): JoinToken | null {
+ let doc: unknown;
+ try {
+ doc = JSON.parse(raw);
+ } catch {
+ return null;
+ }
+ if (doc === null || typeof doc !== "object" || Array.isArray(doc)) return null;
+ const o = doc as Record;
+ const c = o.canonical;
+ if (c === null || typeof c !== "object" || Array.isArray(c)) return null;
+ const cc = c as Record;
+ if (typeof cc.host !== "string" || cc.host.trim() === "") return null;
+ if (typeof cc.port !== "number" || !Number.isFinite(cc.port)) return null;
+ if (typeof cc.sshAlias !== "string") return null;
+ if (typeof o.fleet_token !== "string" || o.fleet_token.trim() === "") return null;
+ if (typeof o.transport_hint !== "string" || o.transport_hint.trim() === "") return null;
+ if (typeof o.pin_version !== "string" || o.pin_version.trim() === "") return null;
+ return {
+ canonical: { host: cc.host, port: cc.port, sshAlias: cc.sshAlias },
+ fleet_token: o.fleet_token,
+ transport_hint: o.transport_hint,
+ pin_version: o.pin_version,
+ };
+}
+
+// ── the enroll-result JSON (the exact shape #1320 consumes) ────────────────────
+export interface EnrollVerifyAttach {
+ ok: boolean;
+ cause?: string;
+}
+export interface EnrollResult {
+ machine_id: string;
+ name: string;
+ server_mode: string;
+ capabilities: string[];
+ transport: string;
+ verify_attach: EnrollVerifyAttach;
+}
+
+// ── the transport→origin resolution for verify-attach ─────────────────────────
+export type ProbeOriginResult = { ok: true; origin: string } | { ok: false; cause: string; fix: string };
+
+// ── injectable seams (production defaults do the real thing; tests stub) ───────
+export interface FleetEnrollDeps {
+ /** This machine's stable id (single-writer roster key). Default: hostname(). */
+ machineId?: () => string;
+ /** This machine's OS-detected friendly display name — the middle tier of the
+ * name precedence (setting override → THIS → prettified hostname). Default:
+ * the OS detector (scutil ComputerName / hostnamectl --pretty) via the
+ * command-runner, falling back to the prettified hostname. NOTE: this is the
+ * DISPLAY name only — it is deliberately kept OUT of the `canonical.host`
+ * derivation so a friendly name never leaks into the join token (ADR 0028). */
+ machineName?: () => string;
+ /** This machine's declared capability tags (roaming → tailscale). Default: []. */
+ capabilities?: () => string[];
+ /** This build's version pin — the enroll-time pin check + the minted token's
+ * pin_version. Default: AMICO_CLIENT_VERSION env, else "dev". */
+ clientVersion?: () => string;
+ /** ISO stamp for the roster row. Default: now. */
+ now?: () => string;
+ /** The HTTP impl for the roster POST + health probes. Default: globalThis.fetch. */
+ fetchImpl?: typeof fetch;
+ /** The fleet.json writer. Default: the hoisted @amicode/schema writeFleetConfig. */
+ writeFleetConfig?: (config: FleetConfig, p: string) => void;
+ /** Seed the lifecycle-admin authority (#1541, ADR 0034 D3): record the
+ * enroller as THIS machine's authority at client redeem — net-new
+ * persistence, the same injectable-with-real-default pattern as
+ * writeFleetConfig/mintFleetToken (enroll stays byte-identical when
+ * defaulted; a test injects a spy). Default: the shared @amicode/schema
+ * authority-store writer. */
+ recordLifecycleAuthority?: (record: LifecycleAuthorityRecord) => void;
+ /** Where fleet.json lives. Default: fleetTopologyPath(). */
+ fleetConfigPath?: string;
+ /** Write the join token at 0600. Default: atomic 0600 write. */
+ writeJoinToken?: (p: string, token: JoinToken) => void;
+ /** Read a join token file (0600). Default: read-or-null. */
+ readJoinTokenFile?: (p: string) => string | null;
+ /** Where the minted join token lands (--as-server). Default under ~/.amico. */
+ joinTokenOutPath?: string;
+ /** Run the fleet installer for a role (guard/tunnel/hub-service; idempotent).
+ * Default: shell tools/fleet/install.sh (honest failure if unlocatable). */
+ runInstaller?: (role: string) => { ok: boolean; detail?: string };
+ /** Set amicode.fleetTransport. Default: merge into the VS Code User settings.json. */
+ setTransport?: (kind: string) => void;
+ /** Mint or reuse the Fleet token (0600, idempotent). Default: read-or-mint. */
+ mintFleetToken?: () => string;
+ /** NEVER called — the never-fork tripwire (a client spawns no engine). */
+ spawnEngine?: () => void;
+ /** Resolve a transport + canonical to the origin verify-attach probes, or the
+ * sshAlias-unresolved cause. Default: local/tailscale/direct → http host:port;
+ * ssh with an empty alias → sshAlias-unresolved. */
+ resolveProbeOrigin?: (transport: string, canonical: JoinTokenCanonical) => ProbeOriginResult;
+ /** Retry delays in ms for verify-attach transport-down probes.
+ * Default: [1000, 2000, 4000] — up to 3 retries with exponential backoff.
+ * Only `{ error: "unreachable" }` retries; auth rejections and pin mismatches
+ * fail immediately. Tests inject [0, 0, 0] for instant retries. */
+ retryDelayMs?: number[];
+ /** The ONE injectable command-runner seam for the impure OS device-identity
+ * detection (macOS `scutil`/`system_profiler`, Linux `hostnamectl` + `/sys`
+ * DMI + `/proc/version`). Default: an execFile-based runner that returns ""
+ * on any failure (detection is best-effort). Tests feed canned command output
+ * through it into the shared @amicode/schema classifiers (#1371 AC6/AC7). */
+ commandRunner?: CommandRunner;
+ /** This machine's OS-detected device_type (form factor) — the middle tier of
+ * the type precedence (setting override → THIS → undefined). Default: OS
+ * detection via `commandRunner` → the shared schema classifier. `undefined`
+ * is an honest abstain (the sidebar's type pill falls back to server_mode). */
+ deviceType?: () => string | undefined;
+ /** Read a device-identity setting override (`amicode.device.name` /
+ * `amicode.device.type`) — the SAME namespace the extension self-row reads
+ * (ADR 0028: one override namespace, both producers). Default: read the VS
+ * Code User settings.json (the same path `setTransport` writes). */
+ readDeviceSetting?: (key: string) => string | undefined;
+}
+
+/** The impure command-runner seam signature: run a command with args and return
+ * its stdout (or "" on failure). ONE per node package (ADR 0028) — the shell
+ * lives here; all judgment is delegated to the shared @amicode/schema pure
+ * classifiers. */
+export type CommandRunner = (cmd: string, args: string[]) => string;
+
+// ── flag parsing ──────────────────────────────────────────────────────────────
+function flagValue(argv: string[], name: string): string | undefined {
+ const i = argv.indexOf(name);
+ return i >= 0 && i + 1 < argv.length ? argv[i + 1] : undefined;
+}
+
+function fail(errors: string[], extra: Record = {}, code = 64): VerbResult {
+ return { json: { verb: "fleet", subcommand: "enroll", ok: false, errors, ...extra }, code };
+}
+
+// ── production defaults ─────────────────────────────────────────────────────
+function defaultWriteJoinToken(p: string, token: JoinToken): void {
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const tmp = `${p}.${process.pid}.${randomUUID()}.tmp`;
+ try {
+ fs.writeFileSync(tmp, JSON.stringify(token, null, 2) + "\n", { mode: 0o600 });
+ fs.chmodSync(tmp, 0o600);
+ fs.renameSync(tmp, p);
+ fs.chmodSync(p, 0o600);
+ } finally {
+ fs.rmSync(tmp, { force: true });
+ }
+}
+
+function defaultReadJoinTokenFile(p: string): string | null {
+ return fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null;
+}
+
+function defaultFleetTokenPath(): string {
+ return path.join(homedir(), ".amico", "ops", "fleet", "fleet-token");
+}
+
+/** Read-or-mint the Fleet token (0600). Exclusive create so a re-run REUSES the
+ * existing token (idempotent mint, AC5). */
+function defaultMintFleetToken(): string {
+ const p = defaultFleetTokenPath();
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const token = randomUUID().replace(/-/g, "");
+ try {
+ fs.writeFileSync(p, token, { encoding: "utf8", flag: "wx", mode: 0o600 });
+ fs.chmodSync(p, 0o600);
+ return token;
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
+ const existing = fs.readFileSync(p, "utf8").trim();
+ if (existing.length === 0) throw new Error(`fleet-token file is empty: ${p}`);
+ return existing;
+ }
+}
+
+/** Locate + run tools/fleet/install.sh for the role (idempotent by the script's
+ * own design). AMICO_FLEET_INSTALLER overrides the path; an unlocatable script
+ * is an honest failure, never a silent success. */
+function defaultRunInstaller(role: string): { ok: boolean; detail?: string } {
+ const candidates = [
+ process.env.AMICO_FLEET_INSTALLER,
+ path.join(homedir(), "harmoniqs", "amicode", "tools", "fleet", "install.sh"),
+ ].filter((c): c is string => typeof c === "string" && c.length > 0);
+ const script = candidates.find((c) => fs.existsSync(c));
+ if (!script) {
+ return { ok: false, detail: `fleet installer not found (set AMICO_FLEET_INSTALLER); role=${role} not provisioned` };
+ }
+ const { spawnSync } = require("node:child_process") as typeof import("node:child_process");
+ const r = spawnSync("bash", [script], { encoding: "utf8" });
+ return r.status === 0 ? { ok: true } : { ok: false, detail: (r.stderr || "").trim() || `installer exited ${r.status}` };
+}
+
+/** VS Code User settings.json path per-OS (mirrors tools/fleet/install.sh). */
+function defaultSettingsPath(): string {
+ if (process.platform === "darwin") {
+ return path.join(homedir(), "Library", "Application Support", "Code", "User", "settings.json");
+ }
+ return path.join(homedir(), ".config", "Code", "User", "settings.json");
+}
+
+/** Merge amicode.fleetTransport into the VS Code User settings.json (the surface
+ * the transport-provider selector reads — amico-run has no VS Code API). */
+function defaultSetTransport(kind: string): void {
+ const p = defaultSettingsPath();
+ let doc: Record = {};
+ try {
+ doc = JSON.parse(fs.readFileSync(p, "utf8")) as Record;
+ } catch {
+ doc = {};
+ }
+ doc["amicode.fleetTransport"] = kind;
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ fs.writeFileSync(p, JSON.stringify(doc, null, 2) + "\n");
+}
+
+// ── device identity: the impure detector (ONE command-runner seam) ─────────────
+/** The default command runner: execFile the command, return stdout, and swallow
+ * any failure into "" — detection is best-effort (a missing binary, a non-zero
+ * exit, a timeout all read as "nothing detected"). The ONE place amico-run
+ * shells out for device identity; all classification is the shared schema fns. */
+function defaultCommandRunner(cmd: string, args: string[]): string {
+ try {
+ const { execFileSync } = require("node:child_process") as typeof import("node:child_process");
+ return execFileSync(cmd, args, { encoding: "utf8", timeout: 2000 }).toString();
+ } catch {
+ return "";
+ }
+}
+
+/** Read a device-identity setting override from the VS Code User settings.json
+ * (the same file `setTransport` writes `amicode.fleetTransport` into). Returns
+ * the string value or undefined (absent file / non-string / parse error). */
+function defaultReadDeviceSetting(key: string): string | undefined {
+ try {
+ const doc = JSON.parse(fs.readFileSync(defaultSettingsPath(), "utf8")) as Record;
+ const v = doc[key];
+ return typeof v === "string" && v.trim() !== "" ? v : undefined;
+ } catch {
+ return undefined;
+ }
+}
+
+/** Map a `/sys/class/dmi/id/chassis_type` NUMERIC code to the device-type
+ * vocabulary (SMBIOS System Enclosure types) — the Linux second source, after
+ * `hostnamectl` chassis and before abstaining. Kept in the node package (this
+ * is platform plumbing over a numeric code, not the string-chassis judgment the
+ * shared `classifyLinuxChassis` owns). */
+function dmiChassisType(code: string): string | undefined {
+ const n = code.trim();
+ if (["8", "9", "10", "11", "12", "14", "30", "31", "32"].includes(n)) return "laptop";
+ if (["3", "4", "5", "6", "7", "13", "15", "16", "34", "35", "36"].includes(n)) return "desktop";
+ if (["17", "23", "28"].includes(n)) return "server";
+ return undefined;
+}
+
+/** OS device_type detection through the command-runner seam → the shared schema
+ * classifiers. macOS: `system_profiler` "Model Name" → classifyMacModel. Linux:
+ * WSL abstains (the VM chassis is not the physical machine); else `hostnamectl`
+ * chassis → classifyLinuxChassis FIRST, then `/sys/class/dmi/id/chassis_type`
+ * numeric, then abstain (#1371 AC7 / ADR 0028). Any other platform abstains. */
+export function detectDeviceTypeVia(run: CommandRunner, platform: string): string | undefined {
+ if (platform === "darwin") {
+ const out = run("system_profiler", ["SPHardwareDataType"]) ?? "";
+ const m = /Model Name:\s*(.+)/i.exec(out);
+ return classifyMacModel((m?.[1] ?? "").trim());
+ }
+ if (platform === "linux") {
+ if (isWslKernel(run("cat", ["/proc/version"]) ?? "")) return undefined; // WSL abstains
+ const byChassis = classifyLinuxChassis((run("hostnamectl", ["chassis"]) ?? "").trim());
+ if (byChassis) return byChassis;
+ const byDmi = dmiChassisType((run("cat", ["/sys/class/dmi/id/chassis_type"]) ?? "").trim());
+ if (byDmi) return byDmi;
+ return undefined;
+ }
+ return undefined;
+}
+
+/** OS friendly-NAME detection through the command-runner seam. macOS: `scutil
+ * --get ComputerName`. Linux: `hostnamectl --pretty` (the PRETTY_HOSTNAME). Any
+ * empty/failed detection falls back to the prettified raw hostname — never a
+ * fabricated name (#1371 AC7 / ADR 0028). */
+export function detectDeviceNameVia(run: CommandRunner, rawHostname: string, platform: string): string {
+ if (platform === "darwin") {
+ const name = (run("scutil", ["--get", "ComputerName"]) ?? "").trim();
+ if (name) return name;
+ } else if (platform === "linux") {
+ const pretty = (run("hostnamectl", ["--pretty"]) ?? "").trim();
+ if (pretty) return pretty;
+ }
+ return normalizeDeviceName(rawHostname);
+}
+
+function defaultResolveProbeOrigin(transport: string, canonical: JoinTokenCanonical): ProbeOriginResult {
+ if (transport === "ssh" && (!canonical.sshAlias || canonical.sshAlias.trim() === "")) {
+ return {
+ ok: false,
+ cause: "sshAlias-unresolved",
+ fix: "the join token carries no canonical.sshAlias — add it to the token (and to ~/.ssh/config) so the ssh tunnel can resolve the hub host",
+ };
+ }
+ return { ok: true, origin: `http://${canonical.host}:${canonical.port}` };
+}
+
+// ── HTTP helpers ───────────────────────────────────────────────────────────
+type HealthProbe = { status: number; version: string | null } | { error: "unreachable" };
+
+async function probeHealth(origin: string, authHeader: string | undefined, fetchImpl: typeof fetch): Promise {
+ const url = `${origin.replace(/\/+$/, "")}/global/health`;
+ try {
+ const res = await fetchImpl(url, {
+ ...(authHeader ? { headers: { Authorization: authHeader } } : {}),
+ });
+ if (!res.ok) return { status: res.status, version: null };
+ const body = (await res.json()) as { version?: unknown };
+ return { status: res.status, version: typeof body.version === "string" ? body.version : null };
+ } catch {
+ return { error: "unreachable" };
+ }
+}
+
+async function postRosterRow(
+ canonical: JoinTokenCanonical,
+ authHeader: string | undefined,
+ row: RosterRow,
+ fetchImpl: typeof fetch,
+): Promise<{ ok: boolean; status: number } | { error: string }> {
+ const url = `http://${canonical.host}:${canonical.port}/amicode/roster`;
+ try {
+ const res = await fetchImpl(url, {
+ method: "POST",
+ headers: { "content-type": "application/json", ...(authHeader ? { Authorization: authHeader } : {}) },
+ body: JSON.stringify(row),
+ });
+ return { ok: res.ok, status: res.status };
+ } catch (e) {
+ return { error: (e as Error).message };
+ }
+}
+
+// ── the server path ────────────────────────────────────────────────────────
+/** Verify the hub is reachable on the given origin, retry-or-honest-fail (#1372
+ * AC1). Retries ONLY on `{error:"unreachable"}` (a transient loopback race);
+ * any answer (the hub responded at all) proves it is up. Returns ok:false only
+ * after exhausting retries. Distinct from the dev-fallback pin probe, which
+ * TOLERATES an unreachable hub and is therefore NOT proof of reachability. */
+async function verifyHubReachable(
+ origin: string,
+ fetchImpl: typeof fetch,
+ retryDelays: number[] = [1000, 2000, 4000],
+): Promise<{ ok: boolean }> {
+ const maxAttempts = 1 + retryDelays.length;
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const probe = await probeHealth(origin, undefined, fetchImpl);
+ if (!("error" in probe)) return { ok: true }; // the hub answered ⇒ reachable
+ if (attempt < retryDelays.length) {
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
+ continue;
+ }
+ }
+ return { ok: false };
+}
+
+/** The pin_version to stamp on a minted join token. Precedence (#1354 follow-up):
+ * an explicit injection or AMICO_CLIENT_VERSION env wins (operator override);
+ * otherwise probe the just-provisioned local hub's /global/health and pin its
+ * REAL engine version — NOT the placeholder "dev", which fails the enroll-time
+ * pin-check against the server's own 1.18.x engine. "dev" survives only as the
+ * last-resort fallback when the hub is unreachable at mint time. */
+async function resolveServerPinVersion(
+ deps: FleetEnrollDeps,
+ canonical: JoinTokenCanonical,
+ fetchImpl: typeof fetch,
+): Promise {
+ if (deps.clientVersion) return deps.clientVersion();
+ const envPin = process.env.AMICO_CLIENT_VERSION;
+ if (typeof envPin === "string" && envPin.trim() !== "") return envPin;
+ // The hub listens on loopback; advertised canonical.host may not resolve locally.
+ const probe = await probeHealth(`http://127.0.0.1:${canonical.port}`, undefined, fetchImpl);
+ if ("version" in probe && typeof probe.version === "string" && probe.version.trim() !== "") {
+ return probe.version;
+ }
+ return "dev";
+}
+
+async function enrollAsServer(argv: string[], deps: FleetEnrollDeps): Promise {
+ const runner = deps.commandRunner ?? defaultCommandRunner;
+ const machineName = (deps.machineName ?? (() => detectDeviceNameVia(runner, hostname(), process.platform)))();
+ // The friendly display name is DECOUPLED from canonical.host (ADR 0028 §inv 6):
+ // canonical derives from --host or the RAW hostname, never the friendly name,
+ // so a friendly name can never leak into `canonical`/the minted join token.
+ const host = flagValue(argv, "--host") ?? hostname();
+ const portRaw = flagValue(argv, "--port") ?? "4096";
+ if (!/^\d+$/.test(portRaw)) return fail([`--port "${portRaw}" must be a positive integer`]);
+ const port = Number(portRaw);
+ const sshAlias = flagValue(argv, "--ssh-alias") ?? host;
+ const transportHint = flagValue(argv, "--transport-hint") ?? "ssh";
+ const canonical: JoinTokenCanonical = { host, port, sshAlias };
+
+ const writeConfig = deps.writeFleetConfig ?? schemaWriteFleetConfig;
+ const configPath = deps.fleetConfigPath ?? fleetTopologyPath();
+ writeConfig({ role: "server", canonical }, configPath);
+
+ // Provision the durable hub service (the installer, role server; idempotent).
+ const inst = (deps.runInstaller ?? defaultRunInstaller)("server");
+ if (!inst.ok) {
+ return fail(
+ [`hub-service provisioning failed: ${inst.detail ?? "(no detail)"}`],
+ { role: "server", fix: "run `bash tools/fleet/install.sh` and re-enroll once it reports ok" },
+ 70,
+ );
+ }
+
+ const fleet_token = (deps.mintFleetToken ?? defaultMintFleetToken)();
+ const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch);
+ const pin_version = await resolveServerPinVersion(deps, canonical, fetchImpl);
+ const token: JoinToken = { canonical, fleet_token, transport_hint: transportHint, pin_version };
+ const outPath = deps.joinTokenOutPath ?? path.join(homedir(), ".amico", "ops", "fleet", "join-token.json");
+ (deps.writeJoinToken ?? defaultWriteJoinToken)(outPath, token);
+
+ // ── self-register the server's OWN roster row (#1372 AC2), keyed by
+ // machine_id = canonical.host — the same identity a client references it
+ // by, so the client's synthesized canonical-server node collapses against
+ // this real row (zero client-side change), and the server's own self-row
+ // (reconciled to canonical.host in the extension) renders exactly once.
+ // ONLY after VERIFYING the hub is reachable on loopback (#1372 AC1): the
+ // hub listens on loopback; canonical.host may not resolve locally. This is
+ // a SEPARATE check from resolveServerPinVersion's dev-fallback probe (which
+ // tolerates an unreachable hub) — a dev pin is never reachability proof.
+ // No silent fallback (ADR 0024/0025): unreachable ⇒ no POST, no false row.
+ const readSetting = deps.readDeviceSetting ?? defaultReadDeviceSetting;
+ const nameOverride = readSetting("amicode.device.name");
+ const resolvedName = nameOverride && nameOverride.trim() !== "" ? nameOverride.trim() : machineName;
+ const typeOverride = readSetting("amicode.device.type");
+ const detectedType = (deps.deviceType ?? (() => detectDeviceTypeVia(runner, process.platform)))();
+ // The server's device_type: override → OS detection → the honest "server"
+ // default (a server that can't detect its form factor is, at least, a server).
+ const serverDeviceType = typeOverride && typeOverride.trim() !== "" ? typeOverride.trim() : (detectedType ?? "server");
+
+ const loopbackOrigin = `http://127.0.0.1:${port}`;
+ const loopbackCanonical: JoinTokenCanonical = { host: "127.0.0.1", port, sshAlias };
+ const reachable = await verifyHubReachable(loopbackOrigin, fetchImpl, deps.retryDelayMs);
+ let self_registered = false;
+ if (reachable.ok) {
+ const serverRow: RosterRow = {
+ machine_id: canonical.host,
+ name: resolvedName,
+ server_mode: "server",
+ capabilities: (deps.capabilities ?? (() => []))(),
+ sshAlias: canonical.sshAlias,
+ transport: transportHint,
+ last_report: (deps.now ?? (() => new Date().toISOString()))(),
+ health: "reachable",
+ device_type: serverDeviceType,
+ };
+ // Reuse the single-writer POST (no new writer). The URL targets loopback
+ // (the local hub); the ROW carries machine_id = canonical.host.
+ const post = await postRosterRow(loopbackCanonical, undefined, serverRow, fetchImpl);
+ self_registered = "ok" in post && post.ok;
+ }
+
+ const result: EnrollResult = {
+ machine_id: (deps.machineId ?? (() => hostname()))(),
+ name: resolvedName,
+ server_mode: "server",
+ capabilities: (deps.capabilities ?? (() => []))(),
+ transport: transportHint,
+ verify_attach: { ok: true },
+ };
+ return {
+ json: {
+ verb: "fleet",
+ subcommand: "enroll",
+ ok: true,
+ result,
+ // the join token rides the result so #1320 can hand it to clients; it is
+ // ALSO persisted at 0600. It is a secret — surfaced on the structured
+ // result only, never in a log line.
+ join_token: token,
+ join_token_path: outPath,
+ self_registered,
+ // AC5: canonical.host is IMMUTABLE after first server enroll — changing
+ // --host on a re-enroll orphans clients' synthesized node and requires
+ // re-enrolling the CLIENTS, not just the server (never auto-reconciled).
+ caveat:
+ "canonical.host is immutable after first enroll — changing --host requires re-enrolling clients (their canonical pointer is not auto-reconciled)",
+ note: self_registered
+ ? "server enrolled — durable hub service provisioned, Fleet token minted, join token emitted (0600), and the server's own roster row self-registered (machine_id = canonical.host)"
+ : "server enrolled — hub provisioned + join token emitted (0600); the hub was NOT reachable on loopback at self-register time, so the server roster row was NOT posted (no false success). Re-enroll once the hub answers.",
+ },
+ code: 0,
+ };
+}
+
+// ── the client redeem path ───────────────────────────────────────────────────
+async function enrollAsClient(argv: string[], token: JoinToken, deps: FleetEnrollDeps): Promise {
+ const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch);
+ const canonical = token.canonical;
+
+ // ── AC4: the PIN CHECK, BEFORE any file is written ──
+ // Probe the canonical host's version and reject a pin_version disagreement
+ // via the pure versionSkewVerdict. An unreachable host at this stage is also
+ // a pre-write refusal (the token cannot be validated) — never a blind write.
+ // #1354: the hub uses open auth (AMICODE_SERVICE_AUTH=open) — the SSH tunnel
+ // is the trust boundary, so no HTTP credentials are sent.
+ const pinProbe = await probeHealth(`http://${canonical.host}:${canonical.port}`, undefined, fetchImpl);
+ if ("error" in pinProbe) {
+ return fail(
+ [`cannot reach the hub host to validate the join token's pin (host ${canonical.host}:${canonical.port} unreachable)`],
+ {
+ stage: "pin-check",
+ cause: "transport-down",
+ fix: "confirm the hub is up and reachable from this machine, then re-run enroll",
+ wrote_nothing: true,
+ },
+ 69,
+ );
+ }
+ if (pinProbe.version !== null) {
+ const verdict = versionSkewVerdict(token.pin_version, pinProbe.version);
+ if (!verdict.agree) {
+ return fail([verdict.reason], {
+ stage: "pin-check",
+ cause: "pin-mismatch",
+ pin_version: token.pin_version,
+ host_version: pinProbe.version,
+ fix: "align the client and hub versions (upgrade one) and re-mint the join token, then re-enroll",
+ wrote_nothing: true,
+ });
+ }
+ }
+
+ // ── transport selection (AC2): roaming → tailscale, else the token hint ──
+ const caps = (deps.capabilities ?? (() => []))();
+ const transport = caps.includes("roaming") ? "tailscale" : token.transport_hint;
+
+ // ── write fleet.json (role + canonical ONLY — capabilities go to the row) ──
+ const writeConfig = deps.writeFleetConfig ?? schemaWriteFleetConfig;
+ const configPath = deps.fleetConfigPath ?? fleetTopologyPath();
+ writeConfig({ role: "client", canonical }, configPath);
+
+ const machineId = (deps.machineId ?? (() => hostname()))();
+ const rawHostname = hostname();
+ const runner = deps.commandRunner ?? defaultCommandRunner;
+ const readSetting = deps.readDeviceSetting ?? defaultReadDeviceSetting;
+
+ // ── device identity, resolved per field (ADR 0028 precedence) ──
+ // name: setting override → OS detection (machineName seam) → prettified hostname
+ // device_type: setting override → OS detection (deviceType seam) → undefined (honest omit)
+ const nameOverride = readSetting("amicode.device.name");
+ const detectedName = (deps.machineName ?? (() => detectDeviceNameVia(runner, rawHostname, process.platform)))();
+ const name = nameOverride && nameOverride.trim() !== "" ? nameOverride.trim() : detectedName;
+
+ const typeOverride = readSetting("amicode.device.type");
+ const detectedType = (deps.deviceType ?? (() => detectDeviceTypeVia(runner, process.platform)))();
+ const deviceType = typeOverride && typeOverride.trim() !== "" ? typeOverride.trim() : detectedType;
+
+ const now = (deps.now ?? (() => new Date().toISOString()))();
+
+ // ── seed lifecycle-admin authority (#1541, ADR 0034 D3) ──
+ // Record the enroller (the canonical server) as THIS machine's lifecycle-admin
+ // authority — the net-new persistence that makes a headless target approvable
+ // from a UI-bearing authority machine (the target only ever ENFORCES). It runs
+ // AFTER the pin check + fleet.json commit (a refused enroll returns before this
+ // and seeds nothing) and is a pure side-addition — the enroll result, roster
+ // row, and join token are byte-identical to before. The authority's identity
+ // anchor is the canonical server's stable id (keypair fingerprints are not
+ // minted at enroll yet — #1477's rollout will thread a real fingerprint here).
+ (deps.recordLifecycleAuthority ?? ((rec: LifecycleAuthorityRecord) => schemaRecordLifecycleAuthority(rec)))({
+ targetMachineId: machineId,
+ authorityMachineId: canonical.host,
+ authorityIdentityKey: canonical.host,
+ recordedAt: now,
+ });
+
+ // ── register the roster row (#1318 POST /amicode/roster), provisional ──
+ const provisionalRow: RosterRow = {
+ machine_id: machineId,
+ name,
+ server_mode: "client",
+ capabilities: caps,
+ sshAlias: canonical.sshAlias,
+ transport,
+ last_report: now,
+ health: "reachable",
+ ...(deviceType ? { device_type: deviceType } : {}),
+ };
+ await postRosterRow(canonical, undefined, provisionalRow, fetchImpl);
+
+ // ── set the transport, then run the installer (guard + client tunnel) ──
+ (deps.setTransport ?? defaultSetTransport)(transport);
+ const inst = (deps.runInstaller ?? defaultRunInstaller)("client");
+
+ // ── VERIFY ATTACH (AC3): probe the just-set transport ──
+ const resolveOrigin = deps.resolveProbeOrigin ?? defaultResolveProbeOrigin;
+ const verify = await runVerifyAttach(resolveOrigin, transport, canonical, token, fetchImpl, deps.retryDelayMs);
+
+ const result: EnrollResult = {
+ machine_id: machineId,
+ name,
+ server_mode: "client",
+ capabilities: caps,
+ transport,
+ verify_attach: verify.ok ? { ok: true } : { ok: false, cause: verify.cause },
+ };
+
+ if (verify.ok) {
+ return {
+ json: {
+ verb: "fleet",
+ subcommand: "enroll",
+ ok: true,
+ result,
+ installer_ok: inst.ok,
+ note: "client enrolled — fleet.json written, roster row registered, transport set, guard installed, verify-attach passed",
+ },
+ code: 0,
+ };
+ }
+
+ // ── honest failure: reflect it on the roster row, do NOT report success ──
+ const failedHealth: RosterHealth = verify.cause === "transport-down" ? "down" : "degraded";
+ await postRosterRow(
+ canonical,
+ undefined,
+ { ...provisionalRow, health: failedHealth, last_report: (deps.now ?? (() => new Date().toISOString()))() },
+ fetchImpl,
+ );
+ return {
+ json: {
+ verb: "fleet",
+ subcommand: "enroll",
+ ok: false,
+ result,
+ cause: verify.cause,
+ fix: verify.fix,
+ roster_health: failedHealth,
+ installer_ok: inst.ok,
+ note: "verify-attach FAILED — the roster row's health reflects it and success is NOT reported (no silent fallback)",
+ },
+ code: 65,
+ };
+}
+
+type VerifyOutcome = { ok: true } | { ok: false; cause: string; fix: string };
+
+async function runVerifyAttach(
+ resolveOrigin: (transport: string, canonical: JoinTokenCanonical) => ProbeOriginResult,
+ transport: string,
+ canonical: JoinTokenCanonical,
+ token: JoinToken,
+ fetchImpl: typeof fetch,
+ retryDelays: number[] = [1000, 2000, 4000],
+): Promise {
+ const originR = resolveOrigin(transport, canonical);
+ if (!originR.ok) return { ok: false, cause: originR.cause, fix: originR.fix };
+
+ const maxAttempts = 1 + retryDelays.length; // first try + retries
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
+ const probe = await probeHealth(originR.origin, undefined, fetchImpl);
+
+ // Only "unreachable" (transport-down) retries — auth rejections, pin
+ // mismatches, and other non-transport failures fail immediately.
+ if ("error" in probe) {
+ if (attempt < retryDelays.length) {
+ await new Promise((resolve) => setTimeout(resolve, retryDelays[attempt]));
+ continue;
+ }
+ // Exhausted retries
+ return {
+ ok: false,
+ cause: "transport-down",
+ fix: `the ${transport} transport to ${canonical.host}:${canonical.port} is not reachable — confirm the tunnel/tailscale is up, then re-run enroll`,
+ };
+ }
+ if (probe.status === 401 || probe.status === 403) {
+ return {
+ ok: false,
+ cause: "auth-rejected",
+ fix: "the hub rejected the Fleet token — re-mint it on the server (`amico fleet enroll --as-server`) and re-enroll with the fresh join token",
+ };
+ }
+ if (probe.status !== 200) {
+ return {
+ ok: false,
+ cause: "transport-down",
+ fix: `the transport reached the host but it answered HTTP ${probe.status} on /global/health — confirm the hub is healthy, then re-run enroll`,
+ };
+ }
+ // 200 but a pin that does not match the host = reached the WRONG/updated host.
+ if (probe.version !== null && !versionSkewVerdict(token.pin_version, probe.version).agree) {
+ return {
+ ok: false,
+ cause: "pin-mismatch",
+ fix: `verify-attach reached a host reporting ${probe.version} but the token pins ${token.pin_version} — the transport points at the wrong host or it was upgraded; re-mint the join token`,
+ };
+ }
+ return { ok: true };
+ }
+
+ // Unreachable — the loop always returns — but TypeScript needs it.
+ return {
+ ok: false,
+ cause: "transport-down",
+ fix: `the ${transport} transport to ${canonical.host}:${canonical.port} is not reachable — confirm the tunnel/tailscale is up, then re-run enroll`,
+ };
+}
+
+// ── the enroll verb body ───────────────────────────────────────────────────
+export async function fleetEnroll(argv: string[], deps: FleetEnrollDeps = {}): Promise {
+ if (argv.includes("--as-server")) {
+ return enrollAsServer(argv, deps);
+ }
+
+ const tokenPath = flagValue(argv, "--join-token");
+ const tokenJson = flagValue(argv, "--join-token-json");
+ let raw: string | null = null;
+ if (tokenJson !== undefined) {
+ raw = tokenJson;
+ } else if (tokenPath !== undefined) {
+ raw = (deps.readJoinTokenFile ?? defaultReadJoinTokenFile)(tokenPath);
+ if (raw === null) return fail([`join token file not found or unreadable: ${tokenPath}`]);
+ } else {
+ return fail([
+ "enroll needs a role: `amico fleet enroll --as-server` (mint a join token) or `amico fleet enroll --join-token ` (redeem one)",
+ ]);
+ }
+
+ const token = parseJoinToken(raw);
+ if (token === null) {
+ return fail([
+ "the join token is malformed — expected { canonical{host,port,sshAlias}, fleet_token, transport_hint, pin_version }",
+ ]);
+ }
+ return enrollAsClient(argv, token, deps);
+}
diff --git a/packages/amico-run/src/fleet_projection_verb.ts b/packages/amico-run/src/fleet_projection_verb.ts
index 60e67e2d2..4f1239b76 100644
--- a/packages/amico-run/src/fleet_projection_verb.ts
+++ b/packages/amico-run/src/fleet_projection_verb.ts
@@ -32,6 +32,7 @@ import { spawnSync } from "node:child_process";
import * as fs from "node:fs";
import { mkdtempSync, rmSync } from "node:fs";
import { homedir, tmpdir } from "node:os";
+import { randomUUID } from "node:crypto";
import * as path from "node:path";
import {
FleetContractVersionError,
@@ -41,6 +42,8 @@ import {
renderFleetStatus,
fleetProjectionCachePath,
fleetTopologyPath,
+ buildBaseProjection,
+ parseFleetTopology,
type FleetProjection,
} from "@amicode/schema";
import { PREMIUM_CODE, readCodes } from "./premium.js";
@@ -88,6 +91,14 @@ export interface FleetProjectionDeps {
/** #1106: the cache write (injectable). Default: mkdir -p + atomic
* tmp+rename, mirroring the extension's writeFleetConfig discipline. */
writeCache?: (p: string, content: string) => void;
+ /** ADR 0023 (base tier): the STABLE per-machine epoch source. Default:
+ * read-or-mint `~/.amico/ops/fleet/base_epoch`. Injected in tests. */
+ baseEpoch?: () => string;
+ /** ADR 0023: the monotonic publish counter. Default: a persisted counter
+ * seeded from the publish wall-second. */
+ baseCounter?: () => number;
+ /** ADR 0023: the ISO publish stamp (provenance only). Default: now. */
+ nowIso?: () => string;
}
function flagValue(argv: string[], name: string): string | undefined {
@@ -150,6 +161,171 @@ function bootstrap(reason: "entitlement" | "checkout", rendered: string, extra:
};
}
+/** The default atomic cache writer — mkdir -p + tmp+rename. Shared by the
+ * amicissimo success path and the base-tier producer. */
+function defaultWriteCache(p: string, content: string): void {
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const tmpFile = `${p}.${process.pid}.${randomUUID()}.tmp`;
+ try {
+ fs.writeFileSync(tmpFile, content);
+ fs.renameSync(tmpFile, p);
+ } finally {
+ fs.rmSync(tmpFile, { force: true });
+ }
+}
+
+/** The base-tier epoch file — a stable per-machine UUID, minted once and reused
+ * so freshness comparisons stay within one epoch (ADR 0023). */
+function baseEpochPath(): string {
+ return path.join(homedir(), ".amico", "ops", "fleet", "base_epoch");
+}
+
+/** Read-or-mint the stable base-tier epoch (production default; tests inject
+ * deps.baseEpoch). Exclusive creation makes concurrent first publishes agree
+ * on the one epoch that was actually persisted. */
+function defaultBaseEpoch(): string {
+ const p = baseEpochPath();
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const epoch = randomUUID();
+ try {
+ fs.writeFileSync(p, epoch, { encoding: "utf8", flag: "wx" });
+ return epoch;
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
+ const existing = fs.readFileSync(p, "utf8").trim();
+ if (existing.length === 0) throw new Error(`base-tier epoch file is empty: ${p}`);
+ return existing;
+ }
+}
+
+function baseCounterPath(): string {
+ return path.join(homedir(), ".amico", "ops", "fleet", "base_counter.json");
+}
+
+interface BaseCounterLease {
+ counter: number;
+ release: () => void;
+}
+
+/** Hold the counter lock through projection publication, so concurrent callers
+ * cannot publish a lower reserved counter after a higher one. */
+function acquireBaseCounterLock(lockPath: string): () => void {
+ const token = `${process.pid}:${randomUUID()}`;
+ const deadline = process.hrtime.bigint() + 5_000_000_000n;
+ for (;;) {
+ try {
+ fs.writeFileSync(lockPath, token, { encoding: "utf8", flag: "wx" });
+ return () => {
+ if (fs.readFileSync(lockPath, "utf8") === token) fs.rmSync(lockPath, { force: true });
+ };
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
+ if (process.hrtime.bigint() >= deadline) {
+ throw new Error(`timed out acquiring base-tier counter lock: ${lockPath}`);
+ }
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
+ }
+ }
+}
+
+/** Persist and reserve the next counter for this epoch. The returned lease is
+ * released only after the projection cache has been published. */
+function defaultBaseCounter(epoch: string): BaseCounterLease {
+ const p = baseCounterPath();
+ fs.mkdirSync(path.dirname(p), { recursive: true });
+ const release = acquireBaseCounterLock(`${p}.lock`);
+ try {
+ let last: number | undefined;
+ try {
+ const state = JSON.parse(fs.readFileSync(p, "utf8")) as { epoch?: unknown; counter?: unknown };
+ if (state.epoch !== epoch) {
+ last = undefined;
+ } else if (Number.isSafeInteger(state.counter) && (state.counter as number) >= 0) {
+ last = state.counter as number;
+ } else {
+ throw new Error(`base-tier counter file is invalid: ${p}`);
+ }
+ } catch (e) {
+ if ((e as NodeJS.ErrnoException).code !== "ENOENT") throw e;
+ }
+ const wallSecond = Math.floor(Date.now() / 1000);
+ const counter = last === undefined ? wallSecond : Math.max(wallSecond, last + 1);
+ if (!Number.isSafeInteger(counter)) throw new Error(`base-tier counter exhausted: ${p}`);
+ defaultWriteCache(p, JSON.stringify({ epoch, counter }) + "\n");
+ return { counter, release };
+ } catch (e) {
+ release();
+ throw e;
+ }
+}
+
+/** ADR 0023 — the base-tier producer. When the amicissimo authority is
+ * unavailable (no entitlement / no checkout) but the machine carries a
+ * fleet.json declaring a real role (client/server), render + cache a minimal
+ * contract-v1 projection from that membership file and return success, so the
+ * base product still gets an enforced client. Returns null when there is no
+ * usable enrolled role — the caller then keeps the honest bootstrap (exit 75).
+ * This is a PRODUCER: consumers still read the cached projection through the
+ * ONE reader, unchanged. */
+function tryBaseTierProjection(
+ deps: FleetProjectionDeps,
+ reason: "entitlement" | "checkout",
+): VerbResult | null {
+ const topologyPath = deps.topologyPath ?? fleetTopologyPath();
+ const read = deps.readFile ?? ((p: string) => (fs.existsSync(p) ? fs.readFileSync(p, "utf8") : null));
+ const raw = read(topologyPath);
+ if (raw === null) return null;
+ const topo = parseFleetTopology(raw);
+ if (topo === null) return null;
+ // Only an ENROLLED role produces a base projection; standalone/unknown keeps
+ // the existing bootstrap-75 behavior verbatim (minimal blast radius).
+ if (topo.role !== "client" && topo.role !== "server") return null;
+
+ const epoch = (deps.baseEpoch ?? defaultBaseEpoch)();
+ const lease = deps.baseCounter === undefined
+ ? defaultBaseCounter(epoch)
+ : { counter: deps.baseCounter(), release: () => {} };
+ try {
+ const publishedAt = (deps.nowIso ?? (() => new Date().toISOString()))();
+ const proj = buildBaseProjection(topo, { epoch, counter: lease.counter, publishedAt });
+ const published = JSON.stringify(proj, null, 2);
+
+ const cachePath = deps.cachePath ?? fleetProjectionCachePath();
+ const writeCache = deps.writeCache ?? defaultWriteCache;
+ writeCache(cachePath, published);
+
+ const topology = objectValue(proj, "topology");
+ const canonical = objectField(topology, "canonical");
+ return {
+ json: {
+ verb: "fleet",
+ subcommand: "status",
+ projection: true,
+ ok: true,
+ base_tier: true,
+ reason,
+ mode: scalarOrBase(proj, "mode", "standalone"),
+ posture: scalarOrBase(proj, "posture", "ok"),
+ ...(topology === undefined ? {} : { role: topology.role }),
+ ...(canonical === undefined ? {} : { canonical }),
+ cache_path: cachePath,
+ publisher: proj.publisher ?? {},
+ sections: proj.sections ?? {},
+ freshness: { counter: proj.freshness?.counter, hub_epoch: proj.freshness?.hub_epoch },
+ summary: renderFleetStatus(proj),
+ note:
+ "base-tier projection (ADR 0023) — the amicissimo authority is unavailable ("
+ + reason
+ + "); this projection was rendered from the machine's fleet.json by the amicode base-tier producer (a public floor, not the authority). amicissimo stays canonical when present; every consumer reads this through the ONE reader, unchanged. Cached at "
+ + cachePath,
+ },
+ code: 0,
+ };
+ } finally {
+ lease.release();
+ }
+}
+
/** A section's carried value, with the base default applied when the section
* is absent (mode absent = standalone, posture absent = ok — the projection
* contract's additive-optional discipline; the base default is applied, not
@@ -201,6 +377,11 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
"",
"(bootstrap exception, exit 75 — distinct from success and from usage; see `amico premium`)",
].join("\n");
+ // ADR 0023: an enrolled machine without amicissimo still gets an honest
+ // base-tier projection from its fleet.json (so the guard enforces client).
+ // Only standalone/unenrolled falls through to the bootstrap exception.
+ const baseTier = tryBaseTierProjection(deps, "entitlement");
+ if (baseTier !== null) return baseTier;
return bootstrap("entitlement", rendered, { config: configFile });
}
@@ -216,6 +397,10 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
"",
"(bootstrap exception, exit 75 — distinct from success and from usage)",
].join("\n");
+ // ADR 0023: entitled but no checkout — the authority still can't run, so an
+ // enrolled machine gets the base-tier floor from fleet.json all the same.
+ const baseTier = tryBaseTierProjection(deps, "checkout");
+ if (baseTier !== null) return baseTier;
return bootstrap("checkout", rendered, { checkout });
}
@@ -277,14 +462,7 @@ export function fleetProjectionStatus(argv: string[], deps: FleetProjectionDeps
// contract version never clobbers the consumers' artifact. The cached
// bytes are the publisher's own output, verbatim.
const cachePath = deps.cachePath ?? fleetProjectionCachePath();
- const writeCache =
- deps.writeCache ??
- ((p: string, content: string) => {
- fs.mkdirSync(path.dirname(p), { recursive: true });
- const tmpFile = `${p}.tmp`;
- fs.writeFileSync(tmpFile, content);
- fs.renameSync(tmpFile, p);
- });
+ const writeCache = deps.writeCache ?? defaultWriteCache;
writeCache(cachePath, published);
// ── the additive machine fields for script consumers (#1106) ──
diff --git a/packages/amico-run/src/fleet_revoke_verb.ts b/packages/amico-run/src/fleet_revoke_verb.ts
new file mode 100644
index 000000000..add7a613f
--- /dev/null
+++ b/packages/amico-run/src/fleet_revoke_verb.ts
@@ -0,0 +1,162 @@
+// `amico fleet revoke ` (#1438, ADR 0032 §D5) — the revocation +
+// rotation surface. Distrusting one machine no longer re-keys the fleet:
+//
+// 1. LOCAL expulsion — drop this machine's issued grant for X AND add X to
+// the mint-list bar (§D4), on ~/.amico/fleet-peer-tokens.json. This is
+// authoritative for THIS machine (single-writer of the registry granting
+// access to itself) and always applies.
+// 2. FAN-OUT — because a machine is the single writer of its own registry,
+// expelling X fleet-wide means EVERY serving peer drops its own issued
+// entry for X. This verb fans the revocation out to each serving peer's
+// receiving endpoint (POST /amicode/fleet/revoke?machine_id=X); each peer
+// applies it to its OWN registry (no machine writes another's).
+//
+// amico-run cannot import the extension's fleet_issued_tokens.ts, so the LOCAL
+// registry mutation is a small self-contained writer over the SAME file shape
+// (the githubAppConfigFile cross-package-duplication precedent). The roster read
+// is shared via @amicode/schema.
+import * as fs from "node:fs";
+import * as path from "node:path";
+import {
+ fleetRosterCachePath,
+ parseRosterDocument,
+ placementDescriptor,
+ type RosterRow,
+} from "@amicode/schema";
+import type { VerbResult } from "./verbs.js";
+
+/** A serving+reachable peer the fan-out targets. */
+export interface ServingPeer {
+ machine_id: string;
+ origin: string;
+}
+
+export interface FanOutResult {
+ peer: string;
+ ok: boolean;
+ error?: string;
+}
+
+export interface FleetRevokeDeps {
+ /** The local issued-token registry. Default: ~/.amico/fleet-peer-tokens.json
+ * (env $AMICO_FLEET_ISSUED_TOKEN_FILE). */
+ registryFile?: string;
+ /** ISO clock for the revoked_at stamp. Default: now. */
+ now?: () => string;
+ /** Resolve the serving+reachable peers to fan out to. Default: read the
+ * host-owned roster and keep serving∧reachable rows that carry a peer_origin. */
+ listServingPeers?: () => Promise;
+ /** Fan a revocation out to ONE peer. Default: POST
+ * /amicode/fleet/revoke?machine_id=X. */
+ fanOutRevoke?: (peer: ServingPeer, machineId: string) => Promise;
+ /** The HTTP impl for the default fan-out. Default: globalThis.fetch. */
+ fetchImpl?: typeof fetch;
+}
+
+const STORE_VERSION = 1;
+
+function fail(errors: string[]): VerbResult {
+ return { json: { verb: "fleet", subcommand: "revoke", ok: false, errors }, code: 64 };
+}
+
+function issuedRegistryPath(deps: FleetRevokeDeps): string {
+ if (deps.registryFile) return deps.registryFile;
+ const env = process.env.AMICO_FLEET_ISSUED_TOKEN_FILE;
+ if (env && env.trim() !== "") return env;
+ return path.join(require("node:os").homedir(), ".amico", "fleet-peer-tokens.json");
+}
+
+/** LOCAL expulsion: drop issued[X], set revoked[X] — atomic 0600, preserving
+ * unknown top-level keys (the same file contract the extension's registry
+ * writer honors). */
+function revokeLocal(file: string, machineId: string, now: () => string): void {
+ let doc: Record = {};
+ try {
+ if (fs.existsSync(file)) {
+ const parsed = JSON.parse(fs.readFileSync(file, "utf8")) as unknown;
+ if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) doc = parsed as Record;
+ }
+ } catch {
+ doc = {};
+ }
+ const issued = { ...((typeof doc.issued === "object" && doc.issued !== null ? doc.issued : {}) as Record) };
+ delete issued[machineId];
+ const revoked = { ...((typeof doc.revoked === "object" && doc.revoked !== null ? doc.revoked : {}) as Record) };
+ revoked[machineId] = { revoked_at: now() };
+ const out = { ...doc, store_version: STORE_VERSION, issued, revoked };
+ fs.mkdirSync(path.dirname(file), { recursive: true });
+ const tmp = `${file}.${process.pid}.tmp`;
+ try {
+ fs.writeFileSync(tmp, JSON.stringify(out, null, 2) + "\n", { mode: 0o600 });
+ fs.chmodSync(tmp, 0o600);
+ fs.renameSync(tmp, file);
+ } finally {
+ fs.rmSync(tmp, { force: true });
+ }
+}
+
+/** Default serving-peer resolution: the host-owned roster's serving∧reachable
+ * rows that self-report a peer_origin (the HTTP target the fan-out reaches). */
+function defaultListServingPeers(): ServingPeer[] {
+ const rosterPath = fleetRosterCachePath();
+ if (!fs.existsSync(rosterPath)) return [];
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(fs.readFileSync(rosterPath, "utf8"));
+ } catch {
+ return [];
+ }
+ const doc = parseRosterDocument(parsed);
+ if (!doc.ok) return [];
+ const peers: ServingPeer[] = [];
+ for (const row of doc.doc.rows as RosterRow[]) {
+ const d = placementDescriptor(row);
+ if (!d.serving || !d.reachable) continue;
+ const origin = typeof row.peer_origin === "string" ? row.peer_origin.trim() : "";
+ if (origin === "") continue; // SSH-only peers have no direct HTTP origin (out of scope here)
+ peers.push({ machine_id: row.machine_id, origin });
+ }
+ return peers;
+}
+
+async function defaultFanOutRevoke(peer: ServingPeer, machineId: string, fetchImpl: typeof fetch): Promise {
+ const url = `${peer.origin.replace(/\/+$/, "")}/amicode/fleet/revoke?machine_id=${encodeURIComponent(machineId)}`;
+ try {
+ const res = await fetchImpl(url, { method: "POST" });
+ return res.ok ? { peer: peer.origin, ok: true } : { peer: peer.origin, ok: false, error: `HTTP ${res.status}` };
+ } catch (e) {
+ return { peer: peer.origin, ok: false, error: (e as Error).message };
+ }
+}
+
+export async function fleetRevoke(argv: string[], deps: FleetRevokeDeps = {}): Promise {
+ const machineId = (argv[0] ?? "").trim();
+ if (machineId === "" || machineId.startsWith("-")) {
+ return fail(["`amico fleet revoke ` requires a machine_id to revoke"]);
+ }
+
+ // 1. LOCAL expulsion (authoritative for this machine; always applies).
+ revokeLocal(issuedRegistryPath(deps), machineId, deps.now ?? (() => new Date().toISOString()));
+
+ // 2. FAN-OUT to every serving peer (each applies it to its own registry).
+ const fetchImpl = deps.fetchImpl ?? (globalThis.fetch as typeof fetch);
+ const peers = await (deps.listServingPeers ?? (async () => defaultListServingPeers()))();
+ const fan = deps.fanOutRevoke ?? ((peer, id) => defaultFanOutRevoke(peer, id, fetchImpl));
+ const fanned_out = await Promise.all(peers.map((p) => fan(p, machineId)));
+
+ const allOk = fanned_out.every((f) => f.ok);
+ return {
+ json: {
+ verb: "fleet",
+ subcommand: "revoke",
+ ok: true, // local expulsion succeeded; per-peer fan-out status is itemized
+ machine_id: machineId,
+ revoked_local: true,
+ fanned_out,
+ note: allOk
+ ? "revoked locally (dropped + mint-barred) and fanned out to every serving peer"
+ : "revoked locally (dropped + mint-barred); one or more peers could not be reached — re-run once they are up (their tokens for this machine still authenticate there until then)",
+ },
+ code: 0,
+ };
+}
diff --git a/packages/amico-run/src/fleet_verb.ts b/packages/amico-run/src/fleet_verb.ts
index 65e02b0fd..7d0d59c8f 100644
--- a/packages/amico-run/src/fleet_verb.ts
+++ b/packages/amico-run/src/fleet_verb.ts
@@ -591,6 +591,8 @@ const USAGE =
"amico fleet re-tier --session --model [--variant ] | amico fleet sweep [--dry-run] | " +
"amico fleet launch --session --pid | " +
'amico fleet finish --session --outcome settled|crashed --pid [--step ""] | ' +
+ "amico fleet enroll --as-server [--host H --port N --ssh-alias A --transport-hint T] | " +
+ "amico fleet enroll --join-token | " +
"amico fleet digest [--post ] [--machines a,b] [--jobs-line \"\"] [--dry-run] [--root D] | " +
"amico fleet shard-watch [--clients a,b] [--port
] [--alert-min ] [--db ] [--post ] [--dry-run]";
@@ -620,6 +622,9 @@ export function fleetVerb(argv: string[], deps: FleetVerbDeps = {}): VerbResult
if (sub === "finish") return fleetFinish(rest);
if (sub === "digest") return fleetDigest(rest);
if (sub === "shard-watch") return shardWatch(rest);
+ // NOTE: `enroll` is async (real HTTP) and is dispatched at the registry seam
+ // (verbs.ts `run`), NOT here — so this synchronous router stays synchronous
+ // and its pinned VerbResult contract (and callers) are untouched.
return {
json: { verb: "fleet", error: `unknown subcommand ${sub ? `"${sub}"` : "(none)"}`, usage: USAGE },
code: 64,
diff --git a/packages/amico-run/src/verbs.ts b/packages/amico-run/src/verbs.ts
index 5d392b92a..eb7316e05 100644
--- a/packages/amico-run/src/verbs.ts
+++ b/packages/amico-run/src/verbs.ts
@@ -21,6 +21,8 @@ import { noteVerb } from "./note_verb.js";
import { ledgerVerb } from "./ledger_verb.js";
import { profileVerb } from "./profile_verb.js";
import { fleetVerb } from "./fleet_verb.js";
+import { fleetEnroll } from "./fleet_enroll_verb.js";
+import { fleetRevoke } from "./fleet_revoke_verb.js";
import { specVerb } from "./spec_verb.js";
import { planVerb } from "./plan_verb.js";
import { handoffVerb } from "./handoff_verb.js";
@@ -159,10 +161,20 @@ const profile: Verb = {
const fleet: Verb = {
name: "fleet",
summary:
- "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard, digest as the Slack projection; status --projection reads the fleet-authority projection (entitlement-gated, #1068)",
+ "fleet registry: list/status read verbs, steer/stop/re-tier as signal enqueuers (never a record write), sweep with a pid-liveness guard, digest as the Slack projection; status --projection reads the fleet-authority projection (entitlement-gated, #1068); enroll joins a machine to a fleet (server mint / client redeem, #1319); revoke expels one peer — local drop + mint-list bar + fan-out to serving peers (ADR 0032 §D5, #1438)",
generalizes: "the fleet view + in-chat /fleet + Amico's conversational fleet questions + the Slack digest, over ~/.amico/ops/fleet",
slice: "fleet substrate (§9 step 2)",
- run: fleetVerb,
+ // `enroll` (#1319) is the ONE async sub-verb (real HTTP: roster POST +
+ // verify-attach) and lives in its OWN module (fleet_enroll_verb.ts), DISTINCT
+ // from the session-registry fleetVerb. It is dispatched HERE at the registry
+ // seam so the synchronous fleetVerb router keeps its pinned VerbResult
+ // contract; Verb.run already permits a Promise and both callers await it.
+ run: (args) =>
+ args[0] === "enroll"
+ ? fleetEnroll(args.slice(1))
+ : args[0] === "revoke"
+ ? fleetRevoke(args.slice(1))
+ : fleetVerb(args),
};
// spec — the deliberation front half: adversarially review a Spec before it compiles to a
diff --git a/packages/amico-run/test/fleet_discover.test.ts b/packages/amico-run/test/fleet_discover.test.ts
new file mode 100644
index 000000000..20767c31a
--- /dev/null
+++ b/packages/amico-run/test/fleet_discover.test.ts
@@ -0,0 +1,147 @@
+// fleet_discover.test.ts (#1320) — the read-only discovery helper that backs
+// the `/create-a-fleet` orchestrator. `discoverFleetCandidates` is PURE: it
+// takes already-gathered inputs (tailnet peers, parsed ~/.ssh/config hosts, the
+// #1318 roster rows) and enumerates the candidate machines, deduped, mutating
+// nothing. No filesystem, no network — the skill gathers the inputs; this helper
+// only reasons over them. A NEW suite, sibling of fleet_enroll_verb.test.ts.
+//
+// Run: pnpm --filter @amicode/amico-run test fleet_discover
+import { describe, it, expect } from "vitest";
+import type { RosterRow } from "@amicode/schema";
+import { discoverFleetCandidates } from "../src/fleet_discover.js";
+
+/** Build a full, lawful RosterRow (#1318 shape) from a partial override. */
+function rosterRow(over: Partial & { machine_id: string }): RosterRow {
+ return {
+ machine_id: over.machine_id,
+ name: over.name ?? over.machine_id,
+ server_mode: over.server_mode ?? "client",
+ capabilities: over.capabilities ?? [],
+ sshAlias: over.sshAlias ?? "",
+ transport: over.transport ?? "ssh",
+ last_report: over.last_report ?? "2026-09-20T00:00:00.000Z",
+ health: over.health ?? "reachable",
+ };
+}
+
+describe("discoverFleetCandidates — enumeration", () => {
+ it("enumerates a machine from each source, tagged with the source it came from", () => {
+ const result = discoverFleetCandidates({
+ tailnet: [{ hostName: "aurora", tailscaleIP: "100.64.0.1", online: true }],
+ sshConfig: [{ alias: "borealis", hostName: "borealis.local" }],
+ roster: [rosterRow({ machine_id: "cid", name: "cygnus", sshAlias: "cygnus", health: "reachable" })],
+ });
+
+ expect(result).toHaveLength(3);
+ const byName = Object.fromEntries(result.map((c) => [c.name, c]));
+ expect(byName.aurora.sources).toEqual(["tailnet"]);
+ expect(byName.borealis.sources).toEqual(["ssh-config"]);
+ expect(byName.cygnus.sources).toEqual(["roster"]);
+ });
+});
+
+describe("discoverFleetCandidates — dedupe across sources", () => {
+ it("folds one machine seen in all three sources into a single candidate, merging sources + reach hints", () => {
+ const result = discoverFleetCandidates({
+ // The same machine, named consistently across sources (tailnet HostName ==
+ // ssh alias == roster name), with a MagicDNS suffix on the tailnet name to
+ // exercise the first-DNS-label normalization.
+ tailnet: [{ hostName: "mini.tail-abcd.ts.net.", tailscaleIP: "100.64.0.9", online: true }],
+ sshConfig: [{ alias: "mini", hostName: "mini.local" }],
+ roster: [rosterRow({ machine_id: "mini-id", name: "mini", sshAlias: "mini", health: "reachable" })],
+ });
+
+ expect(result).toHaveLength(1);
+ const [c] = result;
+ // sources deduped + in canonical order (roster, ssh-config, tailnet).
+ expect(c.sources).toEqual(["roster", "ssh-config", "tailnet"]);
+ expect(c.id).toBe("mini");
+ // reach hints merged from whichever sources carried them.
+ expect(c.sshAlias).toBe("mini");
+ expect(c.address).toBe("100.64.0.9");
+ });
+
+ it("keeps distinct machines distinct even when some share a source", () => {
+ const result = discoverFleetCandidates({
+ tailnet: [
+ { hostName: "mini", tailscaleIP: "100.64.0.9" },
+ { hostName: "studio", tailscaleIP: "100.64.0.10" },
+ ],
+ sshConfig: [{ alias: "mini" }],
+ roster: [],
+ });
+ expect(result.map((c) => c.id)).toEqual(["mini", "studio"]);
+ expect(result.find((c) => c.id === "mini")!.sources).toEqual(["ssh-config", "tailnet"]);
+ expect(result.find((c) => c.id === "studio")!.sources).toEqual(["tailnet"]);
+ });
+});
+
+describe("discoverFleetCandidates — read-only (mutates nothing)", () => {
+ /** Recursively freeze so ANY write to an input throws in strict mode. */
+ function deepFreeze(o: T): T {
+ if (o && typeof o === "object") {
+ for (const v of Object.values(o)) deepFreeze(v);
+ Object.freeze(o);
+ }
+ return o;
+ }
+
+ it("does not read-through-and-write: frozen inputs survive, deep-equal to a pre-call snapshot", () => {
+ const input = {
+ tailnet: [{ hostName: "mini.tail-abcd.ts.net.", tailscaleIP: "100.64.0.9", online: true }],
+ sshConfig: [{ alias: "mini", hostName: "mini.local" }],
+ roster: [rosterRow({ machine_id: "mini-id", name: "mini", sshAlias: "mini", health: "reachable" })],
+ };
+ const snapshot = structuredClone(input);
+ deepFreeze(input);
+
+ // The call must not throw (no write to a frozen input) and must produce a result.
+ const result = discoverFleetCandidates(input);
+ expect(result.length).toBeGreaterThan(0);
+
+ // The inputs are byte-for-byte what they were.
+ expect(input).toEqual(snapshot);
+ });
+});
+
+describe("discoverFleetCandidates — enrolled state + the simulated gap", () => {
+ it("marks a machine enrolled ONLY when its roster row is healthy; a gap machine stays enumerated but unenrolled", () => {
+ const result = discoverFleetCandidates({
+ // hub: enrolled server (verify-attach passed → health reachable).
+ // gap: discovered on the wire but NO roster row (no amicode / no link yet).
+ // stuck: has a roster row but verify-attach FAILED (health down).
+ tailnet: [
+ { hostName: "hub", tailscaleIP: "100.64.0.1" },
+ { hostName: "gap", tailscaleIP: "100.64.0.2" },
+ { hostName: "stuck", tailscaleIP: "100.64.0.3" },
+ ],
+ sshConfig: [{ alias: "hub" }, { alias: "gap" }, { alias: "stuck" }],
+ roster: [
+ rosterRow({ machine_id: "hub-id", name: "hub", sshAlias: "hub", server_mode: "server", health: "reachable" }),
+ rosterRow({ machine_id: "stuck-id", name: "stuck", sshAlias: "stuck", server_mode: "client", health: "down" }),
+ ],
+ });
+
+ // guide-and-resume: the gap machine is NOT dropped — all three continue.
+ expect(result.map((c) => c.id)).toEqual(["gap", "hub", "stuck"]);
+ const byId = Object.fromEntries(result.map((c) => [c.id, c]));
+
+ // hub: a healthy roster row → enrolled, with its role + health surfaced.
+ expect(byId.hub).toMatchObject({ inRoster: true, enrolled: true, serverMode: "server", health: "reachable" });
+
+ // gap: no roster row at all → never reported enrolled (nothing to verify yet).
+ expect(byId.gap).toMatchObject({ inRoster: false, enrolled: false });
+ expect(byId.gap.health).toBeUndefined();
+ expect(byId.gap.serverMode).toBeUndefined();
+
+ // stuck: a roster row exists but verify-attach failed → unenrolled until resolved.
+ expect(byId.stuck).toMatchObject({ inRoster: true, enrolled: false, health: "down" });
+ });
+
+ it("a degraded roster row is also not enrolled (only `reachable` clears the honesty bar)", () => {
+ const result = discoverFleetCandidates({
+ roster: [rosterRow({ machine_id: "d-id", name: "deg", health: "degraded" })],
+ });
+ expect(result[0]).toMatchObject({ inRoster: true, enrolled: false, health: "degraded" });
+ });
+});
diff --git a/packages/amico-run/test/fleet_enroll_verb.test.ts b/packages/amico-run/test/fleet_enroll_verb.test.ts
new file mode 100644
index 000000000..9595b147b
--- /dev/null
+++ b/packages/amico-run/test/fleet_enroll_verb.test.ts
@@ -0,0 +1,831 @@
+// fleet_enroll_verb.test.ts (#1319) — the enroll primitive: `amico fleet
+// enroll`. A NEW suite, DISTINCT from the session-registry fleet_verb.test.ts
+// (different domain: enroll writes fleet.json + a roster row; the registry
+// writes neither). Each AC gets its own tracer-bullet cycle.
+//
+// The stub host below mirrors the #1318 host contract (GET/POST /amicode/roster
+// + GET /global/health) as a loopback server, so verify-attach and roster
+// registration run the REAL fetch path — amico-run cannot import the extension's
+// test/support/stub_hub.ts, so this is its sibling.
+import { describe, it, expect, afterEach } from "vitest";
+import * as http from "node:http";
+import { hostname as osHostname } from "node:os";
+import type { AddressInfo } from "node:net";
+import {
+ parseRosterRow,
+ normalizeDeviceName,
+ type RosterRow,
+ type LifecycleAuthorityRecord,
+} from "@amicode/schema";
+import {
+ fleetEnroll,
+ parseJoinToken,
+ detectDeviceTypeVia,
+ detectDeviceNameVia,
+ type CommandRunner,
+ type FleetEnrollDeps,
+ type JoinToken,
+ type EnrollResult,
+} from "../src/fleet_enroll_verb.js";
+
+// VerbResult.json is typed `unknown`; this is the enroll verb's structured shape.
+interface EnrollJson {
+ ok: boolean;
+ result?: EnrollResult;
+ join_token?: JoinToken;
+ join_token_path?: string;
+ cause?: string;
+ fix?: string;
+ errors?: string[];
+ stage?: string;
+ wrote_nothing?: boolean;
+ roster_health?: string;
+ installer_ok?: boolean;
+ self_registered?: boolean;
+ caveat?: string;
+}
+const j = (r: { json: unknown }): EnrollJson => r.json as EnrollJson;
+
+// ── a loopback stub host (the #1318 roster contract + /global/health) ──────────
+interface EnrollStubOptions {
+ version?: string; // GET /global/health → version (default v1.18.29)
+ requireAuth?: string; // 401 unless Authorization matches
+ healthStatus?: number; // force a non-200 on /global/health (auth-rejected etc.)
+ dead?: boolean; // never listen (transport-down); url points at a closed port
+}
+interface EnrollStub {
+ url: string;
+ host: string;
+ port: number;
+ rosterRows: () => RosterRow[];
+ rosterPosts: () => RosterRow[];
+ healthHits: () => number;
+ stop: () => Promise;
+}
+
+async function startEnrollStub(opts: EnrollStubOptions = {}): Promise {
+ const version = opts.version ?? "v1.18.29";
+ const rows: RosterRow[] = [];
+ const posts: RosterRow[] = [];
+ let health = 0;
+ const readBody = (req: http.IncomingMessage): Promise =>
+ new Promise((resolve) => {
+ const chunks: Buffer[] = [];
+ req.on("data", (c) => chunks.push(c as Buffer));
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
+ });
+ const server = http.createServer((req, res) => {
+ if (opts.requireAuth !== undefined && req.headers.authorization !== opts.requireAuth) {
+ res.writeHead(401, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "unauthorized" }));
+ return;
+ }
+ if (req.method === "GET" && req.url?.startsWith("/global/health")) {
+ health += 1;
+ if (opts.healthStatus && opts.healthStatus !== 200) {
+ res.writeHead(opts.healthStatus, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: false }));
+ return;
+ }
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ healthy: true, version }));
+ return;
+ }
+ if (req.method === "GET" && (req.url === "/amicode/roster" || req.url?.startsWith("/amicode/roster?"))) {
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ schema_version: 1, rows }));
+ return;
+ }
+ if (req.method === "POST" && (req.url === "/amicode/roster" || req.url?.startsWith("/amicode/roster?"))) {
+ void readBody(req).then((raw) => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(raw);
+ } catch {
+ res.writeHead(400, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "bad_request: body must be JSON" }));
+ return;
+ }
+ const row = parseRosterRow(parsed);
+ if (!row.ok) {
+ res.writeHead(400, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: false, error: "bad_request: not a well-formed roster row" }));
+ return;
+ }
+ posts.push(row.row);
+ // single-writer upsert by machine_id (mirrors #1318 upsertRosterRow)
+ const i = rows.findIndex((r) => r.machine_id === row.row.machine_id);
+ if (i >= 0) rows[i] = row.row;
+ else rows.push(row.row);
+ res.writeHead(200, { "content-type": "application/json" });
+ res.end(JSON.stringify({ ok: true, machine_id: row.row.machine_id, error: null }));
+ });
+ return;
+ }
+ res.writeHead(404, { "content-type": "application/json" });
+ res.end(JSON.stringify({ message: "not found" }));
+ });
+ if (opts.dead) {
+ // Bind then immediately close: the port is known but nothing listens →
+ // fetch fails with ECONNREFUSED (the honest transport-down probe).
+ const port = await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port));
+ });
+ await new Promise((r) => server.close(() => r()));
+ return {
+ url: `http://127.0.0.1:${port}`,
+ host: "127.0.0.1",
+ port,
+ rosterRows: () => rows,
+ rosterPosts: () => posts,
+ healthHits: () => health,
+ stop: async () => {},
+ };
+ }
+ const port = await new Promise((resolve) => {
+ server.listen(0, "127.0.0.1", () => resolve((server.address() as AddressInfo).port));
+ });
+ return {
+ url: `http://127.0.0.1:${port}`,
+ host: "127.0.0.1",
+ port,
+ rosterRows: () => rows,
+ rosterPosts: () => posts,
+ healthHits: () => health,
+ stop: () => new Promise((r) => server.close(() => r())),
+ };
+}
+
+// A dep bundle that records every side effect and injects a fake identity, so a
+// cycle can assert order + gating without touching the real machine.
+interface Recorder {
+ deps: FleetEnrollDeps;
+ fleetWrites: { config: unknown; path: string }[];
+ joinTokenWrites: { path: string; token: JoinToken }[];
+ installerCalls: string[];
+ transportSets: string[];
+ engine: { spawns: number };
+ authorityRecords: LifecycleAuthorityRecord[];
+}
+function recorder(over: Partial = {}): Recorder {
+ const fleetWrites: { config: unknown; path: string }[] = [];
+ const joinTokenWrites: { path: string; token: JoinToken }[] = [];
+ const installerCalls: string[] = [];
+ const transportSets: string[] = [];
+ const engine = { spawns: 0 };
+ const authorityRecords: LifecycleAuthorityRecord[] = [];
+ const deps: FleetEnrollDeps = {
+ machineId: () => "machine-abc",
+ machineName: () => "workbench",
+ capabilities: () => [],
+ clientVersion: () => "v1.18.29",
+ now: () => "2026-01-01T00:00:00.000Z",
+ fleetConfigPath: "/tmp/nonexistent-enroll/fleet.json",
+ writeFleetConfig: (config, path) => fleetWrites.push({ config, path }),
+ writeJoinToken: (path, token) => joinTokenWrites.push({ path, token }),
+ joinTokenOutPath: "/tmp/nonexistent-enroll/join-token.json",
+ runInstaller: (role) => {
+ installerCalls.push(role);
+ return { ok: true };
+ },
+ setTransport: (kind) => transportSets.push(kind),
+ mintFleetToken: () => "FLEET-SECRET",
+ spawnEngine: () => {
+ engine.spawns += 1;
+ },
+ retryDelayMs: [0, 0, 0],
+ // Keep device-identity detection hermetic by default: no shelling out, no
+ // real settings.json read. Individual cases override these seams.
+ commandRunner: () => "",
+ readDeviceSetting: () => undefined,
+ // Capture the lifecycle-admin authority record (#1541) instead of writing
+ // the real ~/.amico store — keeps every enroll test hermetic.
+ recordLifecycleAuthority: (rec) => authorityRecords.push(rec),
+ ...over,
+ };
+ return { deps, fleetWrites, joinTokenWrites, installerCalls, transportSets, engine, authorityRecords };
+}
+
+const stubs: EnrollStub[] = [];
+afterEach(async () => {
+ while (stubs.length) await stubs.pop()!.stop();
+});
+async function stub(opts?: EnrollStubOptions): Promise {
+ const s = await startEnrollStub(opts);
+ stubs.push(s);
+ return s;
+}
+
+describe("amico fleet enroll --as-server (#1319 AC1)", () => {
+ it("provisions the hub service, mints the Fleet token, and emits a join token bundling {canonical, fleet_token, transport_hint, pin_version}", async () => {
+ const rec = recorder();
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "hub.example", "--port", "4096", "--ssh-alias", "hub", "--transport-hint", "ssh"],
+ rec.deps,
+ );
+ expect(r.code).toBe(0);
+ expect(j(r).ok).toBe(true);
+
+ // fleet.json written role=server + canonical
+ expect(rec.fleetWrites).toHaveLength(1);
+ expect((rec.fleetWrites[0].config as { role: string }).role).toBe("server");
+ expect((rec.fleetWrites[0].config as { canonical: unknown }).canonical).toEqual({
+ host: "hub.example",
+ port: 4096,
+ sshAlias: "hub",
+ });
+
+ // the durable hub service is provisioned via the installer (server role)
+ expect(rec.installerCalls).toContain("server");
+
+ // the Fleet token is minted and the join token emitted with the 4 fields
+ expect(rec.joinTokenWrites).toHaveLength(1);
+ const token = rec.joinTokenWrites[0].token;
+ expect(token.canonical).toEqual({ host: "hub.example", port: 4096, sshAlias: "hub" });
+ expect(token.fleet_token).toBe("FLEET-SECRET");
+ expect(token.transport_hint).toBe("ssh");
+ expect(token.pin_version).toBe("v1.18.29");
+
+ // the emitted join token is also surfaced on the result for #1320 to hand out
+ expect(j(r).join_token).toEqual(token);
+ });
+
+ // #1354 follow-up: minting must pin the host's REAL engine version, not the
+ // placeholder "dev" — a dev-minted token otherwise fails the pin-check against
+ // the server's own 1.18.x engine before verify-attach ever runs.
+ it("pins the host's probed /global/health version when no version is explicitly configured", async () => {
+ const s = await stub({ version: "1.18.29" });
+ const savedEnv = process.env.AMICO_CLIENT_VERSION;
+ delete process.env.AMICO_CLIENT_VERSION;
+ try {
+ // No clientVersion injected → the mint must probe the local hub (127.0.0.1:port),
+ // NOT fall back to "dev". The stub answers /global/health with 1.18.29.
+ const rec = recorder({ clientVersion: undefined });
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub", "--transport-hint", "ssh"],
+ rec.deps,
+ );
+ expect(r.code).toBe(0);
+ expect(rec.joinTokenWrites).toHaveLength(1);
+ expect(rec.joinTokenWrites[0].token.pin_version).toBe("1.18.29");
+ } finally {
+ if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION;
+ else process.env.AMICO_CLIENT_VERSION = savedEnv;
+ }
+ });
+
+ it("falls back to \"dev\" when the local hub is unreachable at mint time", async () => {
+ const savedEnv = process.env.AMICO_CLIENT_VERSION;
+ delete process.env.AMICO_CLIENT_VERSION;
+ try {
+ const rec = recorder({ clientVersion: undefined });
+ // --port points at a closed port → the mint-time probe is unreachable → "dev".
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "hub.example", "--port", "1", "--ssh-alias", "hub"],
+ rec.deps,
+ );
+ expect(r.code).toBe(0);
+ expect(rec.joinTokenWrites[0].token.pin_version).toBe("dev");
+ } finally {
+ if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION;
+ else process.env.AMICO_CLIENT_VERSION = savedEnv;
+ }
+ });
+
+ it("lets an explicit AMICO_CLIENT_VERSION override win over the probe", async () => {
+ const s = await stub({ version: "1.18.29" });
+ const savedEnv = process.env.AMICO_CLIENT_VERSION;
+ process.env.AMICO_CLIENT_VERSION = "operator-pin";
+ try {
+ const rec = recorder({ clientVersion: undefined });
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"],
+ rec.deps,
+ );
+ expect(r.code).toBe(0);
+ expect(rec.joinTokenWrites[0].token.pin_version).toBe("operator-pin");
+ } finally {
+ if (savedEnv === undefined) delete process.env.AMICO_CLIENT_VERSION;
+ else process.env.AMICO_CLIENT_VERSION = savedEnv;
+ }
+ });
+});
+
+// A ready-to-redeem join token pointing at a stub host.
+function tokenFor(s: EnrollStub, over: Partial = {}): JoinToken {
+ return {
+ canonical: { host: s.host, port: s.port, sshAlias: "hub" },
+ fleet_token: "FLEET-SECRET",
+ transport_hint: "ssh",
+ pin_version: "v1.18.29",
+ ...over,
+ };
+}
+
+describe("amico fleet enroll — client redeem happy path (#1319 AC2)", () => {
+ it("writes fleet.json (role+canonical ONLY), registers the roster row, sets the transport, runs the installer, and reports success after verify-attach", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder();
+ const token = tokenFor(s);
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps);
+
+ expect(r.code).toBe(0);
+ expect(j(r).ok).toBe(true);
+
+ // fleet.json = role + canonical ONLY (no capabilities smuggled in)
+ expect(rec.fleetWrites).toHaveLength(1);
+ expect(rec.fleetWrites[0].config).toEqual({ role: "client", canonical: token.canonical });
+
+ // the roster row registered on the host, contract-valid, server_mode=client
+ expect(s.rosterRows()).toHaveLength(1);
+ const row = s.rosterRows()[0];
+ expect(row.machine_id).toBe("machine-abc");
+ expect(row.name).toBe("workbench");
+ expect(row.server_mode).toBe("client");
+ expect(row.transport).toBe("ssh");
+ expect(row.sshAlias).toBe("hub");
+ expect(row.health).toBe("reachable");
+ // capabilities live on the ROW, not fleet.json
+ expect(row.capabilities).toEqual([]);
+
+ // transport set + installer run (guard) as a client
+ expect(rec.transportSets).toEqual(["ssh"]);
+ expect(rec.installerCalls).toEqual(["client"]);
+
+ // success reported only after verify-attach passed
+ expect(j(r).result?.verify_attach).toEqual({ ok: true });
+ });
+
+ it("defaults the transport to tailscale when this machine's capabilities include `roaming` (else the token's hint)", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({ capabilities: () => ["roaming"] });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ expect(rec.transportSets).toEqual(["tailscale"]);
+ expect(s.rosterRows()[0].transport).toBe("tailscale");
+ expect(s.rosterRows()[0].capabilities).toEqual(["roaming"]);
+ });
+});
+
+describe("amico fleet enroll — client redeem seeds lifecycle-admin authority (#1541 AC1)", () => {
+ it("records the enroller (canonical.host) as this machine's lifecycle-admin authority — persisted + resolvable", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder();
+ const token = tokenFor(s); // canonical.host = s.host (the enroller / canonical server)
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps);
+
+ expect(r.code).toBe(0);
+ expect(rec.authorityRecords).toHaveLength(1);
+ const a = rec.authorityRecords[0];
+ // the target is THIS machine (the one running Enroll); the authority is the enroller.
+ expect(a.targetMachineId).toBe("machine-abc");
+ expect(a.authorityMachineId).toBe(token.canonical.host);
+ expect(a.authorityIdentityKey).toBe(token.canonical.host);
+ expect(typeof a.recordedAt).toBe("string");
+ });
+
+ it("a pin-mismatch refusal seeds NO authority (nothing recorded on a refused enroll)", async () => {
+ const s = await stub({ version: "v2.0.0" }); // major skew → refused before any write
+ const rec = recorder();
+ const r = await fleetEnroll(
+ ["--join-token-json", JSON.stringify(tokenFor(s, { pin_version: "v1.18.29" }))],
+ rec.deps,
+ );
+ expect(r.code).not.toBe(0);
+ expect(rec.authorityRecords).toEqual([]);
+ });
+
+ it("the server path (--as-server) records no authority-over-a-target (it establishes the keeper, not a grantee)", async () => {
+ const rec = recorder();
+ const r = await fleetEnroll(["--as-server", "--host", "hub", "--port", "4096", "--ssh-alias", "hub"], rec.deps);
+ expect(r.code).toBe(0);
+ expect(rec.authorityRecords).toEqual([]);
+ });
+});
+
+describe("amico fleet enroll — pin check refuses a skewed token BEFORE any write (#1319 AC4)", () => {
+ it("rejects a join token whose pin_version disagrees with the host-version probe, writing NOTHING", async () => {
+ const s = await stub({ version: "v2.0.0" }); // host is v2.x; token pins v1.18.29 → major skew
+ const rec = recorder();
+ const token = tokenFor(s, { pin_version: "v1.18.29" });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps);
+
+ // refused, honestly
+ expect(r.code).not.toBe(0);
+ expect(j(r).ok).toBe(false);
+ expect(j(r).cause).toBe("pin-mismatch");
+ // the reason names BOTH versions (the pure versionSkewVerdict speaking)
+ expect(j(r).errors?.join(" ")).toContain("1.18.29");
+ expect(j(r).errors?.join(" ")).toContain("2.0.0");
+
+ // BEFORE any file is written: no fleet.json, no roster row, no transport, no installer
+ expect(j(r).wrote_nothing).toBe(true);
+ expect(rec.fleetWrites).toEqual([]);
+ expect(s.rosterRows()).toEqual([]);
+ expect(rec.transportSets).toEqual([]);
+ expect(rec.installerCalls).toEqual([]);
+ });
+});
+
+describe("amico fleet enroll — verify-attach honest failure (#1319 AC3)", () => {
+ it("transport down: the just-set transport is unreachable → cause=transport-down, row health=down, NO success", async () => {
+ const s = await stub({ version: "v1.18.29" }); // canonical healthy → pin check passes
+ // verify-attach probes the transport, which resolves to a dead port
+ const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: "http://127.0.0.1:1" }) });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).not.toBe(0);
+ expect(j(r).ok).toBe(false);
+ expect(j(r).cause).toBe("transport-down");
+ expect(typeof j(r).fix).toBe("string"); // the specific fix accompanies the cause
+ expect(j(r).result?.verify_attach).toEqual({ ok: false, cause: "transport-down" });
+ // the roster row's health reflects the failure (re-posted), NOT a false green
+ expect(s.rosterRows()).toHaveLength(1);
+ expect(s.rosterRows()[0].health).toBe("down");
+ });
+
+ it("auth rejected: the transport reaches a host that 401s → cause=auth-rejected, row health=degraded, NO success", async () => {
+ const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy)
+ const rejecting = await stub({ healthStatus: 401 }); // what the transport actually reaches
+ const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: rejecting.url }) });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps);
+
+ expect(r.code).not.toBe(0);
+ expect(j(r).cause).toBe("auth-rejected");
+ expect(canonical.rosterRows()[0].health).toBe("degraded");
+ });
+
+ it("sshAlias unresolved: an ssh transport with no alias → cause=sshAlias-unresolved, NO success", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder();
+ // transport_hint ssh + an EMPTY sshAlias → the default origin resolver refuses
+ const token = tokenFor(s, { transport_hint: "ssh", canonical: { host: s.host, port: s.port, sshAlias: "" } });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec.deps);
+
+ expect(r.code).not.toBe(0);
+ expect(j(r).cause).toBe("sshAlias-unresolved");
+ expect(j(r).ok).toBe(false);
+ expect(s.rosterRows()[0].health).toBe("degraded");
+ });
+});
+
+describe("amico fleet enroll — idempotent re-run repairs in place (#1319 AC5)", () => {
+ it("a second enroll on an already-enrolled machine produces NO duplicate roster row and no duplicate unit path", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const token = tokenFor(s);
+
+ const first = await fleetEnroll(["--join-token-json", JSON.stringify(token)], recorder().deps);
+ const rec2 = recorder();
+ const second = await fleetEnroll(["--join-token-json", JSON.stringify(token)], rec2.deps);
+
+ expect(first.code).toBe(0);
+ expect(second.code).toBe(0);
+ // single-writer upsert by machine_id → exactly ONE row after two runs
+ expect(s.rosterRows()).toHaveLength(1);
+ expect(s.rosterRows()[0].machine_id).toBe("machine-abc");
+ // the re-run still repairs via the installer (idempotent), never a second mechanism
+ expect(rec2.installerCalls).toEqual(["client"]);
+ // two posts total (one per run), collapsed to one row — no duplication
+ expect(s.rosterPosts().length).toBe(2);
+ });
+});
+
+describe("amico fleet enroll — the enroll-result JSON shape #1320 consumes (#1319 AC6)", () => {
+ it("client result has EXACTLY {machine_id,name,server_mode,capabilities,transport,verify_attach:{ok}}", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], recorder().deps);
+ const result = j(r).result!;
+ expect(Object.keys(result).sort()).toEqual([
+ "capabilities",
+ "machine_id",
+ "name",
+ "server_mode",
+ "transport",
+ "verify_attach",
+ ]);
+ expect(Object.keys(result.verify_attach)).toEqual(["ok"]); // no cause on success
+ expect(result.server_mode).toBe("client");
+ });
+
+ it("server result carries the same fixed shape (server_mode=server, verify_attach ok)", async () => {
+ const r = await fleetEnroll(["--as-server", "--host", "hub", "--port", "4096", "--ssh-alias", "hub"], recorder().deps);
+ const result = j(r).result!;
+ expect(Object.keys(result).sort()).toEqual([
+ "capabilities",
+ "machine_id",
+ "name",
+ "server_mode",
+ "transport",
+ "verify_attach",
+ ]);
+ expect(result.server_mode).toBe("server");
+ expect(result.verify_attach.ok).toBe(true);
+ });
+
+ it("on failure the verify_attach carries {ok:false, cause} — the exact shape, with the cause", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({ resolveProbeOrigin: () => ({ ok: true, origin: "http://127.0.0.1:1" }) });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+ expect(Object.keys(j(r).result!.verify_attach).sort()).toEqual(["cause", "ok"]);
+ expect(j(r).result!.verify_attach.ok).toBe(false);
+ });
+});
+
+describe("amico fleet enroll — never-fork: a client installs the guard and spawns NO engine (#1319, ADR 0005)", () => {
+ it("enrolling as a client runs the installer (guard) and NEVER touches the engine-spawn seam", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder();
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ // the guard is installed via the client installer (the never-fork enforcement)
+ expect(rec.installerCalls).toEqual(["client"]);
+ // and the enroll verb NEVER spawns a local engine (the tripwire stays untouched)
+ expect(rec.engine.spawns).toBe(0);
+ });
+});
+
+describe("amico fleet enroll — open-auth hub: no HTTP credentials sent (#1354 Bug 2)", () => {
+ it("does NOT send an Authorization header to the hub (the SSH tunnel is the auth boundary)", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const authHeaders: (string | undefined)[] = [];
+ const spyFetch: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const hdrs = init?.headers as Record | undefined;
+ authHeaders.push(hdrs?.Authorization);
+ return globalThis.fetch(input, init);
+ }) as typeof fetch;
+ const rec = recorder({ fetchImpl: spyFetch });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ expect(j(r).ok).toBe(true);
+ // Every fetch call should have Authorization = undefined (open auth)
+ expect(authHeaders.length).toBeGreaterThan(0); // at least the pin check + verify-attach
+ expect(authHeaders.every((h) => h === undefined)).toBe(true);
+ });
+});
+
+describe("amico fleet enroll — verify-attach retries on transient transport-down (#1354 Bug 3)", () => {
+ it("retries up to 3 times on transport-down, then succeeds when the host comes up", async () => {
+ const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy)
+ const verifyTarget = await stub({ version: "v1.18.29" }); // verify-attach target
+ let verifyProbeCount = 0;
+ const rec = recorder({
+ resolveProbeOrigin: () => ({ ok: true as const, origin: verifyTarget.url }),
+ fetchImpl: (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
+ if (url.includes(`:${verifyTarget.port}/global/health`)) {
+ verifyProbeCount++;
+ if (verifyProbeCount <= 2) throw new TypeError("fetch failed");
+ }
+ return globalThis.fetch(input, init);
+ }) as typeof fetch,
+ retryDelayMs: [0, 0, 0],
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps);
+
+ expect(r.code).toBe(0);
+ expect(j(r).ok).toBe(true);
+ expect(j(r).result?.verify_attach).toEqual({ ok: true });
+ expect(verifyProbeCount).toBe(3); // 2 failures + 1 success
+ expect(verifyTarget.healthHits()).toBe(1); // only the successful probe reached the stub
+ });
+
+ it("gives up after exhausting retries and reports transport-down", async () => {
+ const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy)
+ const rec = recorder({
+ resolveProbeOrigin: () => ({ ok: true as const, origin: "http://127.0.0.1:1" }),
+ retryDelayMs: [0, 0, 0],
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps);
+
+ expect(r.code).not.toBe(0);
+ expect(j(r).ok).toBe(false);
+ expect(j(r).cause).toBe("transport-down");
+ expect(j(r).result?.verify_attach).toEqual({ ok: false, cause: "transport-down" });
+ });
+
+ it("does NOT retry on auth-rejected (401)", async () => {
+ const canonical = await stub({ version: "v1.18.29" }); // pin check target (healthy)
+ const rejecting = await stub({ healthStatus: 401 }); // what the transport actually reaches
+ const rec = recorder({
+ resolveProbeOrigin: () => ({ ok: true as const, origin: rejecting.url }),
+ retryDelayMs: [0, 0, 0],
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(canonical))], rec.deps);
+
+ expect(r.code).not.toBe(0);
+ expect(j(r).cause).toBe("auth-rejected");
+ expect(rejecting.healthHits()).toBe(1); // no retries — fails immediately
+ });
+});
+
+// ── device identity on the client roster row (#1371 / #1368, ADR 0028) ─────────
+// The producer half of the shared self-report: the client enroll POSTs a row
+// whose `name` is the resolved friendly name and whose `device_type` is the
+// resolved form factor, per the precedence setting-override → OS-detection →
+// prettified-hostname (name) / undefined (type). The impure OS detection hides
+// behind ONE injectable command-runner seam; classification is the shared
+// @amicode/schema pure functions.
+describe("amico fleet enroll — client row carries friendly name + device_type (#1371 AC8)", () => {
+ it("POSTs a row whose name is the resolved friendly name and device_type the resolved type", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({
+ machineName: () => "Test Laptop", // OS-detected friendly name (scutil ComputerName)
+ deviceType: () => "laptop", // OS-detected form factor
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ const row = s.rosterRows()[0];
+ expect(row.name).toBe("Test Laptop");
+ expect(row.device_type).toBe("laptop");
+ });
+});
+
+describe("amico fleet enroll — device-identity resolution precedence (#1371 AC9)", () => {
+ it("override-wins: an explicit amicode.device.name / amicode.device.type setting beats OS detection", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({
+ machineName: () => "detected-name",
+ deviceType: () => "laptop",
+ readDeviceSetting: (k) =>
+ k === "amicode.device.name" ? "Lab Rig" : k === "amicode.device.type" ? "server" : undefined,
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ const row = s.rosterRows()[0];
+ expect(row.name).toBe("Lab Rig");
+ expect(row.device_type).toBe("server");
+ });
+
+ it("honest fallback: no override + no detected type → name is the prettified hostname, device_type omitted", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({
+ machineName: undefined, // exercise the default name detector
+ deviceType: undefined, // exercise the default type detector
+ commandRunner: () => "", // detection yields nothing
+ readDeviceSetting: () => undefined, // no override
+ });
+ const r = await fleetEnroll(["--join-token-json", JSON.stringify(tokenFor(s))], rec.deps);
+
+ expect(r.code).toBe(0);
+ const row = s.rosterRows()[0];
+ expect(row.name).toBe(normalizeDeviceName(osHostname()));
+ expect(row.device_type).toBeUndefined();
+ });
+});
+
+describe("amico fleet enroll — the friendly name never leaks into canonical/join token (#1371 AC11)", () => {
+ it("wiring a friendly name does NOT change canonical.host or the minted join token", async () => {
+ // A friendly, space-bearing display name; NO --host → canonical.host must be
+ // the RAW hostname, never the friendly name (the seam is decoupled).
+ const rec = recorder({ machineName: () => "Test Desktop" });
+ const r = await fleetEnroll(["--as-server", "--port", "4096", "--ssh-alias", "hub"], rec.deps);
+
+ expect(r.code).toBe(0);
+ const canonicalHost = (rec.fleetWrites[0].config as { canonical: { host: string } }).canonical.host;
+ expect(canonicalHost).toBe(osHostname());
+ expect(canonicalHost).not.toBe("Test Desktop");
+ expect(j(r).join_token?.canonical.host).toBe(osHostname());
+ });
+});
+
+// ── the impure detector: canned command output through the REAL caller (#1371 AC6/AC7) ──
+// AC6: a per-package test feeds canned command output through the real
+// command-runner caller into the shared pure classifier — so "on macOS/Linux/WSL
+// the row reflects X" is failing-test-able without shelling out.
+describe("detectDeviceTypeVia / detectDeviceNameVia — the amico-run impure detector (#1371 AC6/AC7)", () => {
+ it("darwin: system_profiler Model Name → type; scutil ComputerName → name", () => {
+ const run: CommandRunner = (cmd) =>
+ cmd === "system_profiler"
+ ? " Model Name: Mac Studio\n Model Identifier: Mac14,13\n"
+ : cmd === "scutil"
+ ? "Test Desktop\n"
+ : "";
+ expect(detectDeviceTypeVia(run, "darwin")).toBe("desktop");
+ expect(detectDeviceNameVia(run, "Desktop.local", "darwin")).toBe("Test Desktop");
+ });
+
+ it("darwin: an unrecognized Model Name abstains; a missing ComputerName falls to the prettified hostname", () => {
+ const run: CommandRunner = () => ""; // both commands yield nothing
+ expect(detectDeviceTypeVia(run, "darwin")).toBeUndefined();
+ expect(detectDeviceNameVia(run, "Desktop.local", "darwin")).toBe("Desktop");
+ });
+
+ it("linux source order: hostnamectl chassis is consulted first (#1371 AC7)", () => {
+ const run: CommandRunner = (cmd, args) => {
+ if (cmd === "cat" && args[0] === "/proc/version") return "Linux version 6.8.0-generic";
+ if (cmd === "hostnamectl" && args[0] === "chassis") return "server\n";
+ return "";
+ };
+ expect(detectDeviceTypeVia(run, "linux")).toBe("server");
+ });
+
+ it("linux: /sys/class/dmi/id/chassis_type is the second source when hostnamectl gives nothing", () => {
+ const run: CommandRunner = (cmd, args) => {
+ if (cmd === "cat" && args[0] === "/proc/version") return "Linux version 6.8.0-generic";
+ if (cmd === "hostnamectl") return ""; // no systemd / no chassis
+ if (cmd === "cat" && args[0] === "/sys/class/dmi/id/chassis_type") return "9\n"; // 9 = Laptop
+ return "";
+ };
+ expect(detectDeviceTypeVia(run, "linux")).toBe("laptop");
+ });
+
+ it("linux WSL abstains on device_type (#1371 AC7 / ADR 0028)", () => {
+ const run: CommandRunner = (cmd, args) =>
+ cmd === "cat" && args[0] === "/proc/version"
+ ? "Linux version 5.15.90.1-microsoft-standard-WSL2"
+ : "hostnamectl-should-not-be-consulted";
+ expect(detectDeviceTypeVia(run, "linux")).toBeUndefined();
+ });
+
+ it("linux: hostnamectl absent ⇒ device_type omitted; name is the prettified hostname (#1371 AC7)", () => {
+ const run: CommandRunner = () => ""; // nothing detected at all
+ expect(detectDeviceTypeVia(run, "linux")).toBeUndefined();
+ expect(detectDeviceNameVia(run, "box.local", "linux")).toBe("box");
+ });
+
+ it("linux: hostnamectl --pretty (PRETTY_HOSTNAME) drives the name when present", () => {
+ const run: CommandRunner = (cmd, args) =>
+ cmd === "hostnamectl" && args[0] === "--pretty" ? "Lab Server\n" : "";
+ expect(detectDeviceNameVia(run, "box.local", "linux")).toBe("Lab Server");
+ });
+});
+
+// ── server self-registration (#1372 / #1368, ADR 0028) ────────────────────────
+// Slice 2, the SERVER half: after provisioning the hub, `enroll --as-server`
+// self-registers its OWN roster row keyed machine_id = canonical.host (the same
+// identity a client references it by, so the client's synthesized node collapses
+// against it). The POST happens ONLY after the hub is verified reachable on
+// loopback (retry-or-honest-fail); the dev-fallback pin probe is NOT reachability
+// proof. `canonical.host` is documented immutable post-enroll.
+describe("amico fleet enroll --as-server — self-registration (#1372 AC2)", () => {
+ it("POSTs its own roster row keyed machine_id = canonical.host with resolved name + type after a verified-reachable hub", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({ machineName: () => "Test Desktop" });
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "Desktop.local", "--port", String(s.port), "--ssh-alias", "studio"],
+ rec.deps,
+ );
+ expect(r.code).toBe(0);
+ expect(j(r).self_registered).toBe(true);
+ const serverRow = s.rosterRows().find((x) => x.machine_id === "Desktop.local");
+ expect(serverRow).toBeDefined();
+ expect(serverRow!.server_mode).toBe("server");
+ expect(serverRow!.name).toBe("Test Desktop");
+ // detection abstains (commandRunner "" in recorder) ⇒ device_type defaults to "server"
+ expect(serverRow!.device_type).toBe("server");
+ expect(serverRow!.sshAlias).toBe("studio");
+ expect(serverRow!.health).toBe("reachable");
+ });
+
+ it("honors the amicode.device.type override on the server row (#1372 AC2)", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const rec = recorder({
+ machineName: () => "Test Desktop",
+ readDeviceSetting: (k: string) => (k === "amicode.device.type" ? "desktop" : undefined),
+ });
+ const r = await fleetEnroll(["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"], rec.deps);
+ expect(r.code).toBe(0);
+ const serverRow = s.rosterRows().find((x) => x.machine_id === "hub.example");
+ expect(serverRow!.device_type).toBe("desktop");
+ });
+});
+
+describe("amico fleet enroll --as-server — reachability-verified, no false-success row (#1372 AC1)", () => {
+ it("does NOT self-register when the hub is unreachable on loopback — no roster POST, no false-success row", async () => {
+ const postAttempts: string[] = [];
+ const spy: typeof fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : (input as Request).url;
+ if (url.includes("/amicode/roster") && init?.method === "POST") postAttempts.push(url);
+ // The hub is unreachable EVERYWHERE (health probe + any POST both fail).
+ throw new TypeError("fetch failed");
+ }) as typeof fetch;
+ // clientVersion undefined ⇒ the dev-fallback pin probe runs and TOLERATES the
+ // unreachable hub (returns "dev") — which must NOT be taken as reachability.
+ const rec = recorder({ fetchImpl: spy, clientVersion: undefined });
+ const r = await fleetEnroll(
+ ["--as-server", "--host", "Desktop.local", "--port", "4096", "--ssh-alias", "hub"],
+ rec.deps,
+ );
+ // provisioning still succeeded (token minted, fleet.json written), but the
+ // self-registration honestly did not happen — and NO row was POSTed.
+ expect(j(r).self_registered).toBe(false);
+ expect(postAttempts).toEqual([]);
+ // the dev-fallback pin still resolved (tolerant), proving it is a SEPARATE
+ // concern from reachability — a dev pin did not green-light the self-POST.
+ expect(j(r).join_token?.pin_version).toBe("dev");
+ });
+});
+
+describe("amico fleet enroll --as-server — canonical.host immutability (#1372 AC5)", () => {
+ it("documents that canonical.host is immutable post-enroll (changing --host requires re-enrolling clients)", async () => {
+ const s = await stub({ version: "v1.18.29" });
+ const r = await fleetEnroll(["--as-server", "--host", "hub.example", "--port", String(s.port), "--ssh-alias", "hub"], recorder().deps);
+ expect(r.code).toBe(0);
+ expect(String(j(r).caveat ?? "")).toMatch(/canonical.*immutable|re-?enroll/i);
+ });
+});
diff --git a/packages/amico-run/test/fleet_projection_verb.test.ts b/packages/amico-run/test/fleet_projection_verb.test.ts
index ab24e52db..e3175c142 100644
--- a/packages/amico-run/test/fleet_projection_verb.test.ts
+++ b/packages/amico-run/test/fleet_projection_verb.test.ts
@@ -19,13 +19,13 @@
// surfaces the reader's LOUD rejection verbatim (both versions named).
//
// Run: pnpm --filter @amicode/amico-run test fleet_projection_verb
-import { describe, it, expect, beforeEach, afterEach } from "vitest";
+import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fleetVerb } from "../src/fleet_verb.js";
import { fleetProjectionStatus, FLEET_BOOTSTRAP_EXIT, type FleetProjectionDeps } from "../src/fleet_projection_verb.js";
-import { fleetProjectionCachePath, fleetTopologyPath } from "@amicode/schema";
+import { fleetProjectionCachePath, fleetTopologyPath, freshnessBetween, readProjection, BASE_TIER_PUBLISHER_IDENTITY } from "@amicode/schema";
const E1 = "44444444-4444-4444-8444-444444444444";
@@ -37,7 +37,10 @@ let tmp: string;
beforeEach(() => {
tmp = mkdtempSync(join(tmpdir(), "fleet-proj-verb-"));
});
-afterEach(() => rmSync(tmp, { recursive: true, force: true }));
+afterEach(() => {
+ vi.restoreAllMocks();
+ rmSync(tmp, { recursive: true, force: true });
+});
/** A hermetic world: an entitlements file carrying the `amicissimo` code, a
* checkout dir, and a runPublisher that copies the fixture projection to the
@@ -350,3 +353,136 @@ describe("the fleet verb router", () => {
expect((plain.json.errors as string[]).join(" ")).toMatch(/--session is required/);
});
});
+
+// ── ADR 0023: the base-tier producer (a public floor under the authority) ──────
+describe("the base-tier projection (ADR 0023 — enrolled machine, no amicissimo)", () => {
+ const DET = { baseEpoch: () => "epoch-x", baseCounter: () => 7, nowIso: () => "2026-09-18T00:00:00.000Z" };
+
+ it("no entitlement + an enrolled client fleet.json → ok (exit 0), base_tier, role=client, canonical carried, cache written", () => {
+ const topologyPath = join(tmp, "fleet.json");
+ const cachePath = join(tmp, "cache.json");
+ const canonical = { host: "user@100.64.0.1", port: 4096, sshAlias: "test-desktop" };
+ const fleetJson = JSON.stringify({ role: "client", canonical });
+ const deps: FleetProjectionDeps = {
+ readFile: (p) => (p === topologyPath ? fleetJson : null), // NO entitlements file
+ topologyPath,
+ cachePath,
+ ...DET,
+ };
+ const r = run(["--config", join(tmp, "entitlements.toml")], deps);
+ expect(r.code).toBe(0);
+ expect(r.json).toMatchObject({ ok: true, base_tier: true, reason: "entitlement", role: "client", mode: "fleet" });
+ expect(r.json.canonical).toEqual(canonical);
+ // the cached projection is readable by the ONE reader, unchanged
+ const cached = readProjection(cachePath);
+ expect(cached.publisher?.identity).toBe(BASE_TIER_PUBLISHER_IDENTITY);
+ expect((cached.sections?.topology?.value as Record).role).toBe("client");
+ });
+
+ it("no entitlement + role=standalone → the honest bootstrap-75 (base tier does NOT engage)", () => {
+ const topologyPath = join(tmp, "fleet.json");
+ const deps: FleetProjectionDeps = {
+ readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "standalone" }) : null),
+ topologyPath,
+ cachePath: join(tmp, "cache.json"),
+ ...DET,
+ };
+ const r = run(["--config", join(tmp, "entitlements.toml")], deps);
+ expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT);
+ expect(r.json).toMatchObject({ bootstrap: true, reason: "entitlement", mode: "standalone" });
+ });
+
+ it("no entitlement + no fleet.json → the honest bootstrap-75, unchanged", () => {
+ const topologyPath = join(tmp, "fleet.json");
+ const deps: FleetProjectionDeps = {
+ readFile: () => null, // nothing on disk
+ topologyPath,
+ cachePath: join(tmp, "cache.json"),
+ ...DET,
+ };
+ const r = run(["--config", join(tmp, "entitlements.toml")], deps);
+ expect(r.code).toBe(FLEET_BOOTSTRAP_EXIT);
+ expect(r.json).toMatchObject({ bootstrap: true, reason: "entitlement" });
+ });
+
+ it("entitled but NO checkout + an enrolled server fleet.json → base tier engages with reason=checkout", () => {
+ const entitlements = join(tmp, "entitlements.toml");
+ const topologyPath = join(tmp, "fleet.json");
+ const cachePath = join(tmp, "cache.json");
+ const deps: FleetProjectionDeps = {
+ readFile: (p) =>
+ p === entitlements ? 'codes = ["amicissimo"]' : p === topologyPath ? JSON.stringify({ role: "server", canonical: { host: "h", port: 4096 } }) : null,
+ checkDir: () => false, // no amicissimo checkout present
+ topologyPath,
+ cachePath,
+ ...DET,
+ };
+ const r = run(["--checkout", join(tmp, "absent-amicissimo"), "--config", entitlements], deps);
+ expect(r.code).toBe(0);
+ expect(r.json).toMatchObject({ ok: true, base_tier: true, reason: "checkout", role: "server", mode: "fleet" });
+ });
+
+ it("the base-tier freshness is epoch-bound and knowable (no false-fresh, no refetch loop)", () => {
+ const topologyPath = join(tmp, "fleet.json");
+ const deps: FleetProjectionDeps = {
+ readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client", canonical: { port: 4096 } }) : null),
+ topologyPath,
+ cachePath: join(tmp, "cache.json"),
+ ...DET,
+ };
+ const r = run(["--config", join(tmp, "entitlements.toml")], deps);
+ expect(r.json.freshness).toEqual({ counter: 7, hub_epoch: "epoch-x" });
+ });
+
+ it("the default counter turns an equal wall-second into fresh, not equal-counter stale", () => {
+ const previousHome = process.env.HOME;
+ process.env.HOME = join(tmp, "home");
+ vi.spyOn(Date, "now").mockReturnValue(2_000_000_000_000);
+ try {
+ const topologyPath = join(tmp, "fleet.json");
+ const cachePath = join(tmp, "cache.json");
+ const deps: FleetProjectionDeps = {
+ readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client" }) : null),
+ topologyPath,
+ cachePath,
+ };
+ run(["--config", join(tmp, "entitlements.toml")], deps);
+ const previous = readProjection(cachePath);
+ run(["--config", join(tmp, "entitlements.toml")], deps);
+ const fetched = readProjection(cachePath);
+
+ expect(previous.freshness?.counter).toBe(2_000_000_000);
+ expect(fetched.freshness?.counter).toBe(2_000_000_001);
+ expect(freshnessBetween(previous, fetched)).toBe("fresh");
+ } finally {
+ if (previousHome === undefined) delete process.env.HOME;
+ else process.env.HOME = previousHome;
+ }
+ });
+
+ it("the default counter turns a lower wall-second into fresh, not a backwards-counter unknown", () => {
+ const previousHome = process.env.HOME;
+ process.env.HOME = join(tmp, "home");
+ const now = vi.spyOn(Date, "now").mockReturnValue(2_000_000_000_000);
+ try {
+ const topologyPath = join(tmp, "fleet.json");
+ const cachePath = join(tmp, "cache.json");
+ const deps: FleetProjectionDeps = {
+ readFile: (p) => (p === topologyPath ? JSON.stringify({ role: "client" }) : null),
+ topologyPath,
+ cachePath,
+ };
+ run(["--config", join(tmp, "entitlements.toml")], deps);
+ const previous = readProjection(cachePath);
+ now.mockReturnValue(1_000_000_000_000);
+ run(["--config", join(tmp, "entitlements.toml")], deps);
+ const fetched = readProjection(cachePath);
+
+ expect(fetched.freshness?.counter).toBe(2_000_000_001);
+ expect(freshnessBetween(previous, fetched)).toBe("fresh");
+ } finally {
+ if (previousHome === undefined) delete process.env.HOME;
+ else process.env.HOME = previousHome;
+ }
+ });
+});
diff --git a/packages/amico-run/test/fleet_revoke_verb.test.ts b/packages/amico-run/test/fleet_revoke_verb.test.ts
new file mode 100644
index 000000000..9ec1a67be
--- /dev/null
+++ b/packages/amico-run/test/fleet_revoke_verb.test.ts
@@ -0,0 +1,91 @@
+// fleet_revoke_verb.test.ts (#1438, ADR 0032 §D5) — `amico fleet revoke
+// `: LOCAL expulsion (drop the issued grant + add the mint-list
+// bar, on this machine's own registry) PLUS the fan-out to every serving peer
+// (each peer applies it to its own registry; no machine writes another's).
+//
+// A NEW suite, sibling of fleet_enroll_verb.test.ts. The registry file I/O is
+// exercised for real on a temp file; the fan-out is injected so the
+// orchestration is asserted without a live mesh.
+import { describe, it, expect } from "vitest";
+import { mkdtempSync, readFileSync } from "node:fs";
+import { tmpdir } from "node:os";
+import { join } from "node:path";
+import { fleetRevoke, type FleetRevokeDeps, type ServingPeer } from "../src/fleet_revoke_verb.js";
+
+interface RevokeJson {
+ verb: string;
+ subcommand: string;
+ ok: boolean;
+ machine_id?: string;
+ revoked_local?: boolean;
+ fanned_out?: Array<{ peer: string; ok: boolean; error?: string }>;
+ errors?: string[];
+}
+const j = (r: { json: unknown }): RevokeJson => r.json as RevokeJson;
+
+function tmpRegistry(seed?: unknown): string {
+ const file = join(mkdtempSync(join(tmpdir(), "amico-revoke-")), "fleet-peer-tokens.json");
+ if (seed !== undefined) {
+ require("node:fs").writeFileSync(file, JSON.stringify(seed, null, 2));
+ }
+ return file;
+}
+
+describe("amico fleet revoke (#1438, ADR 0032 §D5)", () => {
+ it("requires a machine_id argument", async () => {
+ const r = await fleetRevoke([], { registryFile: tmpRegistry() });
+ expect(r.code).not.toBe(0);
+ expect(j(r).ok).toBe(false);
+ });
+
+ it("LOCAL expulsion: drops the issued grant AND writes the mint-list bar on this machine's registry", async () => {
+ const file = tmpRegistry({
+ store_version: 1,
+ issued: { evil: { token: "T", scope: "full-session", issued_at: "x" } },
+ revoked: {},
+ });
+ const r = await fleetRevoke(["evil"], { registryFile: file, listServingPeers: async () => [] });
+ expect(r.code).toBe(0);
+ expect(j(r).revoked_local).toBe(true);
+ const doc = JSON.parse(readFileSync(file, "utf8")) as { issued: Record; revoked: Record };
+ expect(doc.issued.evil).toBeUndefined(); // dropped
+ expect(doc.revoked.evil).toBeDefined(); // barred (the mint-list bar)
+ });
+
+ it("FANS OUT to every serving peer (each peer applies it to its own registry)", async () => {
+ const calls: Array<{ peer: string; machineId: string }> = [];
+ const peers: ServingPeer[] = [
+ { machine_id: "p1", origin: "http://p1:43117" },
+ { machine_id: "p2", origin: "http://p2:43117" },
+ ];
+ const deps: FleetRevokeDeps = {
+ registryFile: tmpRegistry({ store_version: 1, issued: {}, revoked: {} }),
+ listServingPeers: async () => peers,
+ fanOutRevoke: async (peer, machineId) => {
+ calls.push({ peer: peer.origin, machineId });
+ return { peer: peer.origin, ok: true };
+ },
+ };
+ const r = await fleetRevoke(["evil"], deps);
+ expect(r.code).toBe(0);
+ expect(calls.map((c) => c.peer).sort()).toEqual(["http://p1:43117", "http://p2:43117"]);
+ expect(calls.every((c) => c.machineId === "evil")).toBe(true);
+ expect(j(r).fanned_out?.length).toBe(2);
+ expect(j(r).fanned_out?.every((f) => f.ok)).toBe(true);
+ });
+
+ it("a fan-out failure to one peer is reported (not swallowed), local revoke still applied", async () => {
+ const file = tmpRegistry({ store_version: 1, issued: { evil: { token: "T" } }, revoked: {} });
+ const r = await fleetRevoke(["evil"], {
+ registryFile: file,
+ listServingPeers: async () => [{ machine_id: "p1", origin: "http://p1" }],
+ fanOutRevoke: async () => ({ peer: "http://p1", ok: false, error: "unreachable" }),
+ });
+ // local expulsion is authoritative for THIS machine and always applies
+ expect(j(r).revoked_local).toBe(true);
+ const doc = JSON.parse(readFileSync(file, "utf8")) as { revoked: Record };
+ expect(doc.revoked.evil).toBeDefined();
+ // the failed peer is surfaced honestly
+ expect(j(r).fanned_out?.some((f) => !f.ok && f.error === "unreachable")).toBe(true);
+ });
+});
diff --git a/packages/app-bundle/manifest.json b/packages/app-bundle/manifest.json
index 8e65c7f34..db47e49e5 100644
--- a/packages/app-bundle/manifest.json
+++ b/packages/app-bundle/manifest.json
@@ -8,7 +8,7 @@
"upstream_base_sha": "3104c1428ec91f809e5ab86631300de41eb6952e",
"promoted_at": "2026-09-13T22:15:25.839Z",
"files": {
- "packages/app/CONTEXT.md": "7dc4bf176a32ee578a54593a58a6b97907ffc5a9baf7ae7c16a8fa920d8e6e86",
+ "packages/app/CONTEXT.md": "69e73ecec62f730398e8f25071e736a58b2f7a1d9059b60788e9936051051f14",
"packages/app/index.html": "fa38026be3b7e7ec38958a558d7b4d059140b2aff998c3ac862601543702584d",
"packages/app/package.json": "3c9bf3d487052d4834b7fd3ce93fcf6b7fc358de92b8be4b0e84fc424a971f74",
"packages/app/playwright.config.ts": "b7dae0b39248b4ddf7cba829af96ac7fe3cc919add9711ecb5292d9bd52e1805",
@@ -25,7 +25,7 @@
"packages/app/public/oc-theme-preload.js": "27227e802b3494e7c545da903e679efdb30ccc754cd4eb5cdf08005a40d560b6",
"packages/app/src/app-desktop-commands.test.ts": "1d2057f238dc14e4407ff5c4e53afe091784790bc0fd0dbfde0eacd91e2bd968",
"packages/app/src/app.tsx": "07b292f88de59fad23b0e7564e0c536214917b65b50326552894925a9c0e17d1",
- "packages/app/src/design-polish.css": "6b860316d2d57a4a9a89c1ff2772de45eca0d0cef2bd3e2ea8572d817e1d1910",
+ "packages/app/src/design-polish.css": "6dceafb232f7132be588debc33c913fa6773966d54d6bbf6a1496b7622adeacc",
"packages/app/src/desktop-menu.ts": "350e0e9f2e4294bfe1b1f357b3cd2ea0908b14d20ca1e79fda2305db1958fb24",
"packages/app/src/entry.tsx": "f35e1017f4c9d478d254b2a38043e5750064b6bef25169c3e07ae9f72ff1049c",
"packages/app/src/index.css": "2c11df3dcebb381358e06626094b5dda3c35688ad7940d643b2cbb6cff1ea9e9",
@@ -60,6 +60,7 @@
"packages/schema/src/provider-permission.ts": "382d3b8c8e7bac64816d6a8cb0a84dff6e9a4bd1a921a653ebb20b23d4580e14",
"packages/schema/src/question.ts": "538b73b25f8993700d7836b915ff27a1aff63cb6a6ef42e77b90bfbef83a5558",
"packages/schema/src/session-receipt.ts": "8bdc6d169b3055bfbfd03d10169d71bc10cad8c085409d1f8481210ca6736a4d",
+ "packages/schema/src/session-status-event.ts": "975b5d271dc46576d40a60f7507f035d7b2dbb9a41d6dc667c1e25e8ba77958b",
"packages/schema/src/worktree-event.ts": "833b689b3636335d00a60cbed521f05dc02b467f7ee757de48291a602fac55b3",
"packages/schema/test/provenance-matrix.test.ts": "80c7b10e7c249d20e14aea3f6e1341450f63ff8ee99f3c69ac9e2db52d7340e1",
"packages/schema/test/question.test.ts": "511f4fabf2841ed89e1fed9594f2e3a1be8eea5bcb50b680d762c49d6ef572d9",
@@ -110,14 +111,24 @@
"packages/app/src/components/dialog-release-notes.tsx": "0ea5b049786787643a7f6b54d4ccf84c4f8ffa4e8272426c0d32f60d445b6890",
"packages/app/src/components/dialog-select-directory-v2.css": "2e2f18abdca77f9fe85da157c40f288f3b1641ca92845208749e16455fd58637",
"packages/app/src/components/dialog-select-file.tsx": "e6278ee35dd27d203e00ad73f70c709c44a6c06f81ffbd931edfaa0c986b9e03",
+ "packages/app/src/components/engine-state-signal.ts": "2726628b85d4bc58bf6b3bd3045fc4239ad384deaba14fc8f46b818741549a5e",
+ "packages/app/src/components/engine-toggle-utils.test.ts": "bf53ca7a6fac3696f8d9aac032ceee5e35fb3b7e3dfaef3f9afb1fa0ff3135bb",
+ "packages/app/src/components/engine-toggle-utils.ts": "bab8a7ddef9f49d4fc243477b7206feb741afdf5ec154a2d70a9f31c814f8c5d",
"packages/app/src/components/external-link.tsx": "7130044026ad3ec417de4ba7389cc6059de46f21b370047046269c772aa7a4bb",
"packages/app/src/components/help-button.tsx": "780d533f776a784660cfed072a131198bc6fcc8e8fe332e8e1a220d516c9684e",
+ "packages/app/src/components/new-session-machine-picker.ts": "8c7689f79c7610e32fef478320870d1468fa17a3a02463e2cbe058a487fb4769",
"packages/app/src/components/pane-bridge.tsx": "756b3306b51c963fac6778716f6c6c4626f0f3bf75c93b7cb4cab58ea0bf3d4e",
"packages/app/src/components/profile-popover.tsx": "eff742e90544455f13234a9351bb1b82189663cba2cd3172bdcea1a8a37284e8",
"packages/app/src/components/prompt-input-clipboard-structure.test.ts": "946b718c2164da394602f26e4d9919d752da0d0b9bee714f345ba16c274542da",
- "packages/app/src/components/prompt-input-v2.tsx": "5ab46e41bda4fa11e99f06c37df76c1fd3cce061c6ba7a9f85bee369a965b98a",
- "packages/app/src/components/prompt-input.tsx": "97a9bc55b46cf1f2d7c3e38110e736f7fa9db7f89121d3c013b9650a60d382d4",
+ "packages/app/src/components/prompt-input-v2.tsx": "3eaa57edd5e6071235f2a75928cc1ead492b7da640b7400ce59310e6a8722baf",
+ "packages/app/src/components/prompt-input.tsx": "c127f81e2c0de4bdbc21a349473df39c623d8d7e2df8df0c03ea60bbef101a25",
"packages/app/src/components/prompt-project-selector.tsx": "0157c954b938bce2b5aca9a82f4a72338e361f4c9027e55a7fd5a14a1654d294",
+ "packages/app/src/components/remote-create-arm.test.ts": "388132209e16b6f00d79ac7c700f8c2bfb5f80343eee34cee73451b32842d3f2",
+ "packages/app/src/components/remote-create-arm.ts": "49cb5f89f041ee5d555a348ec5bbbd9709d448752987c491f63376b02522db36",
+ "packages/app/src/components/remote-create-header.test.ts": "bfe81618f1ecf13a5e55e19242ce286ef40ab9eb95dae9a64fed03e46d7b1954",
+ "packages/app/src/components/remote-create-header.ts": "7491c1068231d675dda18cad01e2ad2aefaddea5b23726a8c46cc0a73b3df0c4",
+ "packages/app/src/components/remote-create-preflight.test.ts": "fe9981c45385235e2ba89a215c5cc23c765c8840befb6d795b2d835833c779cb",
+ "packages/app/src/components/remote-create-preflight.ts": "9f875be60c8dd0f93d2401008df7841549106611631282a5c478659d88301bd6",
"packages/app/src/components/report-bug-button.css": "909809b6241d53c386aa4c8ea34539ba162a8998fff755834c783025bc53a5fd",
"packages/app/src/components/report-bug-button.stories.tsx": "82aed22bd3fcca448841205dee759ca0f0de92562a99d07c696eee5bd29e807b",
"packages/app/src/components/report-bug-button.tsx": "a1beb8c94b4faaa4c7db3ab68de349f8a8a03cb3a058ce447f552aed35848d43",
@@ -126,7 +137,7 @@
"packages/app/src/components/settings-providers.tsx": "c86c9bf4a6f6e198dc7b0995a69d5b76e851144987bbe601802b1b0120ee4ac4",
"packages/app/src/components/solver-switch-banner.tsx": "ec7edeec6dc05a4a06b39241b4f3a8e7534da97722dd8ec6f26ff782025802e9",
"packages/app/src/components/split-frame.tsx": "44c53737f72027b461097c03df48a4f411d04a82d02b1fce4901813c73dfcc72",
- "packages/app/src/components/status-popover-body.tsx": "06b457108c48969d3b7419d1d2f3dc7e38a9b18cb45735dd4963ca0f266f6129",
+ "packages/app/src/components/status-popover-body.tsx": "c7f5cce9066a6388de8531156a300503ead6f596cad2ea4269d64c421a97d0c5",
"packages/app/src/components/status-popover-indicator.test.ts": "9afc7b49d9021a94d74f3f777178230ae13bd26e5749da0cee4d3378eacacacd",
"packages/app/src/components/status-popover-indicator.ts": "887b3e659ff49fd0d2a8715fad6562dc7e9402b80d06442058502306bdb108f5",
"packages/app/src/components/status-popover-model.test.ts": "10634035bab0f31de24564e0d6c8413a0f7434fb2c08ac3ef722981a69dd08e7",
@@ -135,12 +146,14 @@
"packages/app/src/components/terminal.tsx": "2ce242034ae129c1f475c7a24fdec3facdd9bb1f49ba8c6b503643b07ae241e5",
"packages/app/src/components/titlebar-channel.test.ts": "184238871c46f586dcc21081f710e37a77914c983fccf250665c7317212e41d8",
"packages/app/src/components/titlebar-channel.ts": "385ace91d3c69c92f5b442bf15d2040e35df855f1d4ece47312c1ff8e1b01780",
- "packages/app/src/components/titlebar-layout.test.ts": "52912f37fab4e7d0d5b7403371060651597f4c4904fefb802ff9e3333facdeed",
+ "packages/app/src/components/titlebar-layout.test.ts": "00708b4dd63a357f76bec035ce0fad017e5b24c18129f38df039f587e9e71f71",
"packages/app/src/components/titlebar-layout.ts": "a375c238ab632421a6505dce3a41fd3894865729e921e68eb32249478c18a286",
+ "packages/app/src/components/titlebar-portal-dedup.test.ts": "99034b76bf7896247a7eb8d492e7801ac2d67290cce226e70487c008eaff2a1f",
+ "packages/app/src/components/titlebar-portal-registry.ts": "e6a159c44490125046ddbb249fbda5cef6cef383a604a168cb4018cf49025521",
"packages/app/src/components/titlebar-tab-nav.css": "90930c2f9476fb0eace8692424a029b3510781c88d1ee61f7f2edba559526832",
- "packages/app/src/components/titlebar-tab-nav.tsx": "2304e936af56c24f27f1549f4f8751a600ed274123d7a3973ecfa6a5bb28102c",
+ "packages/app/src/components/titlebar-tab-nav.tsx": "0d5e11f63006e87fc42a4682c481203f50622677b07a8ff87a3348613cdf3674",
"packages/app/src/components/titlebar-tab-popover.css": "d731db2d7d49838004089a3d82ff68c22214b0ca636bf3b8e02498c14dadbf8d",
- "packages/app/src/components/titlebar-tab-strip.tsx": "932a2d01e6673d283a48d704b580f7a7b88ba000061d11f6df5efe994dbd4ee3",
+ "packages/app/src/components/titlebar-tab-strip.tsx": "a3c8a8df186a6164494230bec7f302cfe3a1cf658e3ab10f9ef0af4f5d63c038",
"packages/app/src/components/titlebar.css": "50c262603ba1140db9e4fefe8f03e9c6ae3bf64dc7a917e55586bfd67a5e3011",
"packages/app/src/components/titlebar.tsx": "c53f438701e97cbe3fbf2eaa67fb584a0b8b3a91f2d4f8016542156e17cd598c",
"packages/app/src/components/vault-browser-model.test.ts": "7ca11eae3574033d58081bc4126b1ec772a5b496573c70159f05497a1365f4cd",
@@ -151,11 +164,17 @@
"packages/app/src/components/workbench-panel.tsx": "d16f477044098842a214a2e16f9be719e7688a04bcb575889ca544988f23c690",
"packages/app/src/context/command.tsx": "388d6cb12b6dc43ecb0827e3fb918370cb9d16d96161a5990dec10fff05af172",
"packages/app/src/context/directory-sync.test.ts": "c03c7bcd95ac47b3de5e1e9719b6eccdb3569063026fe993b448453c818c7b48",
- "packages/app/src/context/directory-sync.ts": "8515e0f8a5bdf0bf6243a0c2dfe64f234f5292074464e38c68da8287235acc3e",
+ "packages/app/src/context/directory-sync.ts": "e53a71fa2b87ad5d73ea0fd8ad8a58fe2d1e06c80e8369d3d89ce21310f0ed7d",
+ "packages/app/src/context/fleet-reseed-scheduler.test.ts": "a7f3fd7f861d2fe9d89e2eb20d125e61f6aa6180bb7d2ddb5422113a1beeba17",
+ "packages/app/src/context/fleet-reseed-scheduler.ts": "1ed15079a7920cf5a62f51073587af2d7810f8b27486d3e11ec7b59ba9b8d8ea",
+ "packages/app/src/context/fleet-session-status.e2e-fixture.ts": "74bcf006f7105ef635c5b0ea251fb0ca06aad5dbaa9c817c77e5093df1e08032",
+ "packages/app/src/context/fleet-session-status.e2e.test.ts": "e76af016dacfafaf0124c86470bdeda28a49e5d97d9a1cfa9d855cdfbbc83687",
+ "packages/app/src/context/fleet-session-status.test.ts": "4d37bac382d59df115a72b26a09a1c01dbd1417e04eb443563f070dcab55597d",
+ "packages/app/src/context/fleet-session-status.ts": "0088413ee2059d2828da97e8f4af98883c09b8d76c1e6078cd5e05b4bdbb7821",
"packages/app/src/context/language.tsx": "0f7b1cbd33071f293c15b66e3a900dfd07347f9eb93d8cf61df0fac3bc4effc5",
- "packages/app/src/context/layout-side-panel-tabs.test.ts": "47425bbed0ada18d3fcb67a76b63de3b9e1f827cd63a61ffe4eb45430fc5814e",
- "packages/app/src/context/layout-side-panel-tabs.ts": "e8426fadbdf9e7c49bf457e00ec92793475b44bcc11548e8b5ef9a9fa461be76",
- "packages/app/src/context/layout-tabs.test.ts": "4d9fdbe963306f164b2f48eac3b6a28c5a703c1758bf14b2cc4796720d8fa72c",
+ "packages/app/src/context/layout-side-panel-tabs.test.ts": "297780d590110895baf053bdde81863439dac94a18592287314167db579a89d5",
+ "packages/app/src/context/layout-side-panel-tabs.ts": "e8dec07b2b5a5ebe68a76c98d688afa1360a4e31741a10bb49228afb8a7f3777",
+ "packages/app/src/context/layout-tabs.test.ts": "524327496e735379bb3425e78cbeb6cefa92e30296ddc44776eb8e99d644925d",
"packages/app/src/context/layout-tabs.ts": "741506ce165f68cdb0b8f2931a2dad3e26c05880f9286c3daf04ec979bac21c5",
"packages/app/src/context/layout.tsx": "7767c8b5f64efd63390cbe99128686bec2f1da7e8f2b21530df4c3fd67d171bb",
"packages/app/src/context/local-agent.test.ts": "a5a9d60bb4401d409218cc247c1cc06ede8b7878ddf01dc6080328f54eb9cade",
@@ -163,20 +182,32 @@
"packages/app/src/context/local.tsx": "3ab8b9fc2db082df4ba485373679f00a95d0ffe3c691a0afee1dc53db7eabd9e",
"packages/app/src/context/models.tsx": "8904525767b36ed5ba93e85a0fddb9bf737a8d066dfff084380ce1a67182f535",
"packages/app/src/context/platform.tsx": "edd9b1773feb5c084b922a57b6914f6a473eed558b44f9de0eeb1c4d4e2b49da",
- "packages/app/src/context/server-sdk.test.ts": "5cd22962c8ea1e4374915508a765281ceb9f808ad8bbf241c06b00b48621c816",
- "packages/app/src/context/server-sdk.tsx": "7e1b71538136237481f1f5ec05680a81b01c82f24ecf5819c430d317c4a72f17",
- "packages/app/src/context/server-session.test.ts": "a76d24b0d36d7c6c9ce187d2eb5bba78c90774184adc0398559ee1a417674516",
- "packages/app/src/context/server-session.ts": "4d85011c9ff81cf0470633f6f27cd9f11c234294c31e2c77a990aad135d317ab",
- "packages/app/src/context/server-sync.tsx": "14396d7402d697ab9a10744c9e428b96e483f3a9620a0415246fa840596fd8ed",
+ "packages/app/src/context/server-sdk.test.ts": "833920190c25089a5c3ed9849a62b021d2e0e2e4fb76196af8ddc9e6aa9817ba",
+ "packages/app/src/context/server-sdk.tsx": "36cfd55e634dbb974d4d6785281fedc80f4ddd539090f859fa7156f959ab500d",
+ "packages/app/src/context/server-session-child-active.test.ts": "de8c467f67f88b6cce351c1805ec90728cf238eb1ef32f39ffdbbf3de4dcec22",
+ "packages/app/src/context/server-session-orphan-part.test.ts": "1d635374c0bfff25a01825061aabac7334e5cdaedca2dab9f3d9467ff543caea",
+ "packages/app/src/context/server-session.test.ts": "30cd24bb1345376fd1a165d65677dee5c36d586efa7969d3b4844967040fda59",
+ "packages/app/src/context/server-session.ts": "d309c8caad0b444d911a2e63be012d9e269b1791ffbfbb55218b0d631fd684fe",
+ "packages/app/src/context/server-sync.tsx": "cdc7057c2188d79c5e586f1be05431cb97880ed3fd6b8f3980268db0fa011e34",
"packages/app/src/context/server.tsx": "08e978e4b4b3094221a2a0b92e069f39cbedb6d6cf95935881e21cde52265ee2",
"packages/app/src/context/session-mirror.test.ts": "65e6d6474e4e67f2da194047140c8c8aac04fe0cc9926441468e53fabc48db96",
"packages/app/src/context/session-mirror.ts": "b98de060f2b06e6f9a48b1f69ed408f12086c626d90c7dd22029a4ee732260e2",
+ "packages/app/src/context/session-status-reconcile.test.ts": "72fa5682150aa4f2d7c03a61dcda76a682e81f095a2a58e5a686315c28c257d3",
+ "packages/app/src/context/session-status-reconcile.ts": "4318c0a4a8057cced4ea3895034484d9712c1d33fe8ffd3b4e99acddb4076802",
"packages/app/src/context/session-warm.test.ts": "b862625f1e418913a982b368573f5abf07aaf6e1a63ded8296a1d43ef164ec34",
"packages/app/src/context/session-warm.ts": "d4184b8f0a090b0a011cdf7b6e80d5f765b6f7acd834eaad064e39ca8e0eb7bc",
+ "packages/app/src/context/session-working.test.ts": "2c6326946bfe04f5f6bdb879861ba3e1e2f6e749d77940bb6fea55356fcde000",
+ "packages/app/src/context/session-working.ts": "aa3f70d326c481524920502aae5fc0cdc4dc2c5067dd94969242629154358e9c",
"packages/app/src/context/settings.tsx": "19e078e81620ffa21a92797335009b84d4311485a37c5ede2f76906b734292ad",
"packages/app/src/context/split.tsx": "891aeb290b369b327a63ce548dcaab1daca08e41fab430648787be7f84e53ec0",
+ "packages/app/src/context/sse-gap-frame.test.ts": "cf93c169015d66cebbf25752bc5a99296af80a7253bde8b6e565d2c0e50410f3",
+ "packages/app/src/context/sse-gap-frame.ts": "2f64fde540a305579762c5276721d4bbfbf7c862131aaf4462e8a4e8f32c5960",
+ "packages/app/src/context/sse-origin.test.ts": "71cd3aa996a51d7e99236a49cede4769e520e20e53f57c6920fe441f2abb0c7d",
+ "packages/app/src/context/sse-origin.ts": "da9a231907dc50d87cf01758177bee0d4a0b0165d8adb5e152db8eca29dfd9f5",
"packages/app/src/context/stream-gap.test.ts": "b7203445564f44fd1d966ef684a11cb9b5ec567d9ab54f1b7617726fdf44d3f5",
"packages/app/src/context/stream-gap.ts": "2fd8eba30a6112cebf38e33c9418ab2b7d9c9d466f76729d828d5ab52d56c5ec",
+ "packages/app/src/context/stream-liveness.test.ts": "63a06ca7fef3e38c0c9abfa23df3d9043b1a804f3c527400d08ed9eb32c466b0",
+ "packages/app/src/context/stream-liveness.ts": "94fe6fe8a776eb0de6903546f653c89f4577cf7628e7f051afa83280e391d16f",
"packages/app/src/context/tab-migration.ts": "569e69cd3425917c7e195f91c3a838abea68b616267ea7511f3ccf01d94ac01e",
"packages/app/src/context/tabs.tsx": "06d6ddb6d50727a4e0a01ac1ca4a70b06bf63a4e5f881526aead54ccde62472b",
"packages/app/src/context/vault-panel.ts": "f91cb5fb49a9f0d46bd2a1b131f9a218b30147e2ad3617d2a2dc4f1e14599a53",
@@ -188,7 +219,7 @@
"packages/app/src/i18n/compaction-failure.test.ts": "a4a3765beb238a2c157c07c9c8ede8418b9febb814d8d1e3a5b2d8868cbd80d9",
"packages/app/src/i18n/da.ts": "db685cb94f22ace194850efe9bdc02c530777a4583dc9eb9d00b97002b0a0968",
"packages/app/src/i18n/de.ts": "c440ad7543321cae1425e0c2fad92ff73963f21672fbf31176831933573bf103",
- "packages/app/src/i18n/en.ts": "01edcfcc7bea550ef0fc98b49ea00c59ce3c320767fb5ab633ca9a4ba59b0e2b",
+ "packages/app/src/i18n/en.ts": "d073d90198297bcc33ad5da1975a3829250b20a1e18fec35eb65809c35fca973",
"packages/app/src/i18n/es.ts": "8cdec3592cddf0b91d7fd59002c81c4e8973c5bf6573e065df64d48c42e1802f",
"packages/app/src/i18n/fr.ts": "b9f646352189054b6918bfbac670579e76418c3148eb7e5afca9d71d97e7f2b7",
"packages/app/src/i18n/ja.ts": "27ffc41a0fd8dc4b2e814a45c55fad4726ff1c7a85c884cf6af95f8397f17cbf",
@@ -205,23 +236,26 @@
"packages/app/src/pages/empty-workspace-landing.tsx": "ac9410f5b223b30e2d82a6d222992fed2226b6afc1213ff0c8305a230cda7cb2",
"packages/app/src/pages/error.tsx": "db9ba0847cfb205100ad6133cce30c6ea2ce983802af44947e6d2b746c2a6ec5",
"packages/app/src/pages/hold-debug-badge-gate.test.ts": "661f59e172a5b074a6807896f46c18338faa59b6d9c0912fd824df2dae4a5730",
+ "packages/app/src/pages/home-archive.test.ts": "ee52897297aaa4aabfd122f341b585afce945e62348dad542884f1eb608bcda9",
"packages/app/src/pages/home-projects.test.ts": "a90155c37210a0e5b7a095bbfc3ba2d40681f02927d9726148a706bed5ba7891",
"packages/app/src/pages/home-projects.ts": "d55124578dc839b98e2084b488d818e30e4a1abf1ae264499b83e403c2aabca7",
"packages/app/src/pages/home-session-archive.test.ts": "e8f1126287a3e9daa455efcc378e5d72aebdd092158bf8526f7779669b72e850",
"packages/app/src/pages/home-session-archive.ts": "1b56c173f37f4a4dfd54088ef5fdb0dbcd3d9b62aeb08de4bfa0ed34f07d7ad5",
- "packages/app/src/pages/home.tsx": "06fb5c7718aacfd29b35e730e05d45076b39d6cd5d936d1270fb1bd9424c32fa",
- "packages/app/src/pages/layout-new.tsx": "3ef477634cb6d7de0c32d3572c5ff699e07efff47e6e75458583456eb1ff8b27",
- "packages/app/src/pages/layout.tsx": "eaf8bed869b5ec53582db732119fb903b223d749ebda354b1e388de3be02dc52",
+ "packages/app/src/pages/home.tsx": "3e33dca2d17a371daa2f02509eb2730ad94068cf60584c428e22d69056582c05",
+ "packages/app/src/pages/layout-archive.test.ts": "eed98374a2bea6c692d7d13116db9287ee61ff526b90952895bfb0f61afbfb2d",
+ "packages/app/src/pages/layout-new.tsx": "7ba4195f7b893a371641fcdf9f6ca228afa09b8f078bbf1f7aa912ce98e79ca0",
+ "packages/app/src/pages/layout.tsx": "90306f1691cf22b35627e063ce6401ea0ed2f98e12db022861f403de2d0f2d08",
"packages/app/src/pages/new-session-landing.test.ts": "95e2780456a653a71e6f66b4302ae546e308a29e6b8ad9e84442938a3a226905",
"packages/app/src/pages/new-session-landing.ts": "528d27cd5d441b521674328fb7812fb2acf77233e9e436d081c98ce611e8b268",
"packages/app/src/pages/new-session.tsx": "c060bbb15569b6da2f67146e6af0879ccb5444345df03dbc2f9b39cb98b9db78",
"packages/app/src/pages/overlay-content-hash.test.ts": "aa208098213522dcde42434408afe9c8641fad663f44a58c316922dbd0da2f5c",
- "packages/app/src/pages/session.tsx": "e502d2274c351612aacbed4dbee49e8692da1fb6b3411fbd1491499737772a39",
- "packages/app/src/utils/amicode-bridge.ts": "5104a04c7822f80dc0c564619e7dd2d850ba50035dbc86ea875604ecd97df05f",
+ "packages/app/src/pages/session.tsx": "b054b966b3ec70c706699f7e473032688efa66d966312fc67523509bc92a8d95",
+ "packages/app/src/utils/amicode-bridge.ts": "3ec87c24c0384d312f7eb644807d9217a53b1b4b97f7fffadab1cc1595603f3b",
"packages/app/src/utils/amicode-bug-report.test.ts": "b1bbf49230ff3aa6d1a43ffa9165ba5a84ca4ee18a6d49f52ee5cd887c3e6444",
"packages/app/src/utils/amicode-bug-report.ts": "d55d9cecfe17719c0d4a0ca498345c7eff6fca1de6b645e4bc3fecb40f2f4645",
"packages/app/src/utils/amicode-developer.ts": "b5cecd598b7e50631d2a441d7c72d1a012a2a77bdee3629fcfe9400a36312ad6",
- "packages/app/src/utils/amicode-fetch.ts": "ec62b502e6b8688cef0a2aa249715e847032265fd0e150d73ab3021a63db0e33",
+ "packages/app/src/utils/amicode-fetch.test.ts": "a99d0d366d444b5b54a4b42f2417f9e30617ecccea38b85dfeb419585e9e2b8d",
+ "packages/app/src/utils/amicode-fetch.ts": "fbd40a63233e4e89aeefbb34c67e07aa21f595c6244b131abfca3e8bc35f0788",
"packages/app/src/utils/amicode-hidden-project.test.ts": "333ce7e13a2940268448db06fcfc8bf1733110b5311932820ade1dc9ab573180",
"packages/app/src/utils/amicode-hidden-project.ts": "d845a80ecad2d497528a5071f55cffab72e7b451f16d106cdb594f5bbd62c0ee",
"packages/app/src/utils/amicode-pane.ts": "46c18a2dc47c5db154f70272d25031e6834539ad03f6ab25613f082426f7c642",
@@ -282,17 +316,19 @@
"packages/llm/src/protocols/bedrock-converse.ts": "834a415fa8ddf9ad1224133f8352d0a926694f19f0954201d182111c171ec03b",
"packages/llm/test/provider/bedrock-converse.test.ts": "e8e14d60e8502e928f170f8ac5b406c44bf16d2dd34996ddc2fb7b9261decf85",
"packages/opencode/src/agent/agent.ts": "512372a4c62997e9565266638ada19b9dc0a3685b0c0483fd28fede2abdb5c9b",
+ "packages/opencode/src/effect/runner.ts": "b660b5b22df810b16293c23c6d832e5e8dcfb12b9a8a858c875d2c051c855b81",
"packages/opencode/src/mcp/browser.ts": "ecdda42cbd55a97b8d14050aa15c6e6450a1d63b0a641c8c8e5b81e50f957c87",
"packages/opencode/src/mcp/index.ts": "da45165e6791a5b30c8962c7fd0ea391bb8e1931cc2357c6b90e9fd090cdeb0d",
"packages/opencode/src/project/instance-context.ts": "65a35792e50e96a50ea14cd9f928045c0e2fc15eb49506f5bbceee38c4af1457",
"packages/opencode/src/project/instance-store.ts": "c22d3e2499e63dd8d9395d58189df75444d306774df833e746f22e327edcf2ab",
"packages/opencode/src/provider/auth.ts": "b0ab1322c1039e27176bf9e3500603eae27506065ac141a69710220f3361f6b8",
"packages/opencode/src/provider/error.ts": "7f3c8210c775e2ce98140bf015dc97e61192912af062c75e18ecb09ee2736a0f",
- "packages/opencode/src/provider/provider.ts": "abe4bcfd92c7b1d24760d6a8ad9dd97e12fe3278e0168edb19c3b8d3da497ef2",
+ "packages/opencode/src/provider/provider.ts": "435a2838291d2aa32de4d1ecab1778b443f9ec9b3b5ed409b94d3b2de0142853",
"packages/opencode/src/provider/transform.ts": "70c7492d4a23a30ebc340a5605702b1602960f4b412e18557534e02962c137b8",
- "packages/opencode/src/server/server.ts": "56b3c2047917aa80d1dfb29bfa9839bcc2ad7f5a6f4db12103cd962a98fba866",
+ "packages/opencode/src/server/auth.ts": "a054aff2ea06a303a99f326275332a68089c58916356a26d66af44f048f597cc",
+ "packages/opencode/src/server/server.ts": "9cbd3503b77b61b07d754f1064207c04a5f28175c4138fbd08bba2abf0016803",
"packages/opencode/src/session/business-record.ts": "d82db061055d0dd26ad95b34bff688ce23fd06a8fb9248dd335c6726c6ff0f5e",
- "packages/opencode/src/session/compaction.ts": "fd6159a8b04050706b6968c7a1b091f79d06e6e4aa988a12629a7616b8128e94",
+ "packages/opencode/src/session/compaction.ts": "101ce3aba83206a31e80fd7e00ed7ec7bbea8180c7b5bda5a848a319addc2013",
"packages/opencode/src/session/evidence.ts": "f1b11aa0dcfb345f074e408ab95986ea9d932249e82d6f2cc6594d8d4ab65a1e",
"packages/opencode/src/session/external-diff.ts": "2e1955ac23f5a50f572d61152a14cc994aee9573c59d6bd261a060513b4140b2",
"packages/opencode/src/session/instruction.ts": "19640d02c96b8ec764edd49b0ce74879432fd2d2cbb4d2ad0076caa200dd4797",
@@ -305,25 +341,31 @@
"packages/opencode/src/session/receipt.ts": "06fd30326424112b8dacd756e4bba5424b3a7a2d10318464a5670aeabb0c4808",
"packages/opencode/src/session/reminders.ts": "abcd602de7f8ab8ab1a0364d30d28f1eb04fe81bf7cd837db9e926ffecf2dc6c",
"packages/opencode/src/session/rollout.ts": "55518da37d6d4d1298eeab1c7ee208f59b80b7246f3370438de98ad774e4b02e",
- "packages/opencode/src/session/session.ts": "1d94c364a55c3124ba6ab762f599cc4194905d7e23ec91ba79552671cd4d86c7",
+ "packages/opencode/src/session/run-state.ts": "e2479d977b3a0bff5bcad8a0f6660d91732e866872787799422d82e5a692c429",
+ "packages/opencode/src/session/session.ts": "d3d8b9e7603aa9e40113a361f5bdbd7a48924250274071f5c07e0c13e1e62d4d",
+ "packages/opencode/src/session/status.ts": "3e4aae80765f57fa5b3e50e7eefbf70a887c6054ebe4a1850640d96d4548d468",
"packages/opencode/src/session/summary.ts": "09f546db362a992156bdb4901cce58e495bd38bab6ba41b4386cca77bebab7f3",
- "packages/opencode/src/session/system.ts": "f607576faf505085c2e31f208f06f45eaec32f0509570fa9cf6e4c130a0a50a5",
+ "packages/opencode/src/session/system.ts": "b8708f3e63386e08041368e6dfa887f250bc6a7543ca396bb8b838041e8aeb19",
"packages/opencode/src/session/turn-output.ts": "889624ca54a8149d35f200c7efa732771dfca36984e8a322a9781b1a25434762",
"packages/opencode/src/session/user-mutation.ts": "8f8cebe1370a8a54361c546fb6777ee4fbdd47d0dec1ef870305ce5dbe232208",
"packages/opencode/src/snapshot/index.ts": "4d76a78c48597bda2379e2c3396f4a53828b21c6ce03e1975d2911150822c6de",
"packages/opencode/src/tool/apply_patch.ts": "37535a783d115ac2032def7a28cce56e304f4e7c75d3ecac05b9a37054ae5b4f",
"packages/opencode/src/tool/edit.ts": "38aa4ce0751ff99bbb0807bcda52f8b9a1d32b6e342df4cc2aedab1f03d342b1",
"packages/opencode/src/tool/question.txt": "49bbecb33a825620c2ed14ec9b61faa294359c3673c33277e32f57c1070d2806",
- "packages/opencode/src/tool/registry.ts": "2b0e8546f01643e78b9dee504357bfc33f2d7075ec45f8a1462d8c3ee8e7aff5",
+ "packages/opencode/src/tool/registry.ts": "0097ab8a7904559d0354d165245caff6bf0e887c99ce267be77fa4ee4b66ec3c",
"packages/opencode/src/tool/task.ts": "ddeb07a1cf009dcafc1d1d1c86b9d507676a8991db33a4632c8f29ae0e70e654",
"packages/opencode/src/tool/truncate.ts": "92c59d83c4a660116d6ecc48e900aeb16bde93962bfae9f520193fda18273379",
"packages/opencode/src/tool/widget-author.ts": "912be8636b162adca8796a501e927019c2449393c14bb5f3ff478b02083b73a9",
"packages/opencode/src/tool/widget-author.txt": "a2572913debbbbd2d4a644dcdf4576accbc7efdcc8217c0fc9f5a011e7a15ec4",
"packages/opencode/src/tool/write.ts": "094661a3bdd3fab2cd439c0b6cb942b6a2653d3e9451420523b33acbc762d1a7",
"packages/opencode/src/worktree/index.ts": "a86f542b579ef40f2940d36e2c8011ce5592d4811cd5ed437bafe614789fb87e",
+ "packages/opencode/test/acp/service-session.test.ts": "5810c4a0c935f06389c5f01df791e4ae82a4c32268407baf32dc4502d487f282",
+ "packages/opencode/test/effect/runner-queue.test.ts": "9ce3074dc4a86f620381e2c7ada08b9034b500a62b5da1a7d63896f2519085ca",
+ "packages/opencode/test/effect/runner.test.ts": "6be02ad6a7d173642100a9e17b323410bad7ece842a00018d2170b91d4894c5a",
"packages/opencode/test/fixture/fixture.ts": "357bdaa06c6233a91069db20cc5f1cbb2d6eafa3bc7ba3e89b019859d195d6ef",
"packages/opencode/test/lib/cli-process.ts": "110c43d247930e834ffffb9068682c5ab2e1b95460ec1ac3d0a75cafbdee60ff",
"packages/opencode/test/project/worktree-rename.test.ts": "3ec168c9f8595249b75832895a251483ec1f3641ce44ad0bd5a664b5bbd6dad1",
+ "packages/opencode/test/provider/combine-abort-signals.test.ts": "0a997303e717184e3e237e4b978b64a34ab8022724c47245b25f32e90738d205",
"packages/opencode/test/provider/harmoniqs-tools.test.ts": "0c626daa51c312eb81dd854509a2e1944712ee9620a36eb4db66078ab80d6b82",
"packages/opencode/test/provider/provider.test.ts": "5b2cbed8994d3bbc968a72d01459460c1de72b6117a0795c2bc6a10abbf02926",
"packages/opencode/test/provider/transform.test.ts": "ecf9dd1bdec36c2126f3140d43125728c06211b9771d47619ff65f7453ff37fd",
@@ -344,7 +386,9 @@
"packages/opencode/test/server/amicode-widget-manifest.test.ts": "ac404d1737bdde8ea3464cf0b5fa12f0a6f9e630a0698ca5fdec38449cb66d0f",
"packages/opencode/test/server/amicode-widgets-src.test.ts": "fa95521084f95e0c421e7f7f13bf61de7e6b5cc9f42aa9fade486f5c1b53a65d",
"packages/opencode/test/server/amicode-widgets.test.ts": "14fc6ccff4e83a5b005a724936e9f0d7ce411fab74109f319e8bc748665f7ab8",
+ "packages/opencode/test/server/auth-accept-set.test.ts": "8fe698812ed9a809d21ace1a9751bd3f1976ab1e58ad5bda65dbfff58df8e3bc",
"packages/opencode/test/server/httpapi-compression.test.ts": "5f7603d7018e0b95f1c2f36c01dd297c01770bfcdd91ce29778e03b7d6143f75",
+ "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "0c91b7417e615850edfde3f2878207dab6b6624d4cfbb6e6783b2e6d9aa4dff0",
"packages/opencode/test/server/httpapi-file.test.ts": "d3386129bc81467ecbcc57a3861f53767ee56ac41b4ff6ad58d8b168fb3b3373",
"packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "4b73a89d2431c02bc3b665d93f9002914e51eb1add979dc1867efbcbc0c36e25",
"packages/opencode/test/server/httpapi-provider.test.ts": "9e8b3e085b478e3f99b689ffff600e10c0b4b50b3cabdbd749f88dae19bf5904",
@@ -357,10 +401,11 @@
"packages/opencode/test/server/workspace-routing.test.ts": "2574731ac379c540f450edc15f8750ac5360e8c33b4a0dad7c61e8eb686137bb",
"packages/opencode/test/session/adversarial-e2e.test.ts": "1b471a69223d040c5d5f4f2ee99c2daa6df56bdf11679b65d902c15bedac3e80",
"packages/opencode/test/session/business-record.test.ts": "7857094479408bcfd6ea8d9f5b425c6384bcf8490f930674bc1c203e82782d75",
- "packages/opencode/test/session/instruction.test.ts": "8b4d954eb10afa852a671e6c0a94d19604deacc9a62f9c3651c7456cc32736b7",
+ "packages/opencode/test/session/instruction.test.ts": "06e1b371a08c7d07ed7688b997eb43ca9957ccd07936f601797d7f04a2bf0ab7",
"packages/opencode/test/session/llm-native-recorded.test.ts": "7139b58569af0172862af521e8f8d7a78132f40f06dcceb4baf8ca3a2f07d3bd",
"packages/opencode/test/session/message-v2.test.ts": "3a45b7b884a33ee48daf533ac84bf5c0b34d01845b8c1c4cc7e68de88b063ed1",
"packages/opencode/test/session/mutation.test.ts": "ccb82b6858ac097d2af09a94497d05f6a0ff843b928a69e763fdb510905a10cd",
+ "packages/opencode/test/session/processor-effect.test.ts": "51ebe1cb9b7b0e1d249472367491a1306b5add24ca8b04d5dc15b2b2fa1f1b56",
"packages/opencode/test/session/prompt.test.ts": "e3d0748edb833640ef284857ae725e4b974843480d4128f56239e3fd1e6d481f",
"packages/opencode/test/session/prose-guard.test.ts": "7239a758cbf27df4c3879fa9ee0374964559c1461773c2f5a85196f64632e6c4",
"packages/opencode/test/session/receipt-privacy.test.ts": "6c8777edc280a061fec1a4d16e2504aec18bdf9cd6dd2cedc7a09ef8fc6c2ca0",
@@ -454,7 +499,7 @@
"packages/ui/src/amicode/devtools-header-alignment.test.ts": "8506411132e142d54a27e551f2dd3806166824a777dc002be6fb5477c77af5a3",
"packages/ui/src/amicode/edit-row.test.ts": "7c68fcf083d3854bfb70768b648110e001843a290dcfdc25869c1a4681445b94",
"packages/ui/src/amicode/edit-row.ts": "28d5f39470376968e7eabc3f50807a779177e9c9f2081302e4b5eb15306aef1b",
- "packages/ui/src/amicode/entity-rail.tsx": "fee936650d0f05ab00e46ebb70333b2c92a57ab0da1e7fcee942d7e6b64004b7",
+ "packages/ui/src/amicode/entity-rail.tsx": "21f1a44653eee4bed80ac32189b8322a89be20a0146b58dc2e2d92bc207117b3",
"packages/ui/src/amicode/entity-view.tsx": "e181575c8bd66a7cbdf89411d0cc1d12173a74a3bac9122163f2e27331e3becc",
"packages/ui/src/amicode/facets.test.ts": "e89bd4e49276f52ac0af4990b202919331a48302b4b34c1ec1d8406fa4fccabf",
"packages/ui/src/amicode/facets.ts": "074f7843a105961da3b4b89c67ea20de106968b4524f9094a5f57d1ecb0acee0",
@@ -471,8 +516,8 @@
"packages/ui/src/amicode/institution-lookup.ts": "3edf90b0778ea50b048975cc317a18a1aa82290cd4f5241cf9df74fd17866fe9",
"packages/ui/src/amicode/onboarding-wizard.test.ts": "268df538ea99280c5297f9b1dc06a236da2fb82830c2d49a107feca3f610279f",
"packages/ui/src/amicode/onboarding-wizard.tsx": "75db623f8f50fd875f1bde8942841fbb5167c77a34fe3c6dbfa80732841b53e8",
- "packages/ui/src/amicode/problem.test.ts": "e7a15928df883fbed424744953a87ad0b5ba8a0c1039dc190ed00bfefe85eeee",
- "packages/ui/src/amicode/problem.ts": "d40ca9f4f411b112d97c7d1067b5834ff22f763456df17df7fa0ad31768a3a51",
+ "packages/ui/src/amicode/problem.test.ts": "096b447e1e16d1ae382c3916018e9df60ea6418404701731b46a311e443b543f",
+ "packages/ui/src/amicode/problem.ts": "76e47d343b1e4e3faf6cb31caf51aa15817a63f99bf3b1385b77126bc2c8181c",
"packages/ui/src/amicode/project-type-icons.test.ts": "1db755e2818a5e97d018e7133ae329b9ce1ff63fc347a06bcac7b9f4d0784969",
"packages/ui/src/amicode/rail-gate.test.ts": "7cf0ff5add76f27eb4022224a0f18532a7bc419dd5368788e7a0667f9913dcae",
"packages/ui/src/amicode/rail-gate.ts": "c24f99d8285a5484863078acedc5ed36d8de67084c2baaf2bb2f7b6d87e657cb",
@@ -536,7 +581,7 @@
"packages/ui/src/components/amicode-connections-tab.tsx": "d184dfcd5c645332c2cc1c79356e551cd53b67a6b61ed16e78d041de5d13fafe",
"packages/ui/src/components/amicode-edit-row.tsx": "99c8c4e90ea02be21f74603c5a5ac1e2c030f487d62ce9da972c99dff505d892",
"packages/ui/src/components/amicode-entity-rail.tsx": "5377561b952d9e6bd9bd6a16cec91a37770f14195bb7bd1cd294eb20a1e78a3d",
- "packages/ui/src/components/amicode-entity-view.tsx": "1ff46b6abdca65992606755e0d5e516483bbd8f3f8cef54b81cb6faa08d895dc",
+ "packages/ui/src/components/amicode-entity-view.tsx": "1514d29716dc6488fa06dee4672b718444700f4563c9ac6de00e5941005d005b",
"packages/ui/src/components/amicode-footer.tsx": "d7d7344613c7acbf2d4b0dd3f33c89f67fae6ac065bcea047e7dc26d13bca5a0",
"packages/ui/src/components/amicode-getting-started.tsx": "219524d12d3b7e35a3e6ac4a112c64b8a54ce1b695bb61dc4a0bcc64a7f37e2e",
"packages/ui/src/components/amicode-harmonic-dot.tsx": "3f459b3e579e5d8fc3de5562593ec4236d22878f1da59ba46b74e524559542dc",
@@ -586,6 +631,7 @@
"packages/ui/src/context/dialog.tsx": "876f8a5dc26ebb98f1f5e9e528e7d0bd7aa79c0a107b62027dd9fe78ed2cc620",
"packages/ui/src/context/marked-math.test.ts": "6edcbe85175366da339d8d921b0f98834e95ea9cf500e91dfae7c2c738bdf902",
"packages/ui/src/context/marked-parser.tsx": "90b79a684f9c0b7afff63bb5776512779eb3912e76f85a7bb2e2d97bb575d4b9",
+ "packages/ui/src/hooks/create-auto-scroll.tsx": "4a8e9aa41f075b5e1815e647ab3a8a5333340d00e20ff7414f5e6c1f9a738337",
"packages/ui/src/i18n/ar.ts": "8113d320fe4a5300f1bd70150ae1695d68cb783e1c6e2be43b8a429b7dbdb711",
"packages/ui/src/i18n/br.ts": "cbde2da43ef820fe2664cb96cb973fd722156a6c8a3c5e16d18638bf014e5196",
"packages/ui/src/i18n/bs.ts": "23ea0ae34a4edaa4599b13f63f481be861e28da3b0d898987e291c7e91a9f11e",
@@ -621,20 +667,26 @@
"packages/app/src/components/prompt-input/placeholder.test.ts": "d3cee5e2c758faf5fda626e270adad81129622bbc2bf2c335c2bffe1544165c3",
"packages/app/src/components/prompt-input/placeholder.ts": "6b2af23a0588e7970cd0dd059fc60c3671c907a76ba3db660483a932f1dce79d",
"packages/app/src/components/prompt-input/slash-popover.tsx": "a1800173edebcd419802796470f7270acaf0ef689a72f1b81a1cedfd8968337d",
- "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "68a94b03e75625531b2f2ad161ef9d0b93365f9edae154a797f82031b5bfc062",
- "packages/app/src/components/prompt-input/submit.ts": "6f14368b0d481bdf10a2711e08da41010edd9f67ca8af90b930c096a56778b89",
+ "packages/app/src/components/prompt-input/submit-stream-gap.test.ts": "351f0f5a8cf7aff55c859cb82fbca388ef848831256b80f74ee52ab9cf5d07c6",
+ "packages/app/src/components/prompt-input/submit.ts": "cdb01cf2f2485fbbdb7ddc77dcdea99197d8c03bd302b8fe032bee8c15f525c3",
"packages/app/src/components/session/context-warning-banner.tsx": "f5fab4d7a9536238b70d64488fc30d44f6a4948fdcffd910a9666f4b6c296019",
- "packages/app/src/components/session/index.ts": "21473290d4a1a3d0670fd878372ddf21ff22d4a0b30d1aef6c81ea906c1488b4",
+ "packages/app/src/components/session/index.ts": "f0d58fa3a336639566ec51a10b692649707f91a7b780c0b5c4dcf18642d030e8",
"packages/app/src/components/session/panel-menu.tsx": "42f323046b7375ae158023a51506038eb15f2e1545c407f45acd47446a8ef3e0",
"packages/app/src/components/session/pdf-canvas-view.tsx": "591bac93a2e1e10a1b42404a29bcc5222ebbc5faf734c80556fbca35c90551dd",
"packages/app/src/components/session/pdf-restore.test.ts": "4cd4cabbdc8063deb5fe95b5e637cf154ccdf864c5ff9f2f6833826bd69e4f90",
"packages/app/src/components/session/pdf-restore.ts": "e5c92096b9302a452439babfc2732610a7e0d18e23793ce3bf6352fa32f91a36",
"packages/app/src/components/session/preview-file-helpers.test.ts": "e0c1ccb40b48990c0c5b87ea62a35de60b860cd760067c7f1327cbb1d36584b1",
"packages/app/src/components/session/preview-file-helpers.ts": "2244aaef25f7412df7b53a45a7d2f72380d45eb90caefa5e96c48d01e988eb4e",
- "packages/app/src/components/session/preview-file-view.tsx": "7b221de755b851170e4b3dfc4d78d8446f6dfa40116b7529466db24e714d90f5",
- "packages/app/src/components/session/session-chats-dropdown.test.ts": "2003d2a15781337ea6bff2c68e730cc5b6df38697f15d927ab26913936444db7",
- "packages/app/src/components/session/session-context-tab.tsx": "227243b178b517f067d9ae0ae0eec3c559beeb6681828158b0600a17e98e7f81",
- "packages/app/src/components/session/session-header.tsx": "7113b3eca8ec950d4a547a2b7e2661b5e47f593653e12fca659b5b5f92cb90a6",
+ "packages/app/src/components/session/preview-file-view.tsx": "8292f2430d41c3b6a587fcbc6547ab6828445542a8ddc6c4554d115c1340f3f3",
+ "packages/app/src/components/session/preview-human-write-gate.ts": "4c4b1cf258a06b0012d722add0125ecb77a0f4e463dd95d193f21e5e73541098",
+ "packages/app/src/components/session/session-archived-fleet.test.ts": "f5d0e16dea2ab6e6cb44a6768b5ef1e772f8ccc39aeeb02571525ec44336ab57",
+ "packages/app/src/components/session/session-chats-dropdown.test.ts": "72e2e797fe96a59ee46ae59c4a5cb34eae8d338a468977bd4e8f61cefd6abd49",
+ "packages/app/src/components/session/session-context-tab.tsx": "4062ef39e161f041bf1d7a300173c510cecd581ad8e1fbb4503961b7cd1a29f5",
+ "packages/app/src/components/session/session-fleet-control-projection.ts": "c1a91ce975dd0399251e491091af81abf2d24c0bbba074a334b58c56868bcad1",
+ "packages/app/src/components/session/session-fleet-peers.test.ts": "0b9e128d62f09907691d676b560edd610b6d92fa919d38705addd888d6fce54a",
+ "packages/app/src/components/session/session-fleet-peers.ts": "474fd37998061f1c2df1be81c88e8362654f5aee6bcf79fd4b6669caf2ce50bd",
+ "packages/app/src/components/session/session-header.test.tsx": "733f48a613d3d1fb57ae87a38a0f857d1f37261ee4b1db2bcf0dd8457ec6c196",
+ "packages/app/src/components/session/session-header.tsx": "8d5e943e3b2b338e519530842e5c06cdb30ca5d393d9652cc362e0105f0f7e64",
"packages/app/src/components/session/session-new-view.tsx": "9510a4f550a3f0d4791e98e8025666f09d70a60fb66f193e48ee61feddae5a57",
"packages/app/src/components/session/session-preview-tab.tsx": "d3a210598d181c0aa29e9f3b5db3cd691169f3ee467001313a3d9a24bee93dbe",
"packages/app/src/components/session/session-preview-tabs.test.ts": "773fc0116c5f61302eaa4a09fd5f91e6b26dee978c89e29c21714243ae3a5653",
@@ -659,17 +711,17 @@
"packages/app/src/components/settings-v2/permission-scope-relocation.test.ts": "39539c4fc4b7433f08b913100f48a7fa75d6ab47615ba697aa9ae8eee09d316b",
"packages/app/src/components/settings-v2/permissions.tsx": "5746c8ef02da833ff72fee1375dab2a7ee241bb111e8ef15b21ae70c49349543",
"packages/app/src/components/settings-v2/providers.tsx": "e15f23a40058abb4299ad7444df2845e3c24b5da3ca58a731e0cbafe58de2a76",
- "packages/app/src/components/settings-v2/settings-v2.css": "38818ab5349cf637023379d2b1ee62081d456676020bdb445576865327858162",
+ "packages/app/src/components/settings-v2/settings-v2.css": "129ebe678b18d5ea897e077ef16e7b71d003468f29c49b0f47e2b54d310c8f5b",
"packages/app/src/components/settings-v2/skills-controller.ts": "2c7344d144da0eec1c25083ead45f5afb2a8c6fd759541ad719cae9a09fcd19b",
"packages/app/src/components/settings-v2/skills.tsx": "42deb0c11fcba158abad857b9644ffb551aed5ba9e00dbfd1584a7ffaaba5aa0",
"packages/app/src/components/ui/drawer.tsx": "76574ca4c6997209f37a766a61000d707332a9a857f6ea8100a3cee1be58d65c",
"packages/app/src/context/global-sync/bootstrap.test.ts": "ed66b4c0427d51c015fd286dd63c2cc24a25391320366d75e28634334d70e366",
- "packages/app/src/context/global-sync/child-store.ts": "3fde298a6cda8e96efc22bccee4ef124e139abd6ec28510363c78f702bf70db9",
- "packages/app/src/context/global-sync/event-reducer.test.ts": "0528ed45952107b3ca174e72fd0b920191dbbd49eb795c4dff63cf89c2c91c0e",
- "packages/app/src/context/global-sync/event-reducer.ts": "9c26e9e83709e56c57b228a03186df6bf1a52a073c4b7e721853956ee0da6142",
+ "packages/app/src/context/global-sync/child-store.ts": "ec6464eaba08a8cf41f3169777faeead75b55a80ce117e830becc9535f6e1b2c",
+ "packages/app/src/context/global-sync/event-reducer.test.ts": "9db2c436adb78f54662126defbf06a2c0a911c5a3b1210c0dba95adb35297533",
+ "packages/app/src/context/global-sync/event-reducer.ts": "4bb0dac9c8962aed0ceaf08ba8aa70fa2ca64e928f3db8eb50872ecdbcf328b8",
"packages/app/src/context/global-sync/session-cache.test.ts": "e3c66ef76ff20a416f3b2d72041acd66c2a65da57ded92cbcb381ab71474285f",
- "packages/app/src/context/global-sync/session-cache.ts": "70f60c37ab9ffcb5ac0778f4962d88f0e7c6fb61fc39a18bda28942322605316",
- "packages/app/src/context/global-sync/types.ts": "76af58cc126d49bbcb6ffc7ea4d6bce72dc9a714f9ee05de576d823f92b27963",
+ "packages/app/src/context/global-sync/session-cache.ts": "78861c053e9ba7574cc0766681773087b2e5b93eabf28dd43dbdc88535389fe0",
+ "packages/app/src/context/global-sync/types.ts": "da1408b5665534588a0f437972730b810b0d1271a98bb8d0c442f3d4625bbf7f",
"packages/app/src/pages/home/home-projects-view.tsx": "074f1cdd7926db9574164824666cd63f1f5bd0f8e8dfb65453f6fe8f2dca0ec8",
"packages/app/src/pages/home/home-sessions-view.tsx": "5b73b18e4349a628a15e09a3810ecf2319bde222e18fe8de5d08373ac91fb37c",
"packages/app/src/pages/layout/helpers.test.ts": "3a77cfe018ce2867cccded2c726f556e53a9895a77baa944a4878714b9b2b59e",
@@ -685,16 +737,26 @@
"packages/app/src/pages/layout/worktree-auto-rename.test.ts": "fb6fac363b8bee1225bbcff8098019c02f19b095b5cd96c29f7e673acb1e4e89",
"packages/app/src/pages/layout/worktree-auto-rename.ts": "2ef3e506b96b972cb55799a89aa54ca6453fc31c80bc022aa16db6ecfe9cdced",
"packages/app/src/pages/new-session/new-session-draft-controller.ts": "3607771a22b6855afd0b0d666de662c9070a2dcd56ae96ba51d89c9e11018e81",
- "packages/app/src/pages/new-session/new-session-view.test.ts": "615110d1a956bb2321ca77c998d6f5da434756d166ee61cf71ca232cc9efb0c4",
- "packages/app/src/pages/new-session/new-session-view.tsx": "30b0b048f88ce741215e642b0760758638ad36696d2eb8770bc8e6b8b8f3fc29",
+ "packages/app/src/pages/new-session/new-session-machine-mount.test.ts": "debb3b8b058a88a64e4c2cc56751502b241be53ce5a74b9c81ee51a617675c6f",
+ "packages/app/src/pages/new-session/new-session-machine-mount.ts": "2eb56ed87f78c15a34dae63a95fa1efddbb7395a3d6893d2b148c2f594910b24",
+ "packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx": "e480e65c849af1434c713452eb55c9b39aeb660292773468e08108761e87f45d",
+ "packages/app/src/pages/new-session/new-session-machine-picker.test.ts": "2f2adf9d738aee98f2989c5453c97eeba0e48522b7863c99d9c0be9a6c11ff3b",
+ "packages/app/src/pages/new-session/new-session-machine-selection.ts": "618df365b74547d997920fa3d14e1f73f3b5d618e54f2b1297b8374535900f7a",
+ "packages/app/src/pages/new-session/new-session-view.test.ts": "eb337485bf03facbc59d9eadac37cfeb04f75bf97814df80d3be4d0ec9455047",
+ "packages/app/src/pages/new-session/new-session-view.tsx": "8fa065e3d792f99de6fdb6f0de8c6205fab519159342655fe59fc09ea67691b1",
"packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "4991e775d3a6c63a514072ccfa5aad190c586383b77ef41689c853fb271d3bf4",
- "packages/app/src/pages/session/helpers.test.ts": "09fd272c286c7746db5ce635e1c453c8943e46111d030b4892af9145a89401db",
- "packages/app/src/pages/session/helpers.ts": "0311f52b7cc34c2c38950512c01397e9672c89a9e61fdfa0e4696192da114f34",
+ "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "f3bb956e343ccd82acd8bf38c9ad2413d48b24ec9f484c5dfd3939d9e07139b7",
+ "packages/app/src/pages/session/fleet-manager-tab.tsx": "1e7db225a1f2fa3234e0eb47f314de49185db1f3ebddb5585fd0247184adebaa",
+ "packages/app/src/pages/session/fleet-manager.test.ts": "b10aa624fba721c66e295a08f5bb862363701e09fe870295028c8d4b39d3b850",
+ "packages/app/src/pages/session/fleet-manager.ts": "2177198394e525abd793da064dde0a1ba119e87084d2d48cd6f4766451e27f03",
+ "packages/app/src/pages/session/helpers.test.ts": "b6a8bcbb78fe237d3dc347520b596342c9561c45056d1cfa4dd2e15ec34425ed",
+ "packages/app/src/pages/session/helpers.ts": "1b777919c8f3c864b52469e90564f873445f9e7877c87f7290db3578823091cd",
+ "packages/app/src/pages/session/preview-human-write-gate.test.ts": "b657eadc3f2fed1ff7332b17afc6dbe5fb23709a27fd1d36249e5a25788f6bc4",
"packages/app/src/pages/session/review-tab.tsx": "3c90e6b11fad40ea19a258966c374ee12c05bd0e7891fcea561ba0b965ddc9f5",
"packages/app/src/pages/session/session-panel-width.test.ts": "9482afba7fbce254cbbd21ef885e9e9620b981616681635376f466478f611155",
"packages/app/src/pages/session/session-panel-width.ts": "02ee3a02ed78db2eac47c2528055cf78aef95f389641e929ddc56c17471dcbe2",
"packages/app/src/pages/session/session-side-panel-structure.test.ts": "b2d1d007d9200d0ac4a0889e45c2dbe57a324a10cb26ae9ab8f420d3c890402b",
- "packages/app/src/pages/session/session-side-panel.tsx": "b295340d9a39ba37c9e025d6360669d2a879284c4e60df795b7902c5a4cef3fc",
+ "packages/app/src/pages/session/session-side-panel.tsx": "a27806be45c93c2cd8ae24639829cca936c2e444fdab1ff224270847effdc47c",
"packages/app/src/pages/session/terminal-panel-v2.tsx": "68dad9307f1d2abf3ff9248e451bd08005acf01ddc46b30a6d01b58f4a90dce0",
"packages/app/src/pages/session/use-amicode-commands.test.ts": "65671d054c404edd2b799b0f6591a9c055e6ecb454a674fa0bd9a7fa6f68e111",
"packages/app/src/pages/session/use-amicode-commands.tsx": "88bfdad9ae6dfd920b8e6a14ffebd2cf9e52b8f67bb9284cb358ad940643ca67",
@@ -723,6 +785,7 @@
"packages/opencode/src/server/amicode/profile.ts": "521338a7990deb8d0f352ed96d0f0a96fd56daeeec407c3ae133b02dfb2ed5c1",
"packages/opencode/src/server/amicode/project.ts": "7f0a8a15aea0ef367a00ab31ba4329ecf2344282965afce553005b5355be63c3",
"packages/opencode/src/server/amicode/run-terminal.ts": "c6857036c1dca90e8b462e916f49df707757f227d0ed74f072b8e94d08d74cd0",
+ "packages/opencode/src/server/amicode/self-shutdown.ts": "ed0961edf483adeb94096704159eaf02b677fae8f851a05518bc78c85f442085",
"packages/opencode/src/server/amicode/toml-lite.ts": "0001fb071c53c1a3ab330e40550ccfba497b78e2a0cc45084e1c016d8f215831",
"packages/opencode/src/server/amicode/vault-browser.ts": "8a8252fb0b46583c75e9475d7c89e68c092f5255b21476f3c3d0b9d379ea2d48",
"packages/opencode/src/server/amicode/vaults.ts": "e48c90c64de27abe24a510f188def6b56297d9bb1fa1ae08cbcc94888d42044a",
@@ -734,13 +797,14 @@
"packages/opencode/src/server/shared/public-ui.ts": "a843b4e84673d9a5e34f36eb7cffbb499850f7588154c23061fc8a81cd1369ef",
"packages/opencode/src/server/shared/ui.ts": "eba93fdd47c786d723d2593e788f6b7278b072548acb916d19eeb2e9616a4197",
"packages/opencode/src/server/shared/workspace-routing.ts": "3704b6cf218fc165ce9c1cdad0ee42fc9cf604976e4a552478200f85db80876a",
- "packages/opencode/src/session/llm/request.ts": "bcee69e2f73778020e68917e099207ab636bfd5b045d85632ab9ecf6c164cfc4",
+ "packages/opencode/src/session/llm/request.ts": "d6fdcac11a92ef9fa18efce69791900f30c66e54e94a1647ea70136e8825e7e6",
"packages/opencode/src/session/prompt/anthropic.txt": "e3e1a55159f0104ceec69098620eb281cd8927cfa808ab5feaa753719af27f05",
"packages/opencode/src/session/prompt/communicating.txt": "e14e1daf86eb4fa2fc1222cc941145913d540827bde3d27ebfe0fe30181f3e97",
"packages/opencode/src/session/prompt/default.txt": "bfb0d6ab93fb82a8edad591101bd84839565a0fb401770ceb0045b1fd3b081bc",
"packages/opencode/src/session/prompt/gemini.txt": "d24815ea833b058b304d95d3212ad2e091bc07064a22a6af9401872cdc3871a9",
"packages/opencode/test/cli/run/question.shared.test.ts": "fbb3bdb20fae7d77f0e54444847c658c721b132feca9ad939a963721d2ddd772",
"packages/opencode/test/cli/run/run-process.test.ts": "13e6386bf1919688cff2f6f9f68bcddcfbbec822ef8fc271ee68347a542827b8",
+ "packages/opencode/test/cli/run/stream.transport.test.ts": "3c11172baa0dd4860e1b754c22b8edc3a7ea0b1c5860cc35a88f94ca3d463961",
"packages/opencode/test/server/httpapi-exercise/index.ts": "59048fa30e60348240de62cca862a5c6abbe2e9e0f6340b77b0a9bbd997bec27",
"packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap": "e1b0c536581dd36599aebcb5562ffe49923b09629cc8fe5d64ba0a2d7fe43630",
"packages/session-ui/src/v2/components/attachment-card-v2.css": "3b4b9c23d69e76b35488bc3a65fb51211038c3e8aac015df3f531b4e97e96359",
@@ -788,7 +852,8 @@
"packages/ui/src/v2/components/menu-v2.stories.tsx": "4ed1498d7e4cd51ce9c7459061f3540efdfae71751ff618901c78ee413951559",
"packages/ui/src/v2/components/radio-v2.css": "bf6eb3457a7fc13e48356f5a7dcf3cbf462382580250a3144a745402ff88e2e6",
"packages/ui/src/v2/components/select-v2.css": "4dd4104bee8f661c30ad08ec806e5bddec21d75be6a5d09b693dd297ea10a24c",
- "packages/ui/src/v2/components/switch-v2.css": "96406f51a412f931c99c005ecd6a889898ad80160143cca0a81cacfa13714f8f",
+ "packages/ui/src/v2/components/switch-v2.css": "863b1f4d29e4c25440f695f6cca95438b671e0445a717ae826115a33dd6d3635",
+ "packages/ui/src/v2/components/switch-v2.tsx": "52167c94e7b931ca37a29a5ace612f7d8bf5ae1aa82ebb6cb07c931de05eb62f",
"packages/ui/src/v2/components/toast-v2.css": "9b652d908f39e35cdf1764f1675e4db3dea1d7abf43cb792cfa2d27b699f2723",
"packages/ui/src/v2/components/tooltip-v2.css": "7cbd2523d9f183b7e24bfbc1f523c370abf8e32836f8be90cfdf75e1c70f05a9",
"packages/ui/src/v2/components/wordmark-v2.tsx": "6037a68db862ddd67235cbae8dae5a95c9f7403e7c5f268b78fdaaf013950166",
@@ -801,7 +866,7 @@
"packages/app/src/pages/session/composer/session-bug-dock.stories.tsx": "a5730bb3699751d63629134924ce8dea021b264722c40f5f6bb98236dc15eda7",
"packages/app/src/pages/session/composer/session-bug-dock.tsx": "e7fed3c5ab80527d69a106440833f795bbbafc5deeba3883902ede9fba612605",
"packages/app/src/pages/session/composer/session-composer-controls.ts": "7ba9d56c81b1547acbdd47c4f9371ddc37c9a92059f5785f7286fc68e0cb4483",
- "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "979d2e28d9734581f88e7cb3e0a51c8a8c204f81e644f7a3b5766f60ddf88938",
+ "packages/app/src/pages/session/composer/session-composer-region-controller.ts": "d0403567856eb07ee93957e6d8a23c0f5c78620d94fa09407c01fc6ad3d9eadc",
"packages/app/src/pages/session/composer/session-composer-region.tsx": "415cd5c3a77b625a3142b828b6458687e1a35b382d3a208d90ff1555cc7098b1",
"packages/app/src/pages/session/composer/session-composer-state.ts": "fcffa11d801309cf58f42a174143bf6eb3ea2b14f860ad91a8ffa4e097df5c16",
"packages/app/src/pages/session/composer/session-question-dock.helpers.test.ts": "a1fe31fe4dfdf2914bc184030a0427bcbdfae0f3e7b146e1d6ee29ce31d85b64",
@@ -813,16 +878,19 @@
"packages/app/src/pages/session/composer/session-tour.tsx": "da5d807c7efe7aed1140d65d7eddd7982a8c17348d928f5d0a887d1b2a8d3a50",
"packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "70a8267c725af320d78d266b85fd0ff005d067c4d09a21bef824126602e831e7",
"packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "83753b3ec7227570523c2e0b9e82401fae7a20f3a23980bb40deaeb13304327b",
- "packages/app/src/pages/session/timeline/message-timeline.tsx": "96aa7f175b2b89bad522ac57c0d6e2eb6bc7bedca453f721ddc27fe2c0e7f65d",
+ "packages/app/src/pages/session/timeline/message-timeline-archive.test.ts": "5699d233b5b1a8ba165354113ff40cc9d55b37a80787d163c15165655d591555",
+ "packages/app/src/pages/session/timeline/message-timeline.tsx": "a32ac3a844bd5fb2d7816880df292635399c15e7eea45827ba14e8189eba72a3",
"packages/app/src/pages/session/timeline/model-pure.ts": "50be4ba12d747dd8df9932044ceee29c5f3b6fa6d6ac36791207f91a91067cd4",
"packages/app/src/pages/session/timeline/model.test.ts": "459e0694409ffcf2530252a65491e1006f982b8f5c78a15573ed41646b2c85cf",
- "packages/app/src/pages/session/timeline/model.ts": "1692edf34c1e829900fb3fadcd3f092598164f353e29fe28a9dfb40debcc36bc",
+ "packages/app/src/pages/session/timeline/model.ts": "cec6ba88bbc628e97b7a14bdb5fe8e5007bcc8ef4d38e0ee1df8b109b48afabe",
"packages/app/src/pages/session/timeline/projection.test.ts": "68655f29c382640fc281780166aa528ea26d28d953f3376e1e062be7a0e13749",
- "packages/app/src/pages/session/timeline/projection.ts": "36ab006523de7b695b33d26a0a03b59db9e148b7e979c36f63970cbcb8bcaca2",
+ "packages/app/src/pages/session/timeline/projection.ts": "e76549e34baedee02c8d2fd9357245f80aa4e79dcf2916869f48f0ea1efed980",
"packages/app/src/pages/session/timeline/rows-current.test.ts": "b6a2d9671cfba970990b36d9ef0d1f107efcee59f0a9863f1280526116299095",
- "packages/app/src/pages/session/timeline/rows.ts": "68581190ff5e51d2dc398298c705fbb5dfdefb7eb4cecf98f1da6ae0bd171f18",
+ "packages/app/src/pages/session/timeline/rows.ts": "5063098af62102fb85d5e3ce05626242e82ce12b3efc266b01f2d5199630f873",
+ "packages/app/src/pages/session/timeline/session-header-provenance.test.ts": "4bdf201c16d00eb60957d0ad9b40dafd69410a4b46deb0634e16da26103be1da",
+ "packages/app/src/pages/session/timeline/session-header-provenance.ts": "7b73833aa5a3530c704edef2ca38cb6730aaaa729c23e06b6227788fb5c7e324",
"packages/app/src/pages/session/timeline/smooth-scroll.test.ts": "79d3bba42ad83c3ab80df907e0acf08665a38c4809468e6c12ac9fcf5e94682f",
- "packages/app/src/pages/session/timeline/smooth-scroll.ts": "a7c16860aa68917b74f5c5c46d239e9a5d7a9a952233407e2ed856b534059828",
+ "packages/app/src/pages/session/timeline/smooth-scroll.ts": "3f2782f52e36acf86398fb444bc53fcfec2fec9a0684613466ff7d24b8bceb26",
"packages/app/src/pages/session/timeline/thought-rail-pure.ts": "886c10b0de61b0ab64baf8a284758dab8154f279fd9cd509b7b1d8ecc155ad02",
"packages/app/src/pages/session/timeline/thought-rail.test.ts": "21960365e57d61de047440c386a18a06607bca74554e89881ef53cb7c00c4519",
"packages/app/src/pages/session/timeline/thought-rail.tsx": "d42be90ccb75c2efef3a3a2718763165a10336d08f8d48f8b33ba7dbb696128f",
@@ -838,19 +906,19 @@
"packages/app/src/pages/session/v2/lineage-ledger-view.ts": "6a465a513bf901413c743c83839d0bc8a12bdee780dbdecef86b68460451bc90",
"packages/app/src/pages/session/v2/review-panel-v2-state.test.ts": "ac0cf1939c164948d9a0c54cc15076b30413612d872237e0fbd0912804c1ced0",
"packages/app/src/pages/session/v2/review-panel-v2-state.ts": "e27c3aa236d2862e3cbd87128c4e03e6d810974faaaadb12a7e04cdd3b531e1c",
- "packages/app/src/pages/session/v2/review-panel-v2.tsx": "a3cd899538a738d0236ecbe9e292928ff14e48d3646f2b045dc25880bc7f3ee3",
+ "packages/app/src/pages/session/v2/review-panel-v2.tsx": "d3dedc2d9020d955223142ef47d40da3c508eb79b59aecf2ba27052e51bc0461",
"packages/opencode/src/cli/cmd/run/footer.question.tsx": "c9a1b335c9c7d8446d15c58c8808ea857f8f238c8a4047121f49599296922f39",
"packages/opencode/src/cli/cmd/run/question.shared.ts": "f4b8ab69c18e66d3efea505f3d2c8c5476bba78d46b0f30bc52da4386b19e478",
"packages/opencode/src/server/amicode/widgets-src/jump-back-in.ts": "a82bf11004b71c7ac6e0357c45a0d7532aad4ee6af16e048ae86ef9bf3799fef",
"packages/opencode/test/server/fixtures/credentials/cloud.json": "431bdf114031da38975538384e21ab0ec7206a0859859f0bf87345b8c360829e",
"packages/opencode/test/server/fixtures/credentials/pasqal.json": "d3c775cc498fe3029d9ac234d9eaa9eaffa1ad8656e12a5aca1355c733276736",
"packages/sdk/js/src/v2/gen/sdk.gen.ts": "6610638b4fb71bac498da98d232ef9adcd4b011b63e5e81a63fc6dc20d311b17",
- "packages/sdk/js/src/v2/gen/types.gen.ts": "0c23b262085d1e14b89bd03292a380c9ec8b52e66c4308304a05173362b79339",
+ "packages/sdk/js/src/v2/gen/types.gen.ts": "20dfc17423861bd51c197fe6a2fcb6efbd7ad11853b28e0bb79844115256e729",
"packages/session-ui/src/v2/components/prompt-input/attachments.ts": "ff66078f2edc9a67a4df0d7dfd76a49be5eab27ac332b303f3412721ef7268ea",
"packages/session-ui/src/v2/components/prompt-input/index.tsx": "194b1c6d0c99000579df78b47c7ff72fe34f62a92ecd21b2a9377133d6d27bb9",
- "packages/opencode/src/server/routes/instance/httpapi/server.ts": "85c200797fed41c0cd08e7aa699ce13aeb8a3d23aa2b9eddc4d72e72b8b5082c",
+ "packages/opencode/src/server/routes/instance/httpapi/server.ts": "a94becd6a5224fb5fc2778c1091733eca334b308253234948bcdcafdc709b021",
"packages/opencode/src/server/routes/instance/httpapi/groups/experimental.ts": "944a0aee215a917e867fd9482f3c2bd40f49082bd3174c8a7d967ca40e34ec15",
- "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "ed165131c62eeb29375db39edf89db8f87488e24ef99fc6f2cf19705ba35751a",
+ "packages/opencode/src/server/routes/instance/httpapi/groups/file.ts": "a96fdc55ddb95ca7c481c86f67e37ed88d4cf2a190a482765ab55ce4409f3a59",
"packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts": "048efa7b74d43c4ad4a75d0f8421c808760c8c0dcd593f0051a87044e40487d6",
"packages/opencode/src/server/routes/instance/httpapi/groups/provider.ts": "34c6f1ab1a085d1b047c220bbff32a36032dc5e924784e9272cda166cf0ec84b",
"packages/opencode/src/server/routes/instance/httpapi/groups/session.ts": "1421f8c07948b121c7e2005bd9b0fcd6b2c456565ddd1da32d8e6c35d03fac9c",
@@ -858,7 +926,8 @@
"packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "10bcb79b117beebee04184fb48c1ad789e782e2f7ae4b0c68603edc00f03aee9",
"packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "7da08d58765cdf2b6ce4648fc70de371402949f5bb7b75696f674d43f2d5365b",
"packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "8baf16cad0992c5351b61b0c0fb27903bb25b17e1b25fc7c5d77f807c24413a6",
- "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f"
+ "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "b37ff4136c4aea85b23083bf441f03e09820e3e4cdd95e4118ca570d8435b94f",
+ "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "665c33633173ee89c6da1d4a0eaacaf4eebe3ccdd04eb36b063515cb4c093e4a"
},
"symlinks": [
"packages/app/public/amico.svg"
@@ -873,11 +942,11 @@
"packages/ui/src/v2/components/text-shimmer-v2.tsx"
],
"exceptions": [],
- "extracted_at": "2026-09-24T04:46:38.771Z",
+ "extracted_at": "2026-10-12T14:32:31.142Z",
"per_package": {
"packages/app": {
- "A": 186,
- "M": 157,
+ "A": 237,
+ "M": 158,
"D": 0
},
"packages/core": {
@@ -891,13 +960,13 @@
"D": 0
},
"packages/opencode": {
- "A": 69,
- "M": 81,
+ "A": 75,
+ "M": 89,
"D": 0
},
"packages/schema": {
"A": 6,
- "M": 7,
+ "M": 8,
"D": 0
},
"packages/sdk": {
@@ -917,12 +986,12 @@
},
"packages/ui": {
"A": 152,
- "M": 62,
+ "M": 64,
"D": 0
}
},
"counts": {
- "overlay_total": 851,
+ "overlay_total": 920,
"deletions": 7,
"server_coupled": 0
},
@@ -979,6 +1048,7 @@
"packages/schema/src/provider-permission.ts": "A",
"packages/schema/src/question.ts": "M",
"packages/schema/src/session-receipt.ts": "A",
+ "packages/schema/src/session-status-event.ts": "M",
"packages/schema/src/worktree-event.ts": "M",
"packages/schema/test/provenance-matrix.test.ts": "A",
"packages/schema/test/question.test.ts": "A",
@@ -1029,14 +1099,24 @@
"packages/app/src/components/dialog-release-notes.tsx": "M",
"packages/app/src/components/dialog-select-directory-v2.css": "M",
"packages/app/src/components/dialog-select-file.tsx": "M",
+ "packages/app/src/components/engine-state-signal.ts": "A",
+ "packages/app/src/components/engine-toggle-utils.test.ts": "A",
+ "packages/app/src/components/engine-toggle-utils.ts": "A",
"packages/app/src/components/external-link.tsx": "M",
"packages/app/src/components/help-button.tsx": "M",
+ "packages/app/src/components/new-session-machine-picker.ts": "A",
"packages/app/src/components/pane-bridge.tsx": "A",
"packages/app/src/components/profile-popover.tsx": "A",
"packages/app/src/components/prompt-input-clipboard-structure.test.ts": "A",
"packages/app/src/components/prompt-input-v2.tsx": "M",
"packages/app/src/components/prompt-input.tsx": "M",
"packages/app/src/components/prompt-project-selector.tsx": "M",
+ "packages/app/src/components/remote-create-arm.test.ts": "A",
+ "packages/app/src/components/remote-create-arm.ts": "A",
+ "packages/app/src/components/remote-create-header.test.ts": "A",
+ "packages/app/src/components/remote-create-header.ts": "A",
+ "packages/app/src/components/remote-create-preflight.test.ts": "A",
+ "packages/app/src/components/remote-create-preflight.ts": "A",
"packages/app/src/components/report-bug-button.css": "A",
"packages/app/src/components/report-bug-button.stories.tsx": "A",
"packages/app/src/components/report-bug-button.tsx": "A",
@@ -1056,6 +1136,8 @@
"packages/app/src/components/titlebar-channel.ts": "A",
"packages/app/src/components/titlebar-layout.test.ts": "A",
"packages/app/src/components/titlebar-layout.ts": "A",
+ "packages/app/src/components/titlebar-portal-dedup.test.ts": "A",
+ "packages/app/src/components/titlebar-portal-registry.ts": "A",
"packages/app/src/components/titlebar-tab-nav.css": "M",
"packages/app/src/components/titlebar-tab-nav.tsx": "M",
"packages/app/src/components/titlebar-tab-popover.css": "M",
@@ -1071,6 +1153,12 @@
"packages/app/src/context/command.tsx": "M",
"packages/app/src/context/directory-sync.test.ts": "A",
"packages/app/src/context/directory-sync.ts": "M",
+ "packages/app/src/context/fleet-reseed-scheduler.test.ts": "A",
+ "packages/app/src/context/fleet-reseed-scheduler.ts": "A",
+ "packages/app/src/context/fleet-session-status.e2e-fixture.ts": "A",
+ "packages/app/src/context/fleet-session-status.e2e.test.ts": "A",
+ "packages/app/src/context/fleet-session-status.test.ts": "A",
+ "packages/app/src/context/fleet-session-status.ts": "A",
"packages/app/src/context/language.tsx": "M",
"packages/app/src/context/layout-side-panel-tabs.test.ts": "A",
"packages/app/src/context/layout-side-panel-tabs.ts": "A",
@@ -1084,18 +1172,30 @@
"packages/app/src/context/platform.tsx": "M",
"packages/app/src/context/server-sdk.test.ts": "M",
"packages/app/src/context/server-sdk.tsx": "M",
+ "packages/app/src/context/server-session-child-active.test.ts": "A",
+ "packages/app/src/context/server-session-orphan-part.test.ts": "A",
"packages/app/src/context/server-session.test.ts": "M",
"packages/app/src/context/server-session.ts": "M",
"packages/app/src/context/server-sync.tsx": "M",
"packages/app/src/context/server.tsx": "M",
"packages/app/src/context/session-mirror.test.ts": "A",
"packages/app/src/context/session-mirror.ts": "A",
+ "packages/app/src/context/session-status-reconcile.test.ts": "A",
+ "packages/app/src/context/session-status-reconcile.ts": "A",
"packages/app/src/context/session-warm.test.ts": "A",
"packages/app/src/context/session-warm.ts": "A",
+ "packages/app/src/context/session-working.test.ts": "A",
+ "packages/app/src/context/session-working.ts": "A",
"packages/app/src/context/settings.tsx": "M",
"packages/app/src/context/split.tsx": "A",
+ "packages/app/src/context/sse-gap-frame.test.ts": "A",
+ "packages/app/src/context/sse-gap-frame.ts": "A",
+ "packages/app/src/context/sse-origin.test.ts": "A",
+ "packages/app/src/context/sse-origin.ts": "A",
"packages/app/src/context/stream-gap.test.ts": "A",
"packages/app/src/context/stream-gap.ts": "A",
+ "packages/app/src/context/stream-liveness.test.ts": "A",
+ "packages/app/src/context/stream-liveness.ts": "A",
"packages/app/src/context/tab-migration.ts": "M",
"packages/app/src/context/tabs.tsx": "M",
"packages/app/src/context/vault-panel.ts": "A",
@@ -1124,11 +1224,13 @@
"packages/app/src/pages/empty-workspace-landing.tsx": "A",
"packages/app/src/pages/error.tsx": "M",
"packages/app/src/pages/hold-debug-badge-gate.test.ts": "A",
+ "packages/app/src/pages/home-archive.test.ts": "A",
"packages/app/src/pages/home-projects.test.ts": "A",
"packages/app/src/pages/home-projects.ts": "A",
"packages/app/src/pages/home-session-archive.test.ts": "M",
"packages/app/src/pages/home-session-archive.ts": "M",
"packages/app/src/pages/home.tsx": "M",
+ "packages/app/src/pages/layout-archive.test.ts": "A",
"packages/app/src/pages/layout-new.tsx": "M",
"packages/app/src/pages/layout.tsx": "M",
"packages/app/src/pages/new-session-landing.test.ts": "A",
@@ -1140,6 +1242,7 @@
"packages/app/src/utils/amicode-bug-report.test.ts": "A",
"packages/app/src/utils/amicode-bug-report.ts": "A",
"packages/app/src/utils/amicode-developer.ts": "A",
+ "packages/app/src/utils/amicode-fetch.test.ts": "A",
"packages/app/src/utils/amicode-fetch.ts": "A",
"packages/app/src/utils/amicode-hidden-project.test.ts": "A",
"packages/app/src/utils/amicode-hidden-project.ts": "A",
@@ -1201,6 +1304,7 @@
"packages/llm/src/protocols/bedrock-converse.ts": "M",
"packages/llm/test/provider/bedrock-converse.test.ts": "M",
"packages/opencode/src/agent/agent.ts": "M",
+ "packages/opencode/src/effect/runner.ts": "M",
"packages/opencode/src/mcp/browser.ts": "M",
"packages/opencode/src/mcp/index.ts": "M",
"packages/opencode/src/project/instance-context.ts": "M",
@@ -1209,6 +1313,7 @@
"packages/opencode/src/provider/error.ts": "M",
"packages/opencode/src/provider/provider.ts": "M",
"packages/opencode/src/provider/transform.ts": "M",
+ "packages/opencode/src/server/auth.ts": "M",
"packages/opencode/src/server/server.ts": "M",
"packages/opencode/src/session/business-record.ts": "A",
"packages/opencode/src/session/compaction.ts": "M",
@@ -1224,7 +1329,9 @@
"packages/opencode/src/session/receipt.ts": "A",
"packages/opencode/src/session/reminders.ts": "M",
"packages/opencode/src/session/rollout.ts": "A",
+ "packages/opencode/src/session/run-state.ts": "M",
"packages/opencode/src/session/session.ts": "M",
+ "packages/opencode/src/session/status.ts": "M",
"packages/opencode/src/session/summary.ts": "M",
"packages/opencode/src/session/system.ts": "M",
"packages/opencode/src/session/turn-output.ts": "A",
@@ -1240,9 +1347,13 @@
"packages/opencode/src/tool/widget-author.txt": "A",
"packages/opencode/src/tool/write.ts": "M",
"packages/opencode/src/worktree/index.ts": "M",
+ "packages/opencode/test/acp/service-session.test.ts": "M",
+ "packages/opencode/test/effect/runner-queue.test.ts": "A",
+ "packages/opencode/test/effect/runner.test.ts": "M",
"packages/opencode/test/fixture/fixture.ts": "M",
"packages/opencode/test/lib/cli-process.ts": "M",
"packages/opencode/test/project/worktree-rename.test.ts": "A",
+ "packages/opencode/test/provider/combine-abort-signals.test.ts": "A",
"packages/opencode/test/provider/harmoniqs-tools.test.ts": "A",
"packages/opencode/test/provider/provider.test.ts": "M",
"packages/opencode/test/provider/transform.test.ts": "M",
@@ -1263,7 +1374,9 @@
"packages/opencode/test/server/amicode-widget-manifest.test.ts": "A",
"packages/opencode/test/server/amicode-widgets-src.test.ts": "A",
"packages/opencode/test/server/amicode-widgets.test.ts": "A",
+ "packages/opencode/test/server/auth-accept-set.test.ts": "A",
"packages/opencode/test/server/httpapi-compression.test.ts": "M",
+ "packages/opencode/test/server/httpapi-file-write-gate.test.ts": "A",
"packages/opencode/test/server/httpapi-file.test.ts": "M",
"packages/opencode/test/server/httpapi-instance-route-auth.test.ts": "M",
"packages/opencode/test/server/httpapi-provider.test.ts": "M",
@@ -1280,6 +1393,7 @@
"packages/opencode/test/session/llm-native-recorded.test.ts": "M",
"packages/opencode/test/session/message-v2.test.ts": "M",
"packages/opencode/test/session/mutation.test.ts": "A",
+ "packages/opencode/test/session/processor-effect.test.ts": "M",
"packages/opencode/test/session/prompt.test.ts": "M",
"packages/opencode/test/session/prose-guard.test.ts": "A",
"packages/opencode/test/session/receipt-privacy.test.ts": "A",
@@ -1505,6 +1619,7 @@
"packages/ui/src/context/dialog.tsx": "M",
"packages/ui/src/context/marked-math.test.ts": "A",
"packages/ui/src/context/marked-parser.tsx": "M",
+ "packages/ui/src/hooks/create-auto-scroll.tsx": "M",
"packages/ui/src/i18n/ar.ts": "M",
"packages/ui/src/i18n/br.ts": "M",
"packages/ui/src/i18n/bs.ts": "M",
@@ -1551,8 +1666,14 @@
"packages/app/src/components/session/preview-file-helpers.test.ts": "A",
"packages/app/src/components/session/preview-file-helpers.ts": "A",
"packages/app/src/components/session/preview-file-view.tsx": "A",
+ "packages/app/src/components/session/preview-human-write-gate.ts": "A",
+ "packages/app/src/components/session/session-archived-fleet.test.ts": "A",
"packages/app/src/components/session/session-chats-dropdown.test.ts": "A",
"packages/app/src/components/session/session-context-tab.tsx": "M",
+ "packages/app/src/components/session/session-fleet-control-projection.ts": "A",
+ "packages/app/src/components/session/session-fleet-peers.test.ts": "A",
+ "packages/app/src/components/session/session-fleet-peers.ts": "A",
+ "packages/app/src/components/session/session-header.test.tsx": "A",
"packages/app/src/components/session/session-header.tsx": "M",
"packages/app/src/components/session/session-new-view.tsx": "M",
"packages/app/src/components/session/session-preview-tab.tsx": "A",
@@ -1583,7 +1704,7 @@
"packages/app/src/components/settings-v2/skills.tsx": "A",
"packages/app/src/components/ui/drawer.tsx": "M",
"packages/app/src/context/global-sync/bootstrap.test.ts": "A",
- "packages/app/src/context/global-sync/child-store.ts": "A",
+ "packages/app/src/context/global-sync/child-store.ts": "M",
"packages/app/src/context/global-sync/event-reducer.test.ts": "M",
"packages/app/src/context/global-sync/event-reducer.ts": "M",
"packages/app/src/context/global-sync/session-cache.test.ts": "A",
@@ -1604,11 +1725,21 @@
"packages/app/src/pages/layout/worktree-auto-rename.test.ts": "A",
"packages/app/src/pages/layout/worktree-auto-rename.ts": "A",
"packages/app/src/pages/new-session/new-session-draft-controller.ts": "M",
+ "packages/app/src/pages/new-session/new-session-machine-mount.test.ts": "A",
+ "packages/app/src/pages/new-session/new-session-machine-mount.ts": "A",
+ "packages/app/src/pages/new-session/new-session-machine-picker-mount.tsx": "A",
+ "packages/app/src/pages/new-session/new-session-machine-picker.test.ts": "A",
+ "packages/app/src/pages/new-session/new-session-machine-selection.ts": "A",
"packages/app/src/pages/new-session/new-session-view.test.ts": "A",
"packages/app/src/pages/new-session/new-session-view.tsx": "M",
"packages/app/src/pages/session/amicode-session-relay.integration.test.ts": "A",
+ "packages/app/src/pages/session/fleet-manager-tab.test.tsx": "A",
+ "packages/app/src/pages/session/fleet-manager-tab.tsx": "A",
+ "packages/app/src/pages/session/fleet-manager.test.ts": "A",
+ "packages/app/src/pages/session/fleet-manager.ts": "A",
"packages/app/src/pages/session/helpers.test.ts": "M",
"packages/app/src/pages/session/helpers.ts": "M",
+ "packages/app/src/pages/session/preview-human-write-gate.test.ts": "A",
"packages/app/src/pages/session/review-tab.tsx": "M",
"packages/app/src/pages/session/session-panel-width.test.ts": "M",
"packages/app/src/pages/session/session-panel-width.ts": "M",
@@ -1642,6 +1773,7 @@
"packages/opencode/src/server/amicode/profile.ts": "A",
"packages/opencode/src/server/amicode/project.ts": "A",
"packages/opencode/src/server/amicode/run-terminal.ts": "A",
+ "packages/opencode/src/server/amicode/self-shutdown.ts": "A",
"packages/opencode/src/server/amicode/toml-lite.ts": "A",
"packages/opencode/src/server/amicode/vault-browser.ts": "A",
"packages/opencode/src/server/amicode/vaults.ts": "A",
@@ -1660,6 +1792,7 @@
"packages/opencode/src/session/prompt/gemini.txt": "M",
"packages/opencode/test/cli/run/question.shared.test.ts": "M",
"packages/opencode/test/cli/run/run-process.test.ts": "M",
+ "packages/opencode/test/cli/run/stream.transport.test.ts": "M",
"packages/opencode/test/server/httpapi-exercise/index.ts": "M",
"packages/opencode/test/tool/__snapshots__/parameters.test.ts.snap": "M",
"packages/session-ui/src/v2/components/attachment-card-v2.css": "M",
@@ -1708,6 +1841,7 @@
"packages/ui/src/v2/components/radio-v2.css": "M",
"packages/ui/src/v2/components/select-v2.css": "M",
"packages/ui/src/v2/components/switch-v2.css": "M",
+ "packages/ui/src/v2/components/switch-v2.tsx": "M",
"packages/ui/src/v2/components/toast-v2.css": "M",
"packages/ui/src/v2/components/tooltip-v2.css": "M",
"packages/ui/src/v2/components/wordmark-v2.tsx": "M",
@@ -1732,6 +1866,7 @@
"packages/app/src/pages/session/composer/session-tour.tsx": "A",
"packages/app/src/pages/session/composer/todo-panel-motion.stories.tsx": "M",
"packages/app/src/pages/session/timeline/last-prompt-bubble.test.ts": "A",
+ "packages/app/src/pages/session/timeline/message-timeline-archive.test.ts": "A",
"packages/app/src/pages/session/timeline/message-timeline.tsx": "M",
"packages/app/src/pages/session/timeline/model-pure.ts": "A",
"packages/app/src/pages/session/timeline/model.test.ts": "M",
@@ -1740,6 +1875,8 @@
"packages/app/src/pages/session/timeline/projection.ts": "M",
"packages/app/src/pages/session/timeline/rows-current.test.ts": "M",
"packages/app/src/pages/session/timeline/rows.ts": "M",
+ "packages/app/src/pages/session/timeline/session-header-provenance.test.ts": "A",
+ "packages/app/src/pages/session/timeline/session-header-provenance.ts": "A",
"packages/app/src/pages/session/timeline/smooth-scroll.test.ts": "A",
"packages/app/src/pages/session/timeline/smooth-scroll.ts": "A",
"packages/app/src/pages/session/timeline/thought-rail-pure.ts": "A",
@@ -1777,7 +1914,8 @@
"packages/opencode/src/server/routes/instance/httpapi/handlers/file.ts": "M",
"packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts": "M",
"packages/opencode/src/server/routes/instance/httpapi/handlers/provider.ts": "M",
- "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M"
+ "packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts": "M",
+ "packages/opencode/src/server/routes/instance/httpapi/middleware/file-write-workspace.ts": "A"
},
"upstream_base_archive_sha256": "4b5f2e5edfc796082a353fd09c1b1dec3e7a108a4b080fe3a86b12d9fa59fae6"
}
diff --git a/packages/app-bundle/overlay/packages/app/CONTEXT.md b/packages/app-bundle/overlay/packages/app/CONTEXT.md
index 53a5f5e8f..bd0ba7993 100644
--- a/packages/app-bundle/overlay/packages/app/CONTEXT.md
+++ b/packages/app-bundle/overlay/packages/app/CONTEXT.md
@@ -69,6 +69,20 @@ Chat-only. Selecting an item inside a Panel acts in the Chat rather than navigat
away from it.
_Avoid_: Modal, page, route, flyout
+**Work Column**:
+The session's supporting column beside the **Chat** — a strip of tabs over the work a
+session produces: the files it changed, the file tabs a researcher opens, and named
+surfaces (Context, Pulse Inspector, Preview). Most tabs are scoped to the session's own
+content. **Home** and **Fleet Manager** are its two **global exceptions** — window-wide
+concerns surfaced as Work Column tabs rather than session content: Home is the
+resume/overview surface, and Fleet Manager consolidates fleet management (the device
+roster and capability chips, this machine's Server mode / transport / Go-Standalone /
+Repair, the durable hub service on a server, and fleet versions — absorbing the retired
+Fleet & Versions panel). Every tab closes and reorders alike; the two exceptions aside, a
+tab tracks something the session itself did.
+_Avoid_: side panel (as the whole), review pane (that is one tab), Fleet & Versions panel
+(retired — absorbed into the Fleet Manager tab)
+
**Account zone**:
The cluster at the foot of the **Rail** for identity and configuration, distinct from
the navigation surfaces above it. Holds the researcher profile (About You), backend
diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts
new file mode 100644
index 000000000..866b35137
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/engine-state-signal.ts
@@ -0,0 +1,119 @@
+import { createSignal } from "solid-js"
+import {
+ parseEngineStateMessage,
+ parseFleetRoleMessage,
+ latchedEngineStateAt,
+ type EngineState,
+ type FleetRole,
+} from "./engine-toggle-utils"
+
+// engine-state-signal.ts — #1608: the GLOBAL engine lifecycle signal.
+//
+// #1598 kept the engine-state signal LOCAL to the status popover, so a push
+// that arrived while the popover was closed was lost, and nothing outside the
+// popover could read it. This promotes it to a module-level always-mounted
+// signal — installed ONCE (see installEngineStateListener) so a single window
+// listener owns the channel for the whole app lifetime.
+//
+// It also carries the LOCAL "stop-requested" latch: the click flips the UI to
+// `stopping` instantly, before any extension round-trip. A delivered push then
+// reconciles it. CRITICALLY (BUG1 fix), the latch is SELF-EXPIRING: if the
+// confirming `off` push is dropped (engine dead → SSE gone → the push never
+// arrives), a timer resolves the latch to the intended terminal `off` state so
+// the toggle is NEVER permanently grayed — the user can always click to restart.
+
+// How long `stopping` may show before the latch self-resolves to `off`. The
+// deliberate kill is SIGTERM→wait→SIGKILL-bounded at ~3s (server_manager.ts),
+// so 4s covers the happy path and still recovers a dropped confirm quickly.
+export const STOP_LATCH_TIMEOUT_MS = 4000
+
+// Default "on": the app can only render when the engine is already serving it,
+// so the resting initial state is on; booting/off/stopping arrive via push.
+const [engineState, setEngineStateRaw] = createSignal("on")
+// Fleet role default standalone — a window with no fleet topology behaves as a
+// normal standalone engine host.
+const [fleetRole, setFleetRole] = createSignal("standalone")
+// The local latch: the wall-clock time of the stop click, or undefined when
+// there is no outstanding optimistic stop. A tick signal advances the derived
+// state so the timeout is observed reactively — effectiveEngineState() MUST
+// read `tick` (below) or the self-expiry setTimeout fires into the void: the
+// popover would only re-derive on reopen (BUG1 follow-up — live update lost).
+const [latchedAt, setLatchedAt] = createSignal(undefined)
+const [tick, setTick] = createSignal(0)
+let latchTimer: ReturnType | undefined
+const now = () => Date.now()
+
+export { engineState, fleetRole }
+
+/** The state the UI should render: the latch reads `stopping` inside the
+ * confirm window, then resolves to `off` (BUG1); a delivered push wins.
+ *
+ * Reads `tick()` for its reactive side effect ONLY: `now()` is a plain
+ * non-reactive Date.now(), so nothing here would re-run when the self-expiry
+ * timer elapses. The timer bumps `tick` (latchStopRequested); reading it makes
+ * this a tracked dependency so the toggle re-derives to `off` live at the 4s
+ * boundary WITHOUT the user reopening the popover. */
+export function effectiveEngineState(): EngineState {
+ tick() // reactive dependency — see the timer in latchStopRequested()
+ return latchedEngineStateAt(engineState(), latchedAt(), now(), STOP_LATCH_TIMEOUT_MS)
+}
+
+function clearLatchTimer() {
+ if (latchTimer !== undefined) {
+ clearTimeout(latchTimer)
+ latchTimer = undefined
+ }
+}
+
+/** Flip the toggle to `stopping` locally the instant the user clicks. Records
+ * the click time and arms the self-expiry timer: if no confirming push lands
+ * within STOP_LATCH_TIMEOUT_MS, the derived state falls back to `off` so the
+ * toggle is never stuck grayed. */
+export function latchStopRequested() {
+ setLatchedAt(now())
+ clearLatchTimer()
+ latchTimer = setTimeout(() => {
+ // Tick to re-derive effectiveEngineState() → past the window it reads `off`.
+ // The latch is intentionally NOT cleared: `off` is the intended terminal
+ // state, and a later real push (e.g. `on` from a restart) still wins.
+ setTick((t) => t + 1)
+ latchTimer = undefined
+ }, STOP_LATCH_TIMEOUT_MS)
+}
+
+/** Apply a delivered engine-state push. Clears the local latch + its timer —
+ * the real state now owns the rendering, so the optimistic latch steps aside. */
+export function applyEngineState(state: EngineState) {
+ setLatchedAt(undefined)
+ clearLatchTimer()
+ setEngineStateRaw(state)
+}
+
+/** Install the ONE window listener that feeds the global signals. Idempotent —
+ * a second install is a no-op — so multiple call sites (the always-mounted
+ * host + the popover) are safe. Returns a disposer for symmetry/testing. */
+let installed = false
+export function installEngineStateListener(win: Window = window): () => void {
+ if (installed) return () => {}
+ installed = true
+ const onMsg = (e: MessageEvent) => {
+ const state = parseEngineStateMessage(e.data)
+ if (state !== undefined) applyEngineState(state)
+ const role = parseFleetRoleMessage(e.data)
+ if (role !== undefined) setFleetRole(role)
+ }
+ win.addEventListener("message", onMsg)
+ return () => {
+ win.removeEventListener("message", onMsg)
+ installed = false
+ }
+}
+
+/** TEST-ONLY: reset the module signals + install guard between cases. */
+export function __resetEngineStateForTest() {
+ setEngineStateRaw("on")
+ setFleetRole("standalone")
+ setLatchedAt(undefined)
+ clearLatchTimer()
+ installed = false
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts
new file mode 100644
index 000000000..ae770f892
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.test.ts
@@ -0,0 +1,201 @@
+// engine-toggle-utils.test.ts — #1598: unit tests for the engine toggle logic.
+import { describe, it, expect, vi, beforeEach, afterEach } from "bun:test"
+import { readFileSync } from "node:fs"
+import { fileURLToPath } from "node:url"
+import {
+ parseEngineStateMessage,
+ parseFleetRoleMessage,
+ sendEngineCommand,
+ engineDotClass,
+ latchedEngineStateAt,
+} from "./engine-toggle-utils"
+
+describe("parseEngineStateMessage", () => {
+ it("parses a valid engine-state message for each state", () => {
+ expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "on" })).toBe("on")
+ expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "booting" })).toBe("booting")
+ expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "off" })).toBe("off")
+ })
+
+ it("returns undefined for non-amicode messages", () => {
+ expect(parseEngineStateMessage({ source: "vscode", kind: "engine-state", state: "on" })).toBeUndefined()
+ })
+
+ it("returns undefined for different amicode kinds", () => {
+ expect(parseEngineStateMessage({ source: "amicode", kind: "theme", state: "on" })).toBeUndefined()
+ })
+
+ it("returns undefined for invalid state values", () => {
+ expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "starting" })).toBeUndefined()
+ })
+
+ // #1608 AC9: the parser accepts the new `stopping` lifecycle state so the
+ // extension can narrate a deliberate off (locked/grayed) before the kill.
+ it("parses the `stopping` state (#1608)", () => {
+ expect(parseEngineStateMessage({ source: "amicode", kind: "engine-state", state: "stopping" })).toBe("stopping")
+ })
+
+ it("returns undefined for non-object input", () => {
+ expect(parseEngineStateMessage(null)).toBeUndefined()
+ expect(parseEngineStateMessage("string")).toBeUndefined()
+ expect(parseEngineStateMessage(42)).toBeUndefined()
+ })
+})
+
+describe("parseFleetRoleMessage", () => {
+ it("parses valid fleet-role messages", () => {
+ expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "standalone" })).toBe("standalone")
+ expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "server" })).toBe("server")
+ expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "client" })).toBe("client")
+ })
+
+ it("returns undefined for non-amicode messages", () => {
+ expect(parseFleetRoleMessage({ source: "vscode", kind: "fleet-role", role: "client" })).toBeUndefined()
+ })
+
+ it("returns undefined for invalid roles", () => {
+ expect(parseFleetRoleMessage({ source: "amicode", kind: "fleet-role", role: "unknown" })).toBeUndefined()
+ })
+})
+
+describe("sendEngineCommand", () => {
+ let posted: unknown[]
+ const originalParent = globalThis.window
+
+ beforeEach(() => {
+ posted = []
+ // Mock window.parent.postMessage
+ if (typeof globalThis.window === "undefined") {
+ ;(globalThis as any).window = {}
+ }
+ ;(globalThis.window as any).parent = {
+ postMessage: (msg: unknown, _target: string) => {
+ posted.push(msg)
+ },
+ }
+ })
+
+ afterEach(() => {
+ // Restore
+ if (originalParent === undefined) {
+ delete (globalThis as any).window
+ }
+ })
+
+ it("sends amicode.stopServer when engine is on", () => {
+ sendEngineCommand("on")
+ expect(posted).toHaveLength(1)
+ expect(posted[0]).toEqual({
+ source: "amicode",
+ kind: "command",
+ command: "amicode.stopServer",
+ })
+ })
+
+ it("sends amicode.restartServer when engine is off", () => {
+ sendEngineCommand("off")
+ expect(posted).toHaveLength(1)
+ expect(posted[0]).toEqual({
+ source: "amicode",
+ kind: "command",
+ command: "amicode.restartServer",
+ })
+ })
+
+ it("does nothing when engine is booting (locked)", () => {
+ sendEngineCommand("booting")
+ expect(posted).toHaveLength(0)
+ })
+
+ // #1608 AC9: `stopping` is a locked transitional state — no command fires.
+ it("does nothing when engine is stopping (locked) (#1608)", () => {
+ sendEngineCommand("stopping")
+ expect(posted).toHaveLength(0)
+ })
+})
+
+// #1608 AC4: the engine dot renders from an EXHAUSTIVE switch. `stopping` and
+// `booting` both read warning (grayed/locked); a future state that isn't
+// handled is a compile error (assertNever), not a silent grey dot.
+describe("engineDotClass (#1608)", () => {
+ it("maps each lifecycle state to its dot token", () => {
+ expect(engineDotClass("on")).toBe("bg-icon-success-base")
+ expect(engineDotClass("booting")).toBe("bg-icon-warning-base")
+ expect(engineDotClass("stopping")).toBe("bg-icon-warning-base")
+ expect(engineDotClass("off")).toBe("bg-border-weak-base")
+ })
+})
+
+// #1608 follow-up (BUG1): the latch must be SELF-LIMITING. The stuck-stopping
+// bug was `stopping` staying locked forever when the confirming `off` push was
+// dropped (engine dead → SSE gone → push never delivered). The latch resolves
+// to the INTENDED terminal state `off` after a timeout, so the toggle is never
+// permanently grayed — a user can always click to restart.
+describe("latchedEngineStateAt — self-expiring latch (#1608 BUG1)", () => {
+ const TIMEOUT = 4000
+ it("reads `stopping` inside the confirm window (instant feedback preserved)", () => {
+ // latched at t=0, now=1s, still within the 4s window
+ expect(latchedEngineStateAt("on", 0, 1000, TIMEOUT)).toBe("stopping")
+ })
+
+ it("falls back to `off` once the window elapses with no confirming push", () => {
+ // latched at t=0, now=5s > 4s window → resolve to the intended terminal off
+ expect(latchedEngineStateAt("on", 0, 5000, TIMEOUT)).toBe("off")
+ })
+
+ it("resolves to `off` exactly at the boundary (>= timeout)", () => {
+ expect(latchedEngineStateAt("on", 0, 4000, TIMEOUT)).toBe("off")
+ })
+
+ it("passes raw through when there is no latch (latchedAt undefined)", () => {
+ expect(latchedEngineStateAt("on", undefined, 9999, TIMEOUT)).toBe("on")
+ expect(latchedEngineStateAt("off", undefined, 9999, TIMEOUT)).toBe("off")
+ })
+
+ it("lets a delivered push win over the latch, even inside the window", () => {
+ // a real off/booting push landed → raw changed → it wins immediately
+ expect(latchedEngineStateAt("off", 0, 500, TIMEOUT)).toBe("off")
+ expect(latchedEngineStateAt("booting", 0, 500, TIMEOUT)).toBe("booting")
+ })
+})
+
+// #1608 follow-up (BUG1b — live update lost): the pure helper above was correct,
+// but the toggle only settled to `off` when the popover was CLOSED and REOPENED.
+// Root cause: effectiveEngineState() reads `now()` (plain Date.now(), NOT
+// reactive), so the self-expiry setTimeout's setTick() invalidated nothing —
+// the derived state never re-ran at the 4s boundary. The fix wires a `tick`
+// signal that effectiveEngineState() READS, so the timer's setTick re-derives
+// it live. solid-js is not resolvable in this package's node_modules (engine
+// source tree, built by the bun bundler, not pnpm install — see the session
+// ledger's honest-degradation note), so a createRoot/createEffect reactivity
+// harness cannot run here; the binary build is the authoritative compile gate.
+// We guard the exact regression with a source assertion: effectiveEngineState()
+// MUST read the tick, or the setTimeout fires into the void again.
+describe("engine-state-signal reactivity wiring (#1608 BUG1b)", () => {
+ const signalSrc = readFileSync(
+ fileURLToPath(new URL("./engine-state-signal.ts", import.meta.url)),
+ "utf8",
+ )
+
+ it("names the tick reader (does not discard the setter alone)", () => {
+ // the bug was `const [, setTick] = createSignal(0)` — a write with no reader
+ expect(signalSrc).toContain("const [tick, setTick] = createSignal(0)")
+ })
+
+ it("effectiveEngineState reads tick() so the self-expiry timer re-derives", () => {
+ const body = signalSrc.slice(
+ signalSrc.indexOf("export function effectiveEngineState"),
+ signalSrc.indexOf("function clearLatchTimer"),
+ )
+ expect(body).toContain("tick()")
+ expect(body).toContain("latchedEngineStateAt(")
+ })
+
+ it("the self-expiry timer bumps tick to invalidate the derived state", () => {
+ const body = signalSrc.slice(
+ signalSrc.indexOf("export function latchStopRequested"),
+ signalSrc.indexOf("export function applyEngineState"),
+ )
+ expect(body).toContain("setTick(")
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts
new file mode 100644
index 000000000..253020359
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/engine-toggle-utils.ts
@@ -0,0 +1,101 @@
+// engine-toggle.ts — #1598: pure logic for the engine on/off toggle.
+//
+// Message-driven state machine: the extension pushes engine-state and
+// fleet-role messages; this module parses them into typed state. The
+// component (engine-toggle.tsx) renders from this state.
+
+/** Engine lifecycle state as pushed by the extension.
+ * #1608: `stopping` narrates a deliberate off — the toggle grays/locks between
+ * the click and the resting `off` state. It stays in sync with the extension's
+ * EngineState (engine_state_push.ts) — the two definitions are the only two. */
+export type EngineState = "on" | "booting" | "off" | "stopping"
+
+/** Fleet role as pushed by the extension. */
+export type FleetRole = "standalone" | "server" | "client"
+
+/** Parse an engine-state message from the extension bridge. */
+export function parseEngineStateMessage(
+ data: unknown,
+): EngineState | undefined {
+ if (!data || typeof data !== "object") return undefined
+ const d = data as { source?: string; kind?: string; state?: string }
+ if (d.source !== "amicode" || d.kind !== "engine-state") return undefined
+ if (d.state === "on" || d.state === "booting" || d.state === "off" || d.state === "stopping") return d.state
+ return undefined
+}
+
+/** Parse a fleet-role message from the extension bridge. */
+export function parseFleetRoleMessage(
+ data: unknown,
+): FleetRole | undefined {
+ if (!data || typeof data !== "object") return undefined
+ const d = data as { source?: string; kind?: string; role?: string }
+ if (d.source !== "amicode" || d.kind !== "fleet-role") return undefined
+ if (d.role === "standalone" || d.role === "server" || d.role === "client") return d.role
+ return undefined
+}
+
+/** Send a bridge command to the extension (stop or restart the engine). */
+export function sendEngineCommand(state: EngineState): void {
+ if (state === "on") {
+ // Engine is on → stop it
+ window.parent?.postMessage(
+ { source: "amicode", kind: "command", command: "amicode.stopServer" },
+ "*",
+ )
+ } else if (state === "off") {
+ // Engine is off → restart it
+ window.parent?.postMessage(
+ { source: "amicode", kind: "command", command: "amicode.restartServer" },
+ "*",
+ )
+ }
+ // booting / stopping → locked transitional states, no action
+}
+
+/** Exhaustiveness helper — a future EngineState addition that isn't handled in
+ * a switch becomes a COMPILE error here (#1608 AC9), never a silent fallthrough. */
+export function assertNever(x: never): never {
+ throw new Error(`unhandled engine state: ${String(x)}`)
+}
+
+/** The engine dot's CSS token for a lifecycle state — the exhaustive switch the
+ * toggle renders from (#1608 AC4). `stopping` reads warning (grayed/locked),
+ * same family as `booting`. */
+export function engineDotClass(state: EngineState): string {
+ switch (state) {
+ case "on":
+ return "bg-icon-success-base"
+ case "booting":
+ case "stopping":
+ return "bg-icon-warning-base"
+ case "off":
+ return "bg-border-weak-base"
+ default:
+ return assertNever(state)
+ }
+}
+
+/** The calm off/stopping/booting narration was removed (#1608 follow-up): the
+ * toggle's own dot/lock is the whole story, per the user. See engineDotClass. */
+
+/** Self-expiring latch resolution (#1608 BUG1). The stuck-stopping bug was the
+ * latch reading `stopping` forever when the confirming `off` push was dropped
+ * (engine dead → SSE gone → the push never arrives). This resolves the latch
+ * to its INTENDED terminal state `off` once `timeoutMs` elapses, so the toggle
+ * is never permanently grayed:
+ * - no latch (`latchedAt` undefined) → raw passes through;
+ * - raw already changed (a push landed) → raw wins immediately;
+ * - latch set, raw still `on`, inside the window → `stopping`;
+ * - latch set, raw still `on`, window elapsed → `off` (clickable to restart).
+ * Pure so it is testable without the solid signal graph or real timers. */
+export function latchedEngineStateAt(
+ raw: EngineState,
+ latchedAt: number | undefined,
+ now: number,
+ timeoutMs: number,
+): EngineState {
+ if (latchedAt === undefined) return raw
+ if (raw !== "on") return raw // a real push already moved us off `on`
+ return now - latchedAt >= timeoutMs ? "off" : "stopping"
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts b/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts
new file mode 100644
index 000000000..8dac5eae1
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/new-session-machine-picker.ts
@@ -0,0 +1,92 @@
+/**
+ * new-session-machine-picker.ts — #1442 AC4
+ *
+ * Pure-logic module for the new-session machine picker cascade:
+ * Machine ▸ Project ▸ Worktree
+ *
+ * Machine is FIRST in the cascade. The default selected machine comes from the
+ * focused machine in fleet_focus.ts. When no machine is focused, the local
+ * machine is the default. The create call carries the chosen machine_id.
+ *
+ * This module is the data/state layer; the TSX renderer consumes it.
+ */
+
+// ── types ────────────────────────────────────────────────────────────────────
+
+/** A machine available for selection in the new-session picker. */
+export interface MachineOption {
+ machineId: string
+ name: string
+ isLocal: boolean
+}
+
+/** The cascade order for the new-session selectors. */
+export type CascadeStep = "machine" | "project" | "worktree"
+
+/** Options for creating the machine picker state. */
+export interface MachinePickerOptions {
+ /** Available machines (from the fleet roster). */
+ machines: MachineOption[]
+ /** The currently focused machine id (from fleet_focus.ts), or undefined for
+ * home (local). */
+ focusedMachineId: string | undefined
+}
+
+/** The create-session payload with the chosen machine. */
+export interface CreateSessionPayload {
+ machine_id: string
+}
+
+/** The machine picker state — manages selection and produces the create payload. */
+export interface MachinePickerState {
+ /** The cascade order: machine first, then project, then worktree. */
+ readonly cascadeOrder: readonly CascadeStep[]
+ /** The currently selected machine id. */
+ readonly selectedMachineId: string
+ /** The available machines for the selector. */
+ readonly machines: readonly MachineOption[]
+ /** Select a different machine by id. */
+ selectMachine(machineId: string): void
+ /** Build the create-session payload carrying the chosen machine_id. */
+ createPayload(): CreateSessionPayload
+}
+
+// ── state factory ────────────────────────────────────────────────────────────
+
+/** The fixed cascade order: Machine ▸ Project ▸ Worktree. */
+const CASCADE_ORDER: readonly CascadeStep[] = ["machine", "project", "worktree"] as const
+
+/** Create the machine picker state. Default selection = focused machine, or
+ * the local machine when no focus is set. */
+export function createMachinePickerState(opts: MachinePickerOptions): MachinePickerState {
+ const { machines, focusedMachineId } = opts
+
+ // Resolve default: focused machine first, then the local machine, then first
+ const localMachine = machines.find((m) => m.isLocal)
+ const focusedMachine = focusedMachineId ? machines.find((m) => m.machineId === focusedMachineId) : undefined
+ const defaultMachineId = focusedMachine?.machineId ?? localMachine?.machineId ?? machines[0]?.machineId ?? ""
+
+ let _selectedMachineId = defaultMachineId
+
+ return {
+ cascadeOrder: CASCADE_ORDER,
+
+ get selectedMachineId() {
+ return _selectedMachineId
+ },
+
+ get machines() {
+ return machines
+ },
+
+ selectMachine(machineId: string) {
+ if (machines.some((m) => m.machineId === machineId)) {
+ _selectedMachineId = machineId
+ }
+ },
+
+ createPayload(): CreateSessionPayload {
+ return { machine_id: _selectedMachineId }
+ },
+ }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx
index ad9cc7ecb..be54dc73e 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input-v2.tsx
@@ -28,6 +28,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useServerSDK } from "@/context/server-sdk"
import { createStreamGap } from "@/context/stream-gap"
+import { effectiveEngineState } from "@/components/engine-state-signal"
import { createSessionTabs } from "@/pages/session/helpers"
import { showToast } from "@/utils/toast"
import { bugReportEnabled } from "@/utils/amicode-bug-report"
@@ -96,6 +97,12 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
// with the reason instead of optimistically posting into a dead tunnel.
const serverSDK = useServerSDK()
const streamGap = createStreamGap(() => serverSDK().event.status())
+ // #1608 AC6: refuse a send while the engine is deliberately off (or stopping)
+ // with an engine-specific reason, ahead of the generic stream-gap refusal.
+ const engineOff = () => {
+ const s = effectiveEngineState()
+ return s === "off" || s === "stopping"
+ }
const files = useFile()
const layout = useLayout()
const comments = useComments()
@@ -240,6 +247,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps):
onAbort: props.onAbort,
onSubmit: props.onSubmit,
streamGap,
+ engineOff,
model: props.controls.model.selection,
})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
index dc8a124f6..95002e2b6 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input.tsx
@@ -30,6 +30,7 @@ import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync"
import { useServerSDK } from "@/context/server-sdk"
import { createStreamGap } from "@/context/stream-gap"
+import { effectiveEngineState } from "@/components/engine-state-signal"
import { useComments } from "@/context/comments"
import { Button } from "@opencode-ai/ui/button"
import { DockShellForm, DockTray } from "@opencode-ai/ui/dock-surface"
@@ -110,6 +111,12 @@ export const PromptInput: Component = (props) => {
// with the reason instead of optimistically posting into a dead tunnel.
const serverSDK = useServerSDK()
const streamGap = createStreamGap(() => serverSDK().event.status())
+ // #1608 AC6: refuse a send while the engine is deliberately off (or stopping)
+ // with an engine-specific reason, ahead of the generic stream-gap refusal.
+ const engineOff = () => {
+ const s = effectiveEngineState()
+ return s === "off" || s === "stopping"
+ }
const files = useFile()
const prompt = props.state ?? usePrompt()
const layout = useLayout()
@@ -1232,6 +1239,7 @@ export const PromptInput: Component = (props) => {
onAbort: props.onAbort,
onSubmit: props.onSubmit,
streamGap,
+ engineOff,
model: props.controls.model.selection,
})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts
index 1c2a773ff..dfe05fb9f 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit-stream-gap.test.ts
@@ -46,3 +46,42 @@ describe("send-during-gap honest refusal (#1203)", () => {
}
})
})
+
+// #1608 AC6: a send attempted while the engine is OFF is refused with an
+// engine-specific message that takes PRECEDENCE over the generic
+// connection-dropped (stream-gap) refusal — the specific reason wins.
+describe("send-while-engine-off honest refusal (#1608 AC6)", () => {
+ test("the engine-off refusal is ordered BEFORE the stream-gap refusal", () => {
+ const engineOff = source.indexOf("input.engineOff?.()")
+ const streamGap = source.indexOf("input.streamGap?.()")
+ expect(engineOff).toBeGreaterThan(-1)
+ expect(streamGap).toBeGreaterThan(-1)
+ // the specific reason wins: engine-off is consulted first
+ expect(engineOff).toBeLessThan(streamGap)
+ })
+
+ test("both refusals fire before anything is committed to the composer", () => {
+ const engineOff = source.indexOf("input.engineOff?.()")
+ const history = source.indexOf("input.addToHistory")
+ const clear = source.indexOf("clearInput()")
+ expect(history).toBeGreaterThan(engineOff)
+ expect(clear).toBeGreaterThan(engineOff)
+ })
+
+ test("the engine-off refusal shows an engine-specific notice (not the generic drop)", () => {
+ expect(source).toContain("prompt.toast.engineOff.title")
+ expect(source).toContain("prompt.toast.engineOff.description")
+ })
+
+ test("the engine-off state is INJECTED, so submission stays testable without the provider tree", () => {
+ expect(source).toContain("engineOff?: Accessor")
+ })
+
+ test("both composers wire the engine-off accessor from the global engine signal", () => {
+ for (const composer of ["../prompt-input.tsx", "../prompt-input-v2.tsx"]) {
+ const wiring = readFileSync(join(import.meta.dir, composer), "utf8")
+ expect(wiring).toContain("effectiveEngineState")
+ expect(wiring).toContain("engineOff,")
+ }
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts
index a6d6843f6..8a161db4e 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/prompt-input/submit.ts
@@ -6,6 +6,7 @@ import { useNavigate, useParams, useSearchParams } from "@solidjs/router"
import { batch, startTransition, type Accessor } from "solid-js"
import { useTabs } from "@/context/tabs"
import { useServerSync, type ServerSync } from "@/context/server-sync"
+import { useServer } from "@/context/server"
import { useLanguage } from "@/context/language"
import { useLayout } from "@/context/layout"
import { useLocal, type ModelSelection } from "@/context/local"
@@ -24,6 +25,12 @@ import { createPromptSubmissionState } from "./submission-state"
import { normalizeSessionInfo } from "@/utils/session"
import { Event } from "@opencode-ai/schema/event"
import { blobDataUrl } from "@/utils/draft-store"
+import { startReconcileTimer, cancelReconcileTimer } from "@/context/session-status-reconcile"
+import { amicodeGet } from "@/utils/amicode-fetch"
+import { currentMachineSelection } from "@/pages/new-session/new-session-machine-selection"
+import { runCreatePreflight } from "@/components/remote-create-preflight"
+import { armRemoteCreate, disarmRemoteCreate } from "@/components/remote-create-arm"
+import type { CreationTargetResponse, PeerHomeBaseResponse } from "@/components/remote-create-preflight"
type PendingPrompt = {
abort: AbortController
@@ -62,10 +69,14 @@ export async function sendFollowupDraft(input: FollowupSendInput) {
const setBusy = () => {
if (!input.optimisticBusy) return
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "busy" })
+ startReconcileTimer(input.draft.sessionID, (sessionId) => {
+ input.serverSync.session.set("session_status", sessionId, { type: "idle" })
+ })
}
const setIdle = () => {
if (!input.optimisticBusy) return
+ cancelReconcileTimer(input.draft.sessionID)
input.serverSync.session.set("session_status", input.draft.sessionID, { type: "idle" })
}
@@ -233,6 +244,12 @@ type PromptSubmitInput = {
* after a first successful connect); a send is then refused with an honest
* notice instead of optimistically posting into a dead tunnel. */
streamGap?: Accessor
+ /** amicode#1608 — true when the engine is deliberately OFF (or stopping). A
+ * send is refused with an engine-specific notice that takes PRECEDENCE over
+ * streamGap, so an intentional off reads as intentional rather than as a
+ * generic connection drop. Injected (like streamGap) so submission stays
+ * testable without the provider tree. */
+ engineOff?: Accessor
model?: ModelSelection
}
@@ -241,6 +258,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
const sdk = useSDK()
const sync = useSync()
const serverSync = useServerSync()
+ const server = useServer()
const local = useLocal()
const permission = usePermission()
const prompt = input.prompt
@@ -340,6 +358,19 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
+ // amicode#1608 AC6 — a send while the engine is deliberately OFF refuses
+ // with an engine-specific reason, ordered BEFORE the stream-gap refusal so
+ // the specific "engine is off" reason wins over the generic
+ // connection-dropped notice. Same honest-refusal contract as the gap:
+ // nothing was sent, the draft is untouched, nothing pushed to history.
+ if (input.engineOff?.()) {
+ showToast({
+ title: language.t("prompt.toast.engineOff.title"),
+ description: language.t("prompt.toast.engineOff.description"),
+ })
+ return
+ }
+
// amicode#1203 AC3 — a send during the stream gap refuses with an honest
// reason: nothing was sent, the draft is untouched in the composer, nothing
// was pushed to history. Never a fake success, never a silent loss. The gap
@@ -419,6 +450,35 @@ export function createPromptSubmit(input: PromptSubmitInput) {
let session = input.info()
if (!session && isNewSession) {
+ // #1643 (completes #1484 AC3): the pre-flight gate. When the composer's
+ // machine picker selected a REMOTE peer, resolve the create target + the
+ // peer's working directory BEFORE creating. A blocked pick surfaces its
+ // exact reason and creates NOTHING; a remote pick arms x-amicode-owner
+ // (the multiplexer routes the path-less create POST to the peer) and
+ // lands in the peer's directory; a local pick is byte-unchanged.
+ const picked = currentMachineSelection()
+ let remoteOwnerArmed = false
+ if (picked) {
+ const conn = server.current
+ const preflight = await runCreatePreflight(picked, {
+ getCreationTarget: (m) =>
+ amicodeGet(conn, `/amicode/fleet/creation-target?machine=${encodeURIComponent(m)}`) as Promise,
+ getPeerHomeBase: (m) =>
+ amicodeGet(conn, `/amicode/fleet/peer-home-base?machine=${encodeURIComponent(m)}`) as Promise,
+ })
+ if (!preflight.proceed) {
+ showToast({
+ title: language.t("prompt.toast.sessionCreateFailed.title"),
+ description: preflight.displayText ?? language.t("common.requestFailed"),
+ })
+ return
+ }
+ if (preflight.ownerToArm) {
+ armRemoteCreate(preflight.ownerToArm)
+ remoteOwnerArmed = true
+ if (preflight.remoteDirectory) sessionDirectory = preflight.remoteDirectory
+ }
+ }
const created = await sdk()
.api.session.create({
agent: currentAgent.name,
@@ -427,6 +487,8 @@ export function createPromptSubmit(input: PromptSubmitInput) {
})
.then(normalizeSessionInfo)
.catch((err) => {
+ // Never leave a stale arm behind if the create rejected.
+ if (remoteOwnerArmed) disarmRemoteCreate()
showToast({
title: language.t("prompt.toast.sessionCreateFailed.title"),
description: errorMessage(err),
@@ -536,6 +598,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
clearInput()
const messageID = Identifier.ascending("message")
serverSync().session.set("session_status", session.id, { type: "busy" })
+ startReconcileTimer(session.id, (sessionId) => {
+ serverSync().session.set("session_status", sessionId, { type: "idle" })
+ })
sdk()
.api.session.command({
sessionID: session.id,
@@ -552,6 +617,7 @@ export function createPromptSubmit(input: PromptSubmitInput) {
),
})
.catch((err) => {
+ cancelReconcileTimer(session.id)
serverSync().session.set("session_status", session.id, { type: "idle" })
showToast({
title: language.t("prompt.toast.commandSendFailed.title"),
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts
new file mode 100644
index 000000000..043a7d21c
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.test.ts
@@ -0,0 +1,81 @@
+/**
+ * remote-create-arm.test.ts — #1643 (completes #1484 AC3)
+ *
+ * The arming store + fetch-seam attachment. The composer, having pre-flight
+ * resolved a REMOTE target, ARMS the owner id; the SDK's custom fetch (the
+ * sseFetch-precedent seam, server-sdk.tsx) reads the armed owner and attaches
+ * `x-amicode-owner` to the NEXT path-less session-create POST, then disarms.
+ * A one-shot: it must not leak the header onto later or unrelated requests.
+ */
+import { describe, expect, test, beforeEach } from "bun:test"
+import {
+ armRemoteCreate,
+ disarmRemoteCreate,
+ peekArmedOwner,
+ attachOwnerHeaderIfArmed,
+ isSessionCreate,
+} from "./remote-create-arm"
+import { OWNER_HEADER } from "./remote-create-header"
+
+describe("#1643 isSessionCreate — recognizes the path-less create POST", () => {
+ test("POST /session is a create", () => {
+ expect(isSessionCreate("POST", "http://x/session")).toBe(true)
+ })
+ test("POST /api/session is a create (v2 vendored client)", () => {
+ expect(isSessionCreate("POST", "http://x/api/session")).toBe(true)
+ })
+ test("POST /session/abc/message is NOT a create", () => {
+ expect(isSessionCreate("POST", "http://x/session/abc/message")).toBe(false)
+ })
+ test("GET /session is NOT a create", () => {
+ expect(isSessionCreate("GET", "http://x/session")).toBe(false)
+ })
+})
+
+describe("#1643 arm/disarm store", () => {
+ beforeEach(() => disarmRemoteCreate())
+
+ test("peek is undefined by default", () => {
+ expect(peekArmedOwner()).toBeUndefined()
+ })
+ test("arm then peek returns the owner", () => {
+ armRemoteCreate("studio-001")
+ expect(peekArmedOwner()).toBe("studio-001")
+ })
+ test("disarm clears it", () => {
+ armRemoteCreate("studio-001")
+ disarmRemoteCreate()
+ expect(peekArmedOwner()).toBeUndefined()
+ })
+})
+
+describe("#1643 attachOwnerHeaderIfArmed — one-shot header injection", () => {
+ beforeEach(() => disarmRemoteCreate())
+
+ test("armed + create POST → header attached AND store disarmed", () => {
+ armRemoteCreate("studio-001")
+ const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", {})
+ expect(headers[OWNER_HEADER]).toBe("studio-001")
+ // one-shot: disarmed after use
+ expect(peekArmedOwner()).toBeUndefined()
+ })
+
+ test("armed but NOT a create POST → no header, still armed", () => {
+ armRemoteCreate("studio-001")
+ const headers = attachOwnerHeaderIfArmed("POST", "http://x/session/abc/message", {})
+ expect(headers[OWNER_HEADER]).toBeUndefined()
+ expect(peekArmedOwner()).toBe("studio-001")
+ })
+
+ test("not armed + create POST → NO header (local create is byte-unchanged)", () => {
+ const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", {})
+ expect(headers[OWNER_HEADER]).toBeUndefined()
+ })
+
+ test("preserves existing headers", () => {
+ armRemoteCreate("studio-001")
+ const headers = attachOwnerHeaderIfArmed("POST", "http://x/session", { "content-type": "application/json" })
+ expect(headers["content-type"]).toBe("application/json")
+ expect(headers[OWNER_HEADER]).toBe("studio-001")
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts
new file mode 100644
index 000000000..171a29f7e
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-arm.ts
@@ -0,0 +1,78 @@
+/**
+ * remote-create-arm.ts — #1643 (completes #1484 AC3)
+ *
+ * The arming store + fetch-seam attachment — the mechanism that carries the
+ * owner header from the composer's pre-flight decision to the actual create
+ * request, WITHOUT threading a parameter through the SDK's typed create call
+ * (which takes no per-call headers). It mirrors the sseFetch precedent
+ * (server-sdk.tsx): a custom fetch wrapper rewrites the outgoing request.
+ *
+ * Flow:
+ * 1. The composer pre-flight resolves a REMOTE target → armRemoteCreate(id).
+ * 2. It calls sdk().api.session.create(...); that create is a path-less
+ * POST /session (the exact case the multiplexer routes on the header).
+ * 3. The SDK's fetch wrapper calls attachOwnerHeaderIfArmed — on a create
+ * POST while armed, it attaches x-amicode-owner and DISARMS (one-shot).
+ * 4. A local create never arms → no header → byte-unchanged behavior.
+ *
+ * One-shot by design: the header must land on exactly the create it was armed
+ * for, never leak onto later requests. Module-level state is the right lifetime
+ * (a single armed create at a time; a create is synchronous-to-arm here).
+ */
+
+import { OWNER_HEADER } from "./remote-create-header"
+
+// ── the arming store ─────────────────────────────────────────────────────────
+
+let _armedOwner: string | undefined
+
+/** Arm the next session-create to route to this owner peer. */
+export function armRemoteCreate(ownerMachineId: string): void {
+ const trimmed = ownerMachineId.trim()
+ _armedOwner = trimmed === "" ? undefined : trimmed
+}
+
+/** Clear any armed owner (called on disarm and after a one-shot attach). */
+export function disarmRemoteCreate(): void {
+ _armedOwner = undefined
+}
+
+/** Read the armed owner without consuming it (test/inspection). */
+export function peekArmedOwner(): string | undefined {
+ return _armedOwner
+}
+
+// ── request recognition ──────────────────────────────────────────────────────
+
+/** Is this the path-less session-create POST? Both client shapes:
+ * POST /session (v1 legacy client)
+ * POST /api/session (v2 vendored client)
+ * A create carries NO trailing sub-segment (message/command/etc. are not
+ * creates). Mirrors the multiplexer's own path discipline. */
+export function isSessionCreate(method: string, url: string): boolean {
+ if (method.toUpperCase() !== "POST") return false
+ let pathname: string
+ try {
+ pathname = new URL(url).pathname
+ } catch {
+ pathname = url
+ }
+ return pathname === "/session" || pathname === "/api/session"
+}
+
+// ── the fetch-seam attachment ────────────────────────────────────────────────
+
+/** On a create POST while armed, return headers with x-amicode-owner attached
+ * and DISARM (one-shot); otherwise return the headers unchanged. Existing
+ * headers are preserved. */
+export function attachOwnerHeaderIfArmed(
+ method: string,
+ url: string,
+ headers: Record,
+): Record {
+ if (_armedOwner === undefined) return headers
+ if (!isSessionCreate(method, url)) return headers
+ const owner = _armedOwner
+ disarmRemoteCreate()
+ return { ...headers, [OWNER_HEADER]: owner }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts
new file mode 100644
index 000000000..56666f0e2
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.test.ts
@@ -0,0 +1,65 @@
+/**
+ * remote-create-header.test.ts — #1643 (completes #1484 AC3)
+ *
+ * The owner-header producer: given the pre-flight-resolved creation target, it
+ * decides what the composer does on submit — attach `x-amicode-owner` and
+ * create remotely, create locally with NO header, or refuse with a blocked
+ * reason. This is the missing PRODUCER for ADR 0031 §D1a leg 2 (the multiplexer
+ * has consumed the header since #1449; nothing emitted it).
+ */
+import { describe, expect, test } from "bun:test"
+import {
+ OWNER_HEADER,
+ planRemoteCreate,
+ type CreationTargetView,
+} from "./remote-create-header"
+
+describe("#1643 planRemoteCreate — the owner-header producer", () => {
+ test("local target → proceed, no owner header", () => {
+ const plan = planRemoteCreate({ kind: "local" })
+ expect(plan.proceed).toBe(true)
+ expect(plan.headers).toEqual({})
+ expect(plan.blockedReason).toBeUndefined()
+ })
+
+ test("remote target → proceed, attach x-amicode-owner = machineId", () => {
+ const plan = planRemoteCreate({ kind: "remote", machineId: "studio-001" })
+ expect(plan.proceed).toBe(true)
+ expect(plan.headers[OWNER_HEADER]).toBe("studio-001")
+ })
+
+ test("blocked target → do NOT proceed, carry the reason, no header", () => {
+ const plan = planRemoteCreate({ kind: "blocked", machineId: "studio-001", reason: "no-control-grant" })
+ expect(plan.proceed).toBe(false)
+ expect(plan.blockedReason).toBe("no-control-grant")
+ expect(plan.headers).toEqual({})
+ })
+
+ test("the header key is the exact routing header the multiplexer reads", () => {
+ // Must byte-match session_multiplexer.ts OWNER_ROUTING_HEADER.
+ expect(OWNER_HEADER).toBe("x-amicode-owner")
+ })
+
+ test("remote target with an empty machineId is refused (never an empty header)", () => {
+ const plan = planRemoteCreate({ kind: "remote", machineId: "" } as CreationTargetView)
+ expect(plan.proceed).toBe(false)
+ expect(plan.headers).toEqual({})
+ })
+})
+
+describe("#1643 blocked-reason display text", () => {
+ test("every blocked reason maps to human-readable inline text", () => {
+ const reasons = [
+ "no-control-grant",
+ "grant-revoked",
+ "insufficient-scope",
+ "transport-down",
+ "no-remote-root",
+ ] as const
+ for (const r of reasons) {
+ const plan = planRemoteCreate({ kind: "blocked", machineId: "m", reason: r })
+ expect(plan.displayText).toBeTruthy()
+ expect(typeof plan.displayText).toBe("string")
+ }
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts
new file mode 100644
index 000000000..03bda49fd
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-header.ts
@@ -0,0 +1,81 @@
+/**
+ * remote-create-header.ts — #1643 (completes #1484 AC3)
+ *
+ * The OWNER-HEADER PRODUCER — the missing emitter for ADR 0031 §D1a leg 2. The
+ * per-session multiplexer has read `x-amicode-owner` since #1449; nothing in the
+ * app ever set it, so every create resolved local (the AC3-shipped-incomplete
+ * finding). This module is that producer, as a pure decision function:
+ *
+ * given the pre-flight-resolved creation target (from GET
+ * /amicode/fleet/creation-target), decide whether the composer proceeds and,
+ * if remote, WHAT header the session.create attaches.
+ *
+ * - local → proceed, no header (byte-unchanged from today's local create)
+ * - remote → proceed, attach x-amicode-owner:
+ * - blocked → do NOT proceed; carry the reason for inline display
+ *
+ * Pure: no I/O. The composer runs the pre-flight fetch, feeds the target here,
+ * and either dispatches with plan.headers or renders plan.displayText inline.
+ */
+
+/** The routing header. MUST byte-match session_multiplexer.ts
+ * OWNER_ROUTING_HEADER — the multiplexer reads exactly this key. */
+export const OWNER_HEADER = "x-amicode-owner"
+
+/** The blocked reasons the service's creation-target route can return, plus
+ * no-remote-root from the peer-home-base route. Closed vocabulary. */
+export type BlockedReason =
+ | "no-control-grant"
+ | "grant-revoked"
+ | "insufficient-scope"
+ | "transport-down"
+ | "no-remote-root"
+
+/** The creation target as the service route reports it (mirrors CreationTarget
+ * in session_creation_target.ts, plus no-remote-root from the home-base gate). */
+export type CreationTargetView =
+ | { kind: "local" }
+ | { kind: "remote"; machineId: string }
+ | { kind: "blocked"; machineId: string; reason: BlockedReason }
+
+export interface RemoteCreatePlan {
+ /** Whether the composer should proceed with session.create. */
+ proceed: boolean
+ /** Headers to attach to the create request (empty for local). */
+ headers: Record
+ /** The blocked reason, when proceed is false. */
+ blockedReason?: BlockedReason
+ /** Human-readable inline text to render when blocked. */
+ displayText?: string
+}
+
+const BLOCKED_TEXT: Record = {
+ "no-control-grant": "No control grant for that machine — request control before creating a session there.",
+ "grant-revoked": "The control grant for that machine was revoked.",
+ "insufficient-scope": "That grant is observe-only — creating a session needs control scope.",
+ "transport-down": "That machine is currently unreachable.",
+ "no-remote-root": "Couldn't resolve a working directory on that machine.",
+}
+
+/** Decide the composer's create plan from the resolved target. Pure. */
+export function planRemoteCreate(target: CreationTargetView): RemoteCreatePlan {
+ if (target.kind === "local") {
+ return { proceed: true, headers: {} }
+ }
+ if (target.kind === "remote") {
+ // Defensive: a remote target must carry a non-empty machineId, else we would
+ // emit an empty routing header (which the multiplexer treats as local — the
+ // exact silent-local-fallback #1382 forbids). Refuse instead.
+ if (!target.machineId || target.machineId.trim() === "") {
+ return { proceed: false, headers: {}, displayText: "Remote target is missing a machine id." }
+ }
+ return { proceed: true, headers: { [OWNER_HEADER]: target.machineId } }
+ }
+ // blocked
+ return {
+ proceed: false,
+ headers: {},
+ blockedReason: target.reason,
+ displayText: BLOCKED_TEXT[target.reason],
+ }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts
new file mode 100644
index 000000000..7edf6fff6
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.test.ts
@@ -0,0 +1,84 @@
+/**
+ * remote-create-preflight.test.ts — #1643 (completes #1484 AC3)
+ *
+ * The composer's pre-flight orchestration: given the picked machine, query the
+ * creation-target gate (and, for a remote target, the peer-home-base gate),
+ * then decide the create plan — proceed local, proceed remote (armed + remote
+ * directory), or refuse with an inline reason. Pure over injected fetchers so
+ * it is unit-testable without a live server.
+ */
+import { describe, expect, test } from "bun:test"
+import { runCreatePreflight, type PreflightFetchers } from "./remote-create-preflight"
+import { OWNER_HEADER } from "./remote-create-header"
+
+function fetchers(over?: Partial): PreflightFetchers {
+ return {
+ getCreationTarget: async (machine) => {
+ if (machine === "studio") return { ok: true, target: { kind: "remote", machineId: "studio" } }
+ if (machine === "local") return { ok: true, target: { kind: "local" } }
+ if (machine === "blocked") return { ok: true, target: { kind: "blocked", machineId: "blocked", reason: "no-control-grant" } }
+ return { ok: true, target: { kind: "local" } }
+ },
+ getPeerHomeBase: async (machine) => {
+ if (machine === "studio") return { ok: true, directory: "/Users/studio/work" }
+ return { ok: false, reason: "no-remote-root" }
+ },
+ ...over,
+ }
+}
+
+describe("#1643 runCreatePreflight", () => {
+ test("no machine selected → local, proceed, no arm, no remote directory", async () => {
+ const r = await runCreatePreflight(undefined, fetchers())
+ expect(r.proceed).toBe(true)
+ expect(r.ownerToArm).toBeUndefined()
+ expect(r.remoteDirectory).toBeUndefined()
+ })
+
+ test("local machine → local, proceed, no arm", async () => {
+ const r = await runCreatePreflight("local", fetchers())
+ expect(r.proceed).toBe(true)
+ expect(r.ownerToArm).toBeUndefined()
+ })
+
+ test("remote machine with directory → proceed, arm owner, carry remote dir", async () => {
+ const r = await runCreatePreflight("studio", fetchers())
+ expect(r.proceed).toBe(true)
+ expect(r.ownerToArm).toBe("studio")
+ expect(r.remoteDirectory).toBe("/Users/studio/work")
+ })
+
+ test("blocked target → refuse with reason, no arm", async () => {
+ const r = await runCreatePreflight("blocked", fetchers())
+ expect(r.proceed).toBe(false)
+ expect(r.blockedReason).toBe("no-control-grant")
+ expect(r.ownerToArm).toBeUndefined()
+ })
+
+ test("remote but home-base unresolvable → refuse no-remote-root, no arm", async () => {
+ const r = await runCreatePreflight("studio", fetchers({
+ getPeerHomeBase: async () => ({ ok: false, reason: "no-remote-root" }),
+ }))
+ expect(r.proceed).toBe(false)
+ expect(r.blockedReason).toBe("no-remote-root")
+ expect(r.ownerToArm).toBeUndefined()
+ })
+
+ test("a gate fetch that throws → refuse safely (never a silent local remote)", async () => {
+ const r = await runCreatePreflight("studio", fetchers({
+ getCreationTarget: async () => {
+ throw new Error("network")
+ },
+ }))
+ expect(r.proceed).toBe(false)
+ expect(r.ownerToArm).toBeUndefined()
+ expect(r.blockedReason).toBe("transport-down")
+ })
+
+ test("the owner to arm equals what the header producer would emit", async () => {
+ const r = await runCreatePreflight("studio", fetchers())
+ // planRemoteCreate would put ownerToArm under OWNER_HEADER
+ expect(r.ownerToArm).toBe("studio")
+ expect(OWNER_HEADER).toBe("x-amicode-owner")
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts
new file mode 100644
index 000000000..cf1d161e4
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/remote-create-preflight.ts
@@ -0,0 +1,98 @@
+/**
+ * remote-create-preflight.ts — #1643 (completes #1484 AC3)
+ *
+ * The composer's pre-flight orchestration — the app-side GATE that runs BEFORE
+ * a session.create so a blocked remote pick surfaces its exact reason inline
+ * and no session is created. It composes the two service routes:
+ *
+ * 1. GET /amicode/fleet/creation-target?machine= → local | remote | blocked
+ * 2. for a remote target, GET /amicode/fleet/peer-home-base?machine=
+ * → the directory the remote create lands in (unresolvable → no-remote-root)
+ *
+ * The decision:
+ * - local → proceed, no arm, no remote directory (unchanged path)
+ * - remote + directory → proceed, arm the owner, carry the remote directory
+ * - blocked / no-root → refuse, carry the reason for inline display
+ * - any fetch error → refuse as transport-down (NEVER a silent local
+ * create dressed as remote — the #1382 fallback the design forbids)
+ *
+ * Pure over injected fetchers; the live composer wires them to amicodeGet.
+ */
+
+import { planRemoteCreate, type BlockedReason, type CreationTargetView } from "./remote-create-header"
+
+export interface CreationTargetResponse {
+ ok: boolean
+ target?: CreationTargetView
+ reason?: string
+}
+
+export interface PeerHomeBaseResponse {
+ ok: boolean
+ directory?: string
+ reason?: string
+}
+
+export interface PreflightFetchers {
+ getCreationTarget: (machine: string) => Promise
+ getPeerHomeBase: (machine: string) => Promise
+}
+
+export interface PreflightResult {
+ /** Whether the composer should proceed with the create. */
+ proceed: boolean
+ /** The owner id to arm (remote target only); undefined for local. */
+ ownerToArm?: string
+ /** The directory the remote create must use (remote target only). */
+ remoteDirectory?: string
+ /** The blocked reason when proceed is false. */
+ blockedReason?: BlockedReason
+ /** Human-readable inline text when blocked. */
+ displayText?: string
+}
+
+/** Run the pre-flight gate for a picked machine. No selection or a local pick
+ * proceeds locally with no arming. */
+export async function runCreatePreflight(
+ selectedMachineId: string | undefined,
+ fetchers: PreflightFetchers,
+): Promise {
+ if (selectedMachineId === undefined || selectedMachineId.trim() === "") {
+ return { proceed: true }
+ }
+
+ let target: CreationTargetView
+ try {
+ const res = await fetchers.getCreationTarget(selectedMachineId)
+ if (!res.ok || !res.target) {
+ return { proceed: false, blockedReason: "transport-down", displayText: "Couldn't check that machine." }
+ }
+ target = res.target
+ } catch {
+ // A gate we cannot reach is NOT a license to create locally under a remote
+ // pick — refuse as transport-down (the #1382 no-silent-fallback invariant).
+ return { proceed: false, blockedReason: "transport-down", displayText: "That machine is currently unreachable." }
+ }
+
+ const plan = planRemoteCreate(target)
+ if (!plan.proceed) {
+ return { proceed: false, blockedReason: plan.blockedReason, displayText: plan.displayText }
+ }
+
+ // Local target → proceed unchanged.
+ if (target.kind === "local") {
+ return { proceed: true }
+ }
+
+ // Remote target → resolve the peer's working directory before arming.
+ try {
+ const home = await fetchers.getPeerHomeBase(target.machineId)
+ if (!home.ok || !home.directory) {
+ const noRoot = planRemoteCreate({ kind: "blocked", machineId: target.machineId, reason: "no-remote-root" })
+ return { proceed: false, blockedReason: "no-remote-root", displayText: noRoot.displayText }
+ }
+ return { proceed: true, ownerToArm: target.machineId, remoteDirectory: home.directory }
+ } catch {
+ return { proceed: false, blockedReason: "transport-down", displayText: "That machine is currently unreachable." }
+ }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
index 92b194974..3ac8c2da1 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/index.ts
@@ -1,4 +1,4 @@
-export { SessionHeader } from "./session-header"
+export { SessionHeader, SessionComposerControlScrim } from "./session-header"
export { SessionContextTab } from "./session-context-tab"
export { SessionPreviewTab } from "./session-preview-tab"
export { PanelMenu } from "./panel-menu"
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
index 7ac344b61..c00c3023b 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-file-view.tsx
@@ -31,6 +31,7 @@ import { PreviewEditor } from "@opencode-ai/session-ui/v2/preview-editor"
import type { PreviewViewState } from "@opencode-ai/session-ui/v2/preview-view-state"
import { PdfCanvasView } from "./pdf-canvas-view"
import { toAbsolutePath, shouldApplyWatcherRead } from "./preview-file-helpers"
+import { validateHumanWritePath } from "./preview-human-write-gate"
// ---------------------------------------------------------------------------
// Helpers
@@ -291,6 +292,14 @@ export function PreviewFileView(props: {
})
const saveFile = async (filePath: string, content: string, closeAfterSave = false) => {
+ // #1454 (W5): human write gate — reject a write that resolves outside the
+ // session workspace (the engine handler stays permissive for the agent; the
+ // gate is applied UPSTREAM here). Only gate when the workspace is known.
+ const workspace = sdk().directory
+ if (workspace && !validateHumanWritePath(filePath, workspace).allowed) {
+ setSaveStatus("idle")
+ return
+ }
setSaveStatus("saving")
try {
await serverSDK().client.file.write({ path: filePath, content })
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts b/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts
new file mode 100644
index 000000000..f8337ce7a
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/preview-human-write-gate.ts
@@ -0,0 +1,86 @@
+/**
+ * preview-human-write-gate.ts — #1442 AC5, extended by #1454 (W5)
+ *
+ * Browser-safe workspace-containment gate for the human Preview / review-panel
+ * write path. It runs in the webview, so — like preview-file-helpers.ts — it
+ * uses plain string operations, NOT `node:path` (which is not available in the
+ * browser bundle).
+ *
+ * The gate is applied UPSTREAM of the shared engine `file.write` handler
+ * (`overlay/.../handlers/file.ts`), which deliberately permits absolute paths
+ * for the agent capability — the handler body is NEVER edited. This gate narrows
+ * only the HUMAN write path:
+ * - relative paths → resolved against the workspace root and allowed
+ * - absolute INSIDE the ws → normalized and allowed (Preview paths are often
+ * absolute: preview-file-helpers.ts:11-21)
+ * - anything OUTSIDE the ws → rejected (never reaches the owner)
+ * - empty paths → rejected
+ * The agent's in-process write (tool/write.ts) is out of scope and untouched.
+ *
+ * @module
+ */
+
+import { isAbsolutePreviewPath } from "./preview-file-helpers"
+
+// ── types ────────────────────────────────────────────────────────────────────
+
+/** The result of validating a human write path. */
+export type HumanWriteValidation =
+ | { allowed: true; resolvedPath: string }
+ | { allowed: false; reason: "path-escapes-workspace" | "empty-path" }
+
+// ── validation ───────────────────────────────────────────────────────────────
+
+/** Collapse "." / ".." segments and unify separators to POSIX "/" — a
+ * browser-safe stand-in for `path.resolve`/`path.normalize`. */
+function normalizePosix(p: string): string {
+ const isAbs = p.startsWith("/")
+ const out: string[] = []
+ for (const seg of p.replace(/\\/g, "/").split("/")) {
+ if (seg === "" || seg === ".") continue
+ if (seg === "..") {
+ out.pop()
+ continue
+ }
+ out.push(seg)
+ }
+ return (isAbs ? "/" : "") + out.join("/")
+}
+
+/** Validate a path for the human Preview/review write gate.
+ *
+ * Rules:
+ * 1. Empty path → rejected.
+ * 2. Resolve to an absolute path: an already-absolute path is taken as-is; a
+ * relative path is joined onto the workspace root.
+ * 3. Normalize "." / ".." segments.
+ * 4. If the resolved path is inside the workspace (or equals it) → allowed with
+ * the normalized absolute path; otherwise → rejected as escaping the
+ * workspace.
+ *
+ * Unlike the engine's `file.write` handler (which allows absolute paths for
+ * agents), this gate accepts an absolute path ONLY when it resolves inside the
+ * workspace — the human path is narrowed below the raw peer token's reach. */
+export function validateHumanWritePath(filePath: string, workspace: string): HumanWriteValidation {
+ // Rule 1: empty path
+ if (!filePath || filePath.trim() === "") {
+ return { allowed: false, reason: "empty-path" }
+ }
+
+ const ws = normalizePosix(workspace)
+
+ // Rule 2: resolve to an absolute candidate
+ const candidateRaw = isAbsolutePreviewPath(filePath)
+ ? filePath
+ : `${ws.replace(/\/+$/, "")}/${filePath}`
+
+ // Rule 3: normalize
+ const resolved = normalizePosix(candidateRaw)
+
+ // Rule 4: containment — must be the workspace root or inside it
+ const wsWithSep = ws.endsWith("/") ? ws : ws + "/"
+ if (resolved === ws || resolved.startsWith(wsWithSep)) {
+ return { allowed: true, resolvedPath: resolved }
+ }
+ return { allowed: false, reason: "path-escapes-workspace" }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts
new file mode 100644
index 000000000..21c7132fc
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-archived-fleet.test.ts
@@ -0,0 +1,50 @@
+import { describe, expect, test } from "bun:test"
+import { archivedSessionsWithRemote, type DropdownSession } from "./session-fleet-peers"
+
+// #1647 (S3): the local Archive tab becomes fleet-aware. archivedSessionsWithRemote
+// merges this machine's LOCAL archived sessions (authoritative, engine-paginated)
+// with the peer-owned archived sessions from GET /amicode/fleet/sessions?archived=true.
+// Standalone / fetch-failure ⇒ raw undefined ⇒ the local list is unchanged.
+
+const localArchived: DropdownSession[] = [
+ { id: "loc-1", time: { created: 5, updated: 5, archived: 5 } } as DropdownSession,
+]
+
+function projection(sessions: unknown[]): unknown {
+ return { ok: true, mode: "fleet", sessions }
+}
+
+describe("#1647 S3 — archivedSessionsWithRemote", () => {
+ test("raw undefined (standalone / fetch failure) ⇒ local list unchanged", () => {
+ expect(archivedSessionsWithRemote(localArchived, undefined)).toEqual(localArchived)
+ })
+
+ test("remote archived peer sessions are merged in, owner-tagged", () => {
+ const raw = projection([
+ { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } },
+ ])
+ const merged = archivedSessionsWithRemote(localArchived, raw)
+ const ids = merged.map((s) => s.id).sort()
+ expect(ids).toEqual(["loc-1", "peer-1"])
+ const peer = merged.find((s) => s.id === "peer-1")!
+ expect(peer.amicode_owner?.owner_machine_id).toBe("mac-studio")
+ expect(peer.amicode_owner?.is_local).toBe(false)
+ })
+
+ test("a LOCAL session in the projection is not double-added (peers-only pull)", () => {
+ const raw = projection([
+ { id: "loc-1", time: { created: 5, updated: 5, archived: 5 }, amicode_owner: { owner_machine_id: "local", owner_name: "Me", is_local: true } },
+ { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } },
+ ])
+ const merged = archivedSessionsWithRemote(localArchived, raw)
+ expect(merged.map((s) => s.id).sort()).toEqual(["loc-1", "peer-1"])
+ })
+
+ test("sorted by last activity descending (most recent first)", () => {
+ const raw = projection([
+ { id: "peer-1", time: { created: 9, updated: 9, archived: 9 }, amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false } },
+ ])
+ const merged = archivedSessionsWithRemote(localArchived, raw)
+ expect(merged.map((s) => s.id)).toEqual(["peer-1", "loc-1"])
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts
index 0ac82300a..4d1625999 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-chats-dropdown.test.ts
@@ -1,4 +1,9 @@
import { describe, expect, test } from "bun:test"
+// #1537 B2a (AC6): the machine helpers are no longer defined here — they live in
+// the ONE real module the component also imports (`session-fleet-peers.ts`).
+// `deriveBadge` was a stale duplicate of the real `deriveSessionBadge`; the
+// per-machine narrowing `filterSessionsByMachine` is promoted alongside it.
+import { deriveSessionBadge, filterSessionsByMachine, sortDropdownSessions, type DropdownSession, type SortMode } from "./session-fleet-peers"
/**
* Tests for the Session Chats Dropdown logic (amicode#274).
@@ -7,12 +12,25 @@ import { describe, expect, test } from "bun:test"
* flyout (amicode#273).
*/
+// Local fixture shape for this suite (a superset-tolerant subset of the SDK
+// session — `time.updated` optional, as the dropdown fixtures use it). The
+// promoted helpers are structural, so they accept this shape directly.
+type SessionOwnerTag = {
+ owner_machine_id: string
+ owner_name: string
+ device_type?: string
+ directory?: string
+ is_local: boolean
+}
+
type Session = {
id: string
title?: string
directory: string
parentID?: string
time: { created: number; updated?: number; archived?: number | null }
+ /** Fleet-wide owner overlay (#1439 — absent on pre-fleet sessions). */
+ amicode_owner?: SessionOwnerTag
}
// --- Helpers under test (pure logic extracted from the component) ---
@@ -232,4 +250,187 @@ describe("Session Chats Dropdown", () => {
expect(action.type).toBe("navigate") // would navigate after close
})
})
+
+ // ── fleet-wide machine badges (#1439, AC2) ─────────────────────────────────
+
+ describe("deriveSessionBadge — machine badge for fleet sessions", () => {
+ const localSession: Session = {
+ id: "ses_local",
+ title: "Local work",
+ directory: "/proj",
+ time: { created: 100 },
+ amicode_owner: {
+ owner_machine_id: "macbook-pro",
+ owner_name: "MacBook Pro",
+ device_type: "laptop",
+ is_local: true,
+ },
+ }
+
+ const remoteSession: Session = {
+ id: "ses_remote",
+ title: "Remote work",
+ directory: "/proj",
+ time: { created: 200 },
+ amicode_owner: {
+ owner_machine_id: "mac-studio",
+ owner_name: "Mac Studio",
+ device_type: "desktop",
+ is_local: false,
+ },
+ }
+
+ const preFleetsession: Session = {
+ id: "ses_old",
+ title: "Legacy",
+ directory: "/proj",
+ time: { created: 50 },
+ // no amicode_owner — pre-fleet session
+ }
+
+ test("local session is unbadged (absence = local)", () => {
+ expect(deriveSessionBadge(localSession)).toBeUndefined()
+ })
+
+ test("remote session shows the owner machine name as badge", () => {
+ expect(deriveSessionBadge(remoteSession)).toBe("Mac Studio")
+ })
+
+ test("pre-fleet session (no owner tag) is unbadged", () => {
+ expect(deriveSessionBadge(preFleetsession)).toBeUndefined()
+ })
+ })
+
+ // ── per-machine filter (#1439, AC3) ────────────────────────────────────────
+
+ describe("filterSessionsByMachine — per-machine narrowing", () => {
+ const fleetSessions: Session[] = [
+ {
+ id: "ses_a",
+ title: "Local session",
+ directory: "/proj",
+ time: { created: 100 },
+ amicode_owner: { owner_machine_id: "macbook", owner_name: "MacBook", is_local: true },
+ },
+ {
+ id: "ses_b",
+ title: "Studio session 1",
+ directory: "/proj",
+ time: { created: 200 },
+ amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false },
+ },
+ {
+ id: "ses_c",
+ title: "Studio session 2",
+ directory: "/proj",
+ time: { created: 300 },
+ amicode_owner: { owner_machine_id: "mac-studio", owner_name: "Mac Studio", is_local: false },
+ },
+ {
+ id: "ses_d",
+ title: "Mini session",
+ directory: "/proj",
+ time: { created: 400 },
+ amicode_owner: { owner_machine_id: "mac-mini", owner_name: "Mac Mini", is_local: false },
+ },
+ ]
+
+ test("null machineId returns all sessions (the 'all machines' state)", () => {
+ expect(filterSessionsByMachine(fleetSessions, null)).toHaveLength(4)
+ })
+
+ test("undefined machineId returns all sessions (the 'clear filter' state)", () => {
+ expect(filterSessionsByMachine(fleetSessions, undefined)).toHaveLength(4)
+ })
+
+ test("a specific machineId narrows to that machine's sessions only", () => {
+ const studio = filterSessionsByMachine(fleetSessions, "mac-studio")
+ expect(studio).toHaveLength(2)
+ expect(studio.every((s) => s.amicode_owner?.owner_machine_id === "mac-studio")).toBe(true)
+ })
+
+ test("the local machine's sessions are filterable by its machine_id", () => {
+ const local = filterSessionsByMachine(fleetSessions, "macbook")
+ expect(local).toHaveLength(1)
+ expect(local[0].id).toBe("ses_a")
+ })
+
+ test("a machineId with no sessions returns an empty list", () => {
+ expect(filterSessionsByMachine(fleetSessions, "nonexistent")).toHaveLength(0)
+ })
+
+ test("filter composes with filterSessionsByQuery", () => {
+ const getTitle = (s: Session) => s.title || s.id
+ const studioSessions = filterSessionsByMachine(fleetSessions, "mac-studio")
+ const searched = filterSessionsByQuery(studioSessions, "session 2", getTitle)
+ expect(searched).toHaveLength(1)
+ expect(searched[0].id).toBe("ses_c")
+ })
+ })
+
+ // ── sort modes compose with open-tab pinning (#1599) ──────────────────────
+
+ describe("sort modes compose with open-tab pinning", () => {
+ /**
+ * The display pipeline partitions into open-tab and rest, sorts EACH
+ * partition with sortDropdownSessions, then concatenates. Open-tab
+ * sessions must stay first regardless of which sort mode is applied.
+ *
+ * This mirrors the real pipeline in session-header.tsx.
+ */
+ function composeSortWithPinning(
+ sessions: DropdownSession[],
+ hasOpenTab: (s: DropdownSession) => boolean,
+ mode: SortMode,
+ ): DropdownSession[] {
+ const openTabs: DropdownSession[] = []
+ const rest: DropdownSession[] = []
+ for (const s of sessions) {
+ if (hasOpenTab(s)) openTabs.push(s)
+ else rest.push(s)
+ }
+ return [
+ ...sortDropdownSessions(openTabs, mode),
+ ...sortDropdownSessions(rest, mode),
+ ]
+ }
+
+ const all: DropdownSession[] = [
+ { id: "a", title: "Zebra", directory: "/p", time: { created: 100 } },
+ { id: "b", title: "Alpha", directory: "/p", time: { created: 300 } },
+ { id: "c", title: "Mid", directory: "/p", time: { created: 200 } },
+ ] as DropdownSession[]
+ const openIds = new Set(["c"])
+ const hasOpen = (s: DropdownSession) => openIds.has(s.id)
+
+ test("alpha sort: open-tab session stays first even if title sorts later", () => {
+ const result = composeSortWithPinning(all, hasOpen, "alpha")
+ // "c" (Mid) has an open tab — it must be first
+ expect(result[0].id).toBe("c")
+ // Rest sorted alphabetically: Alpha (b), Zebra (a)
+ expect(result.slice(1).map((s) => s.id)).toEqual(["b", "a"])
+ })
+
+ test("recent sort: open-tab session stays first even if older", () => {
+ const result = composeSortWithPinning(all, hasOpen, "recent")
+ expect(result[0].id).toBe("c")
+ // Rest sorted by recency: b (300), a (100)
+ expect(result.slice(1).map((s) => s.id)).toEqual(["b", "a"])
+ })
+
+ test("machine sort: open-tab session stays first", () => {
+ const machineAll: DropdownSession[] = [
+ { id: "r1", title: "R1", directory: "/p", time: { created: 300 }, amicode_owner: { owner_machine_id: "peer", owner_name: "Box", is_local: false } },
+ { id: "loc", title: "Local", directory: "/p", time: { created: 100 } },
+ { id: "r2", title: "R2", directory: "/p", time: { created: 200 }, amicode_owner: { owner_machine_id: "peer", owner_name: "Box", is_local: false } },
+ ] as DropdownSession[]
+ const machineOpen = new Set(["r1"])
+ const result = composeSortWithPinning(machineAll, (s) => machineOpen.has(s.id), "machine")
+ // r1 is the open tab — stays first despite being remote
+ expect(result[0].id).toBe("r1")
+ // Rest sorted by machine: local first, then remote
+ expect(result[1].id).toBe("loc")
+ expect(result[2].id).toBe("r2")
+ })
+ })
})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx
index 6d12fef55..3ad9f7508 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-context-tab.tsx
@@ -284,7 +284,13 @@ export function SessionContextTab() {
return map.get(mount) === false
}
- const busy = createMemo(() => (sync().data.session_status[params.id ?? ""]?.type ?? "idle") !== "idle")
+ // #1637: the context-tab busy memo reflects session_working (the turn-active
+ // floor), NOT raw status type, so a stray idle during a live turn cannot flip
+ // a turn to done in the context tree.
+ const busy = createMemo(() => {
+ const id = params.id
+ return !!id && sync().data.session_working(id)
+ })
const turns = createMemo(() => {
const byPrompt = new Map()
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts
new file mode 100644
index 000000000..b107fd5ad
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-control-projection.ts
@@ -0,0 +1,76 @@
+/**
+ * session-fleet-control-projection.ts — the SHARED control-projection poll.
+ *
+ * Extracted from session-header.tsx so both the session header (composer scrim)
+ * and the titlebar tab strip (driving-remote icon) can consume the same
+ * ref-counted, single-interval poll of GET /amicode/fleet/sessions without
+ * importing each other. The poll runs ≈3 s + refetch on window focus, and tears
+ * down when the last consumer unmounts.
+ *
+ * Two hooks:
+ * - useSharedControlProjection() — convenience for components inside a
+ * (calls useServer() internally).
+ * - useControlProjectionForConnection(conn) — for components that have an
+ * explicit ServerConnection (e.g. the titlebar tab strip).
+ */
+
+import { createResource, createRoot, createSignal, onCleanup } from "solid-js"
+import { useServer, type ServerConnection } from "@/context/server"
+import { amicodeGet } from "@/utils/amicode-fetch"
+
+const FLEET_CONTROL_POLL_MS = 3000
+
+type SharedControlProjection = { latest: () => unknown; refetch: () => void }
+
+let sharedControlPoll: SharedControlProjection | null = null
+let sharedControlDispose: (() => void) | null = null
+let sharedControlRefs = 0
+
+function acquireSharedControlProjection(conn: () => ServerConnection.Any | undefined): SharedControlProjection {
+ sharedControlRefs += 1
+ if (!sharedControlPoll) {
+ createRoot((dispose) => {
+ const [tick, setTick] = createSignal(0)
+ const [projection, { refetch }] = createResource(
+ () => [conn(), tick()] as const,
+ ([c]) => (c ? amicodeGet(c, "/amicode/fleet/sessions?scope=all").catch(() => undefined) : undefined),
+ )
+ const interval = setInterval(() => setTick((t) => t + 1), FLEET_CONTROL_POLL_MS)
+ const onFocus = () => void refetch()
+ if (typeof window !== "undefined") window.addEventListener("focus", onFocus)
+ sharedControlPoll = { latest: () => projection.latest, refetch: () => void refetch() }
+ sharedControlDispose = () => {
+ clearInterval(interval)
+ if (typeof window !== "undefined") window.removeEventListener("focus", onFocus)
+ dispose()
+ sharedControlPoll = null
+ sharedControlDispose = null
+ }
+ })
+ }
+ return sharedControlPoll!
+}
+
+function releaseSharedControlProjection() {
+ sharedControlRefs = Math.max(0, sharedControlRefs - 1)
+ if (sharedControlRefs === 0 && sharedControlDispose) sharedControlDispose()
+}
+
+/** Shared accessor for components inside a context (e.g.
+ * SessionHeader, SessionComposerControlScrim). Acquires on mount, releases
+ * on cleanup — exactly one interval poll runs while any consumer is mounted. */
+export function useSharedControlProjection(): () => unknown {
+ const server = useServer()
+ const shared = acquireSharedControlProjection(() => server.current)
+ onCleanup(releaseSharedControlProjection)
+ return shared.latest
+}
+
+/** Shared accessor for components that hold an explicit connection rather than
+ * a context (e.g. TitlebarTabStrip). Same ref-counted
+ * singleton — no duplicate polling when both hooks are live. */
+export function useControlProjectionForConnection(conn: () => ServerConnection.Any | undefined): () => unknown {
+ const shared = acquireSharedControlProjection(conn)
+ onCleanup(releaseSharedControlProjection)
+ return shared.latest
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts
new file mode 100644
index 000000000..31f7a1312
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.test.ts
@@ -0,0 +1,885 @@
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import {
+ peerSessionsFromProjection,
+ allSessionsFromProjection,
+ mergePeerSessions,
+ stabilizeSessionIdentity,
+ sessionRowSignature,
+ hideArchivedTombstones,
+ settleArchivedTombstones,
+ deriveSessionBadge,
+ isRemotePeerSession,
+ resolveDropdownOpenAction,
+ readSessionControl,
+ isControlHeld,
+ writeAffordanceEnabled,
+ failClosedChip,
+ controlAffordance,
+ drivingBanner,
+ drivingBannerFromProjection,
+ drivenByBanner,
+ drivenByBannerFromProjection,
+ remoteDeleteAction,
+ findSessionControlInProjection,
+ groupSessionsByOwner,
+ sortDropdownSessions,
+ CONTROL_CHIP_REASONS,
+ type DropdownSession,
+ type SessionControlProjection,
+ type SessionGroup,
+ type SortMode,
+} from "./session-fleet-peers"
+
+// #1525 B1 (read-only): merge PEER sessions from the fleet projection into the
+// titlebar Sessions dropdown. These pure helpers are the data layer the
+// component consumes; every reader is tolerant (never throws, [] on garbage).
+
+const localTag = { owner_machine_id: "test-laptop", owner_name: "MacBook Pro", is_local: true }
+const studioTag = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", device_type: "desktop", is_local: false }
+
+function projection(sessions: unknown[]): unknown {
+ return { sessions, sources: {} }
+}
+
+describe("#1525 peerSessionsFromProjection — keep only remote peer sessions", () => {
+ test("keeps a remote (is_local:false) session, coerced with title/time/owner", () => {
+ const raw = projection([
+ { id: "ses_studio", title: "Free port 4096", directory: "/proj", time: { created: 3, updated: 7 }, amicode_owner: studioTag },
+ ])
+ const out = peerSessionsFromProjection(raw)
+ expect(out).toHaveLength(1)
+ expect(out[0].id).toBe("ses_studio")
+ expect(out[0].title).toBe("Free port 4096")
+ expect(out[0].time).toEqual({ created: 3, updated: 7 })
+ expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "test-desktop", is_local: false })
+ })
+
+ test("drops LOCAL-owned and UNOWNED entries (the local list already has those)", () => {
+ const raw = projection([
+ { id: "ses_local", title: "local", time: { created: 1 }, amicode_owner: localTag },
+ { id: "ses_plain", title: "plain", time: { created: 2 } },
+ { id: "ses_studio", title: "remote", time: { created: 3 }, amicode_owner: studioTag },
+ ])
+ const out = peerSessionsFromProjection(raw)
+ expect(out.map((s) => s.id)).toEqual(["ses_studio"])
+ })
+
+ test("tolerant: undefined / {} / non-array sessions / malformed rows → []", () => {
+ expect(peerSessionsFromProjection(undefined)).toEqual([])
+ expect(peerSessionsFromProjection({})).toEqual([])
+ expect(peerSessionsFromProjection({ sessions: "nope" })).toEqual([])
+ expect(peerSessionsFromProjection(projection([{ no_id: true, amicode_owner: studioTag }]))).toEqual([])
+ expect(peerSessionsFromProjection(projection([{ id: "x", amicode_owner: { owner_name: 1, is_local: false } }]))).toEqual([])
+ })
+
+ test("a remote entry missing time still coerces (created defaults to 0)", () => {
+ const out = peerSessionsFromProjection(projection([{ id: "ses_studio", amicode_owner: studioTag }]))
+ expect(out).toHaveLength(1)
+ expect(out[0].time.created).toBe(0)
+ })
+
+ // #1646 regression: the peer row MUST carry the projection's amicode_control
+ // overlay. Without it, readSessionControl on the row always fell to the
+ // remote fail-closed default (`no-control-grant`), so an enabled `interactive`
+ // grant never reached the row and archive/delete stayed refused forever.
+ test("#1646: a peer entry's amicode_control overlay is carried onto the row", () => {
+ const out = peerSessionsFromProjection(
+ projection([
+ {
+ id: "ses_studio",
+ amicode_owner: studioTag,
+ amicode_control: { controlState: "interactive", reason: null, eligibility: "none" },
+ },
+ ]),
+ )
+ expect(out).toHaveLength(1)
+ expect(readSessionControl(out[0]).controlState).toBe("interactive")
+ })
+
+ test("#1646: a peer entry WITHOUT control still coerces (degrades to fail-closed at read time)", () => {
+ const out = peerSessionsFromProjection(projection([{ id: "ses_studio", amicode_owner: studioTag }]))
+ expect(out[0].amicode_control).toBeUndefined()
+ // No overlay on the row → readSessionControl applies the remote fail-closed default.
+ expect(readSessionControl(out[0]).reason).toBe("no-control-grant")
+ })
+
+ // #archive-lag follow-up: a REMOTE subagent child (parentID set) must NOT show
+ // as a dropdown row — the dropdown lists root sessions only (mirrors roots()).
+ test("drops a REMOTE child session (parentID set)", () => {
+ const out = peerSessionsFromProjection(
+ projection([
+ { id: "ses_peer_root", amicode_owner: studioTag },
+ { id: "ses_peer_child", parentID: "ses_peer_root", amicode_owner: studioTag },
+ ]),
+ )
+ expect(out.map((s) => s.id)).toEqual(["ses_peer_root"])
+ })
+})
+
+describe("#1525 deriveSessionBadge / isRemotePeerSession", () => {
+ test("remote session badges with the owner name", () => {
+ expect(deriveSessionBadge({ amicode_owner: studioTag })).toBe("Test Desktop")
+ expect(isRemotePeerSession({ amicode_owner: studioTag })).toBe(true)
+ })
+ test("local and unowned sessions are unbadged / not remote", () => {
+ expect(deriveSessionBadge({ amicode_owner: localTag })).toBeUndefined()
+ expect(deriveSessionBadge({})).toBeUndefined()
+ expect(deriveSessionBadge(undefined)).toBeUndefined()
+ expect(isRemotePeerSession({ amicode_owner: localTag })).toBe(false)
+ expect(isRemotePeerSession(undefined)).toBe(false)
+ })
+})
+
+describe("#1525 mergePeerSessions — dedupe (local wins) + sort by last activity", () => {
+ const mk = (id: string, updated: number, owner?: DropdownSession["amicode_owner"]): DropdownSession =>
+ ({ id, directory: "/d", time: { created: 0, updated }, ...(owner ? { amicode_owner: owner } : {}) }) as DropdownSession
+
+ test("peers append to local, sorted by updated desc", () => {
+ const local = [mk("a", 5), mk("b", 1)]
+ const peers = [mk("c", 9, studioTag), mk("d", 3, studioTag)]
+ expect(mergePeerSessions(local, peers).map((s) => s.id)).toEqual(["c", "a", "d", "b"])
+ })
+
+ test("id collision: the local row wins but is enriched with the peer's owner tag", () => {
+ const local = [mk("dup", 5)]
+ const peers = [mk("dup", 99, studioTag)]
+ const out = mergePeerSessions(local, peers)
+ expect(out).toHaveLength(1)
+ // The local row stays (its id, its time — live state), but #1599 enriches
+ // it with the peer's owner overlay so the machine badge survives dedup.
+ expect(out[0].id).toBe("dup")
+ expect(out[0].time.updated).toBe(5) // local row's data, not the peer's
+ expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "test-desktop", is_local: false })
+ })
+
+ test("id collision: an already-tagged local row keeps its OWN owner tag (no clobber)", () => {
+ const laptop = { owner_machine_id: "other", owner_name: "Other", is_local: false }
+ const local = [mk("dup", 5, laptop)]
+ const peers = [mk("dup", 99, studioTag)]
+ const out = mergePeerSessions(local, peers)
+ expect(out).toHaveLength(1)
+ expect(out[0].amicode_owner).toMatchObject({ owner_machine_id: "other" })
+ })
+
+ test("empty peers → local list unchanged (degrade path)", () => {
+ const local = [mk("a", 5), mk("b", 9)]
+ expect(mergePeerSessions(local, []).map((s) => s.id)).toEqual(["b", "a"])
+ })
+
+ // #1599 regression: opening a remote session pulls an UNTAGGED copy of it
+ // into the local directory store (the intended open flow — openSession calls
+ // projects.open + session.sync to render it). On the next dropdown compute,
+ // mergePeerSessions sees the local (untagged) copy AND the projection
+ // (tagged) copy for the same id. Previously local won outright and the badge
+ // vanished the moment the remote session went live. The fix enriches the
+ // local copy with the projection's owner tag so the badge persists.
+ test("#1599 regression: opening a remote session keeps its badge (local copy enriched from projection)", () => {
+ const liveLocalCopy = mk("ses_remote", 5) // untagged — pulled in by opening it
+ const fromProjection = mk("ses_remote", 5, studioTag) // authoritative owner overlay
+ const out = mergePeerSessions([liveLocalCopy], [fromProjection])
+ expect(out).toHaveLength(1)
+ // Badge survives — this is the corrected behavior (was `undefined` pre-#1599).
+ expect(out[0].amicode_owner).toBeDefined()
+ expect(deriveSessionBadge(out[0])).toBe("Test Desktop")
+ })
+
+ // #1646 regression: the SAME open flow that pulls an untagged local copy also
+ // strips the fleet CONTROL overlay — the local copy has no control channel of
+ // its own. Enabling control flips the PROJECTION's copy to `interactive`, but
+ // pre-#1646 the merge kept the control-less local copy, so the row read
+ // fail-closed and archive/delete refused with "Control not enabled" even
+ // though control was held. The fix grafts the peer's control overlay across.
+ test("#1646 regression: an enabled control grant on the projection reaches the merged row", () => {
+ const liveLocalCopy = mk("ses_remote", 5) // untagged, control-less — pulled in by opening it
+ const fromProjection = {
+ ...mk("ses_remote", 5, studioTag),
+ amicode_control: { controlState: "interactive", reason: null, eligibility: "none" },
+ } as DropdownSession
+ const out = mergePeerSessions([liveLocalCopy], [fromProjection])
+ expect(out).toHaveLength(1)
+ // Control is HELD on the merged row (was fail-closed `read-only` pre-#1646),
+ // so writeAffordanceEnabled — the archive/delete gate — is true.
+ expect(readSessionControl(out[0]).controlState).toBe("interactive")
+ expect(writeAffordanceEnabled(readSessionControl(out[0]))).toBe(true)
+ })
+
+ test("#1646: a revoked/read-only grant on the projection also reaches the merged row (not just interactive)", () => {
+ const liveLocalCopy = mk("ses_remote", 5)
+ const fromProjection = {
+ ...mk("ses_remote", 5, studioTag),
+ amicode_control: { controlState: "read-only", reason: "grant-revoked", eligibility: "enable-control" },
+ } as DropdownSession
+ const out = mergePeerSessions([liveLocalCopy], [fromProjection])
+ // The projection is SoT both ways: a revocation must reach the row too.
+ expect(readSessionControl(out[0]).reason).toBe("grant-revoked")
+ expect(writeAffordanceEnabled(readSessionControl(out[0]))).toBe(false)
+ })
+
+ test("#1539 fixed: when directory store is clean, projection badge persists through dedup", () => {
+ // After the fix: the remote session is ONLY in the projection (with badge),
+ // never contaminated into the local store. The dropdown merges correctly.
+ const localSessions = [mk("ses_local", 5)]
+ const projectionPeers = [mk("ses_remote", 3, studioTag)]
+ const out = mergePeerSessions(localSessions, projectionPeers)
+ expect(out).toHaveLength(2)
+ const remote = out.find((s) => s.id === "ses_remote")!
+ expect(remote.amicode_owner).toBeDefined()
+ expect(remote.amicode_owner!.is_local).toBe(false)
+ expect(deriveSessionBadge(remote)).toBe("Test Desktop")
+ })
+})
+
+// #1537 B2a (AC4): owner-routed OPEN. `resolveDropdownOpenAction` is the pure
+// decision the dropdown's openSession() dispatches on. A REMOTE peer row must
+// resolve to the owner-routed navigate (the multiplex routes reads by owner) —
+// NOT the B1 "lives on " guard toast. A LOCAL row is byte-unchanged
+// from B1: existing tab → select-tab; no tab → navigate. No branch produces a
+// mutation/remote-write action — open is a read.
+describe("#1537 resolveDropdownOpenAction — owner-routed open of a peer row", () => {
+ const encodePath = (dir: string, id: string) => `/${btoa(dir)}/session/${id}`
+ const studioTag = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", is_local: false }
+
+ const localRow: DropdownSession = { id: "ses_local", directory: "/proj", time: { created: 1 } } as DropdownSession
+ const remoteRow: DropdownSession = {
+ id: "ses_studio",
+ directory: "/studio-proj",
+ time: { created: 2 },
+ amicode_owner: studioTag,
+ } as DropdownSession
+
+ test("REMOTE row → owner-routed navigate to its raw directory/id (never a toast)", () => {
+ const action = resolveDropdownOpenAction(remoteRow, false, encodePath)
+ expect(action.type).toBe("navigate")
+ expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/studio-proj")}/session/ses_studio`)
+ })
+
+ test("REMOTE row with an already-open tab → select that tab (no re-navigate)", () => {
+ const action = resolveDropdownOpenAction(remoteRow, true, encodePath)
+ expect(action.type).toBe("select-tab")
+ expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_studio")
+ })
+
+ test("LOCAL row without a tab → navigate (byte-unchanged from B1)", () => {
+ const action = resolveDropdownOpenAction(localRow, false, encodePath)
+ expect(action.type).toBe("navigate")
+ expect((action as { type: "navigate"; path: string }).path).toBe(`/${btoa("/proj")}/session/ses_local`)
+ })
+
+ test("LOCAL row with an existing tab → select-tab (byte-unchanged from B1)", () => {
+ const action = resolveDropdownOpenAction(localRow, true, encodePath)
+ expect(action.type).toBe("select-tab")
+ expect((action as { type: "select-tab"; sessionId: string }).sessionId).toBe("ses_local")
+ })
+
+ test("no branch yields a remote-write action — the union is only navigate | select-tab", () => {
+ for (const row of [localRow, remoteRow]) {
+ for (const hasTab of [true, false]) {
+ expect(["navigate", "select-tab"]).toContain(resolveDropdownOpenAction(row, hasTab, encodePath).type)
+ }
+ }
+ })
+})
+
+// ── #1544 (slice 4): the app-side control surface ─────────────────────────────
+// The state channel `amicode_control` ({ controlState, reason, eligibility })
+// rides the fleet projection beside `amicode_owner` (SoT: the extension's
+// remote_session_state). These pure helpers are the data layer the session
+// surface consumes: the fail-closed chip, the enable/request affordance, the
+// persistent driving banner, and the owner-routed remote-delete gate. Every
+// reader is tolerant (defaults to `local`/no-affordance on garbage).
+const ctrl = (over: Partial = {}): SessionControlProjection => ({
+ controlState: "read-only",
+ reason: "no-control-grant",
+ eligibility: "enable-control",
+ ...over,
+})
+const remoteWith = (control: SessionControlProjection, machineId = "test-desktop"): DropdownSession =>
+ ({
+ id: "ses_studio",
+ directory: "/studio-proj",
+ time: { created: 1 },
+ amicode_owner: { owner_machine_id: machineId, owner_name: "Studio", is_local: false },
+ amicode_control: control,
+ }) as unknown as DropdownSession
+
+describe("#1544 readSessionControl — tolerant read of amicode_control", () => {
+ test("absent / malformed on a LOCAL / unowned session → the local no-affordance default (never throws)", () => {
+ expect(readSessionControl(undefined)).toEqual({ controlState: "local", reason: null, eligibility: "none" })
+ expect(readSessionControl({} as DropdownSession)).toEqual({ controlState: "local", reason: null, eligibility: "none" })
+ expect(readSessionControl({ amicode_control: { nope: 1 } } as unknown as DropdownSession).controlState).toBe("local")
+ })
+ test("absent / malformed on a REMOTE peer session → FAIL-CLOSED (amicode#1544 follow-up)", () => {
+ // A remote row with no control channel must NOT read as held — the server
+ // write gate denies with no-control-grant, so the client must agree (no
+ // silently-failing delete button; a "Control not enabled" chip instead).
+ const remoteOwner = { owner_machine_id: "studio", owner_name: "Studio", is_local: false as const }
+ const missing = readSessionControl({ amicode_owner: remoteOwner } as unknown as DropdownSession)
+ expect(missing).toEqual({ controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" })
+ expect(writeAffordanceEnabled(missing)).toBe(false)
+ const malformed = readSessionControl({
+ amicode_owner: remoteOwner,
+ amicode_control: { nope: 1 },
+ } as unknown as DropdownSession)
+ expect(malformed.controlState).toBe("read-only")
+ expect(writeAffordanceEnabled(malformed)).toBe(false)
+ })
+ test("a well-formed projection round-trips", () => {
+ expect(readSessionControl(remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })))).toEqual({
+ controlState: "interactive",
+ reason: null,
+ eligibility: "none",
+ })
+ })
+})
+
+// ── sortDropdownSessions (#1599) ──────────────────────────────────────────────
+
+describe("sortDropdownSessions", () => {
+ // helper: create a minimal DropdownSession fixture
+ function mk(
+ id: string,
+ title: string,
+ created: number,
+ updated?: number,
+ ownerTag?: { owner_machine_id: string; owner_name: string; is_local: boolean },
+ ): DropdownSession {
+ return {
+ id,
+ title,
+ directory: "/p",
+ time: { created, ...(updated !== undefined ? { updated } : {}) },
+ ...(ownerTag ? { amicode_owner: ownerTag } : {}),
+ } as DropdownSession
+ }
+
+ const local = (id: string, title: string, created: number, updated?: number) =>
+ mk(id, title, created, updated)
+ const remote = (id: string, title: string, created: number, owner: string, updated?: number) =>
+ mk(id, title, created, updated, { owner_machine_id: owner, owner_name: owner, is_local: false })
+
+ describe("recent mode", () => {
+ test("sorts by updated desc, falls back to created when no updated", () => {
+ const sessions = [local("a", "A", 100), local("b", "B", 300), local("c", "C", 200)]
+ const result = sortDropdownSessions(sessions, "recent")
+ expect(result.map((s) => s.id)).toEqual(["b", "c", "a"])
+ })
+
+ test("prefers updated over created when both present", () => {
+ const sessions = [local("a", "A", 100, 500), local("b", "B", 600, 200)]
+ const result = sortDropdownSessions(sessions, "recent")
+ expect(result.map((s) => s.id)).toEqual(["a", "b"])
+ })
+
+ test("tie-breaks by id ascending", () => {
+ const sessions = [local("z", "Z", 100), local("a", "A", 100)]
+ const result = sortDropdownSessions(sessions, "recent")
+ expect(result.map((s) => s.id)).toEqual(["a", "z"])
+ })
+ })
+
+ describe("alpha mode", () => {
+ test("sorts by title case-insensitive ascending", () => {
+ const sessions = [local("1", "Zebra", 300), local("2", "alpha", 100), local("3", "Middle", 200)]
+ const result = sortDropdownSessions(sessions, "alpha")
+ expect(result.map((s) => s.id)).toEqual(["2", "3", "1"])
+ })
+
+ test("tie-breaks by recency desc", () => {
+ const sessions = [local("a", "Same", 100), local("b", "Same", 300)]
+ const result = sortDropdownSessions(sessions, "alpha")
+ expect(result.map((s) => s.id)).toEqual(["b", "a"])
+ })
+
+ test("falls back to id when title is missing", () => {
+ const noTitle = { id: "zzz", directory: "/p", time: { created: 100 } } as DropdownSession
+ const withTitle = local("aaa", "Beta", 200)
+ const result = sortDropdownSessions([noTitle, withTitle], "alpha")
+ // "Beta" < "zzz"
+ expect(result.map((s) => s.id)).toEqual(["aaa", "zzz"])
+ })
+ })
+
+ describe("machine mode", () => {
+ test("local/unowned sessions come first, then by owner_name asc", () => {
+ const sessions = [
+ remote("r1", "R1", 300, "Studio"),
+ local("l1", "L1", 100),
+ remote("r2", "R2", 200, "Box"),
+ ]
+ const result = sortDropdownSessions(sessions, "machine")
+ expect(result.map((s) => s.id)).toEqual(["l1", "r2", "r1"])
+ })
+
+ test("tie-breaks by recency within the same machine", () => {
+ const sessions = [
+ remote("r1", "R1", 100, "Box"),
+ remote("r2", "R2", 300, "Box"),
+ ]
+ const result = sortDropdownSessions(sessions, "machine")
+ expect(result.map((s) => s.id)).toEqual(["r2", "r1"])
+ })
+
+ test("sessions with is_local=true are treated as local", () => {
+ const localTagged = mk("l", "L", 100, undefined, { owner_machine_id: "me", owner_name: "Me", is_local: true })
+ const remoteTagged = remote("r", "R", 200, "Peer")
+ const result = sortDropdownSessions([remoteTagged, localTagged], "machine")
+ expect(result[0].id).toBe("l")
+ expect(result[1].id).toBe("r")
+ })
+ })
+
+ test("never mutates the input array", () => {
+ const sessions = [local("b", "B", 100), local("a", "A", 200)]
+ const original = [...sessions]
+ sortDropdownSessions(sessions, "alpha")
+ expect(sessions.map((s) => s.id)).toEqual(original.map((s) => s.id))
+ })
+
+ test("returns empty array for empty input", () => {
+ expect(sortDropdownSessions([], "recent")).toEqual([])
+ expect(sortDropdownSessions([], "alpha")).toEqual([])
+ expect(sortDropdownSessions([], "machine")).toEqual([])
+ })
+})
+
+describe("#1544 write affordances gated on control held", () => {
+ test("local + interactive → control held → writes enabled", () => {
+ expect(isControlHeld(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBe(true)
+ expect(isControlHeld(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true)
+ expect(writeAffordanceEnabled(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBe(true)
+ })
+ test("read-only + suspended → control NOT held → writes disabled", () => {
+ expect(isControlHeld(ctrl({ controlState: "read-only" }))).toBe(false)
+ expect(isControlHeld(ctrl({ controlState: "suspended", reason: "transport-down" }))).toBe(false)
+ expect(writeAffordanceEnabled(ctrl({ controlState: "read-only" }))).toBe(false)
+ })
+})
+
+describe("#1544 failClosedChip — disabled-with-reason, derived from the SoT reason (never the collapsed gate reason)", () => {
+ test("null (no chip) when control is held (local / interactive)", () => {
+ expect(failClosedChip(ctrl({ controlState: "local", reason: null, eligibility: "none" }))).toBeNull()
+ expect(failClosedChip(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }))).toBeNull()
+ })
+ test("EVERY one of the five SoT reasons yields a distinct, human chip label", () => {
+ const labels = new Set()
+ for (const reason of CONTROL_CHIP_REASONS) {
+ const controlState = reason === "transport-down" || reason === "revocation-pending" ? "suspended" : "read-only"
+ const chip = failClosedChip(ctrl({ controlState, reason }))
+ expect(chip, `reason ${reason} must produce a chip`).not.toBeNull()
+ expect(chip!.reason).toBe(reason)
+ expect(typeof chip!.label).toBe("string")
+ expect(chip!.label.length).toBeGreaterThan(0)
+ labels.add(chip!.label)
+ }
+ // revocation-pending and grant-revoked must NOT share a label (the SoT keeps
+ // them distinct where the write gate collapses them).
+ expect(failClosedChip(ctrl({ controlState: "suspended", reason: "revocation-pending" }))!.label).not.toBe(
+ failClosedChip(ctrl({ controlState: "read-only", reason: "grant-revoked" }))!.label,
+ )
+ expect(labels.size).toBe(CONTROL_CHIP_REASONS.length)
+ })
+})
+
+describe("#1544 controlAffordance — enable (self) / request (shared) / none", () => {
+ test("eligibility enable-control → an Enable affordance, live (not inert)", () => {
+ const a = controlAffordance(ctrl({ eligibility: "enable-control" }))
+ expect(a.kind).toBe("enable-control")
+ expect(a.inert).toBe(false)
+ expect(a.label.length).toBeGreaterThan(0)
+ })
+ test("eligibility request-control → a Request affordance, INERT (backend is #1545)", () => {
+ const a = controlAffordance(ctrl({ eligibility: "request-control" }))
+ expect(a.kind).toBe("request-control")
+ expect(a.inert).toBe(true)
+ })
+ test("eligibility none → no affordance", () => {
+ expect(controlAffordance(ctrl({ controlState: "interactive", reason: null, eligibility: "none" })).kind).toBe("none")
+ })
+})
+
+describe("#1544 drivingBanner — persistent, pinned to the peer being driven", () => {
+ test("interactive (control held over a remote peer) → banner carries the peer machineId", () => {
+ const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "test-desktop")
+ expect(drivingBanner(session)).toEqual({ machineId: "test-desktop" })
+ })
+ test("not interactive (read-only / local) → no banner", () => {
+ expect(drivingBanner(remoteWith(ctrl({ controlState: "read-only" })))).toBeNull()
+ const local = { id: "l", directory: "/d", time: { created: 1 } } as DropdownSession
+ expect(drivingBanner(local)).toBeNull()
+ })
+})
+
+describe("#1544 remoteDeleteAction — owner-routed delete, gated on control (arm→confirm reused, not this gate)", () => {
+ test("control held → allowed + an OWNER-ROUTED request carrying the owner machineId", () => {
+ const session = remoteWith(ctrl({ controlState: "interactive", reason: null, eligibility: "none" }), "test-desktop")
+ const action = remoteDeleteAction(session)
+ expect(action.allowed).toBe(true)
+ expect(action.request).toEqual({ sessionID: "ses_studio", directory: "/studio-proj", ownerMachineId: "test-desktop" })
+ })
+ test("control NOT held → disallowed, no request, carries the fail-closed reason (never a live erroring button)", () => {
+ const session = remoteWith(ctrl({ controlState: "read-only", reason: "no-control-grant" }))
+ const action = remoteDeleteAction(session)
+ expect(action.allowed).toBe(false)
+ expect(action.request).toBeUndefined()
+ expect(action.reason).toBe("no-control-grant")
+ })
+})
+
+describe("#1544 findSessionControlInProjection — the current session's control off the fleet projection", () => {
+ const raw = {
+ sessions: [
+ { id: "ses_local", time: { created: 1 }, amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true }, amicode_control: { controlState: "local", reason: null, eligibility: "none" } },
+ { id: "ses_studio", time: { created: 2 }, amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" } },
+ ],
+ }
+ test("finds a remote entry's control by id", () => {
+ expect(findSessionControlInProjection(raw, "ses_studio")).toEqual({
+ controlState: "read-only",
+ reason: "no-control-grant",
+ eligibility: "enable-control",
+ })
+ })
+ test("unknown id / garbage → the local default (never throws)", () => {
+ expect(findSessionControlInProjection(raw, "nope")).toEqual({ controlState: "local", reason: null, eligibility: "none" })
+ expect(findSessionControlInProjection(undefined, "x")).toEqual({ controlState: "local", reason: null, eligibility: "none" })
+ })
+
+ test("drivingBannerFromProjection lights the banner for a driven (interactive) current session", () => {
+ const driving = {
+ sessions: [
+ { id: "ses_studio", amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false }, amicode_control: { controlState: "interactive", reason: null, eligibility: "none" } },
+ ],
+ }
+ expect(drivingBannerFromProjection(driving, "ses_studio")).toEqual({ machineId: "studio" })
+ // read-only current session → no banner; unknown id → no banner
+ expect(drivingBannerFromProjection(raw, "ses_studio")).toBeNull()
+ expect(drivingBannerFromProjection(driving, "nope")).toBeNull()
+ expect(drivingBannerFromProjection(undefined, "x")).toBeNull()
+ })
+})
+
+// ── #1562-followup (Slice C): GROUP the dropdown by owner machine ─────────────
+// The dropdown merged peer + local sessions and sorted by recency, so a single
+// Studio session sat at the bottom under ~100 local ones — effectively
+// invisible. The projection data is correct; this is PRESENTATION. groupSessions-
+// ByOwner is the pure decision: the LOCAL / unowned rows first (one unlabeled
+// group, order preserved), then ONE labeled group per peer machine (grouped by
+// owner_machine_id, labeled with the owner name), in first-seen order. Read-only
+// — no row is dropped or reordered within a group.
+describe("#1562 groupSessionsByOwner — local first, then a labeled group per peer machine", () => {
+ const laptopTag = { owner_machine_id: "test-laptop", owner_name: "MacBook Pro", is_local: true }
+ const studio = { owner_machine_id: "test-desktop", owner_name: "Test Desktop", is_local: false }
+ const tower = { owner_machine_id: "lab-tower", owner_name: "Lab Tower", is_local: false }
+ const mk = (id: string, owner?: DropdownSession["amicode_owner"]): DropdownSession =>
+ ({ id, directory: "/d", time: { created: 0, updated: 0 }, ...(owner ? { amicode_owner: owner } : {}) }) as DropdownSession
+
+ test("local + unowned first (one UNLABELED group), then one LABELED group per peer machine", () => {
+ const groups: SessionGroup[] = groupSessionsByOwner([
+ mk("a"),
+ mk("b", laptopTag),
+ mk("s1", studio),
+ mk("c"),
+ mk("s2", studio),
+ mk("t1", tower),
+ ])
+ expect(groups).toHaveLength(3)
+ // group 0 = local / unowned, no machineId, no label, input order preserved
+ expect(groups[0].machineId).toBeNull()
+ expect(groups[0].label).toBeUndefined()
+ expect(groups[0].sessions.map((s) => s.id)).toEqual(["a", "b", "c"])
+ // then one labeled group per peer machine, FIRST-SEEN order (studio before tower)
+ expect(groups[1].machineId).toBe("test-desktop")
+ expect(groups[1].label).toBe("Test Desktop")
+ expect(groups[1].sessions.map((s) => s.id)).toEqual(["s1", "s2"])
+ expect(groups[2].machineId).toBe("lab-tower")
+ expect(groups[2].label).toBe("Lab Tower")
+ expect(groups[2].sessions.map((s) => s.id)).toEqual(["t1"])
+ })
+
+ test("empty peers → JUST the local list (one group, machineId null)", () => {
+ const groups = groupSessionsByOwner([mk("a"), mk("b", laptopTag)])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].machineId).toBeNull()
+ expect(groups[0].sessions.map((s) => s.id)).toEqual(["a", "b"])
+ })
+
+ test("empty input → a single empty local group (never blank / never throws)", () => {
+ const groups = groupSessionsByOwner([])
+ expect(groups).toHaveLength(1)
+ expect(groups[0].machineId).toBeNull()
+ expect(groups[0].sessions).toEqual([])
+ })
+
+ test("peer order is FIRST-SEEN; rows within a peer group keep input (recency) order", () => {
+ const groups = groupSessionsByOwner([mk("t1", tower), mk("s1", studio), mk("t2", tower)])
+ expect(groups.map((g) => g.machineId)).toEqual([null, "lab-tower", "test-desktop"])
+ expect(groups[1].sessions.map((s) => s.id)).toEqual(["t1", "t2"])
+ expect(groups[2].sessions.map((s) => s.id)).toEqual(["s1"])
+ })
+
+ test("only-peer input → an empty local group followed by the labeled peer group(s)", () => {
+ const groups = groupSessionsByOwner([mk("s1", studio)])
+ expect(groups).toHaveLength(2)
+ expect(groups[0].machineId).toBeNull()
+ expect(groups[0].sessions).toEqual([])
+ expect(groups[1].machineId).toBe("test-desktop")
+ expect(groups[1].sessions.map((s) => s.id)).toEqual(["s1"])
+ })
+})
+
+// #1599: the dropdown now renders a FLAT list with per-row machine badges
+// (no per-machine group headers). Source-assertion confirms the old grouping
+// code was removed and the sort utility is wired in.
+describe("#1599 the dropdown renders a flat sorted list (no machine group headers)", () => {
+ const headerSource = readFileSync(resolve(__dirname, "session-header.tsx"), "utf8")
+ test("the dropdown uses sortDropdownSessions and does NOT group by owner", () => {
+ expect(headerSource).toContain("sortDropdownSessions(")
+ expect(headerSource).not.toContain("groupSessionsByOwner(")
+ expect(headerSource).not.toContain('data-slot="session-group-label"')
+ })
+})
+
+// ── #1568: drivenByBanner — presence indicator for locally-owned sessions
+// being remotely controlled. The REVERSE of drivingBanner: a local session
+// whose projection entry carries `amicode_controlled_by` shows who is driving.
+describe("#1568 drivenByBanner — presence indicator for a locally-controlled session", () => {
+ test("local session with amicode_controlled_by → banner carries the controller machine info", () => {
+ const session = {
+ id: "ses_local",
+ directory: "/proj",
+ time: { created: 1 },
+ amicode_owner: localTag,
+ amicode_controlled_by: { machine_id: "peer-studio", machine_name: "Mac Studio" },
+ } as DropdownSession
+ expect(drivenByBanner(session)).toEqual({ machineId: "peer-studio", machineName: "Mac Studio" })
+ })
+
+ test("local session without amicode_controlled_by → no banner", () => {
+ const session = {
+ id: "ses_local",
+ directory: "/proj",
+ time: { created: 1 },
+ amicode_owner: localTag,
+ } as DropdownSession
+ expect(drivenByBanner(session)).toBeNull()
+ })
+
+ test("remote session with amicode_controlled_by → no banner (only local sessions show it)", () => {
+ const session = {
+ id: "ses_remote",
+ directory: "/proj",
+ time: { created: 1 },
+ amicode_owner: studioTag,
+ amicode_controlled_by: { machine_id: "peer-x", machine_name: "X" },
+ } as DropdownSession
+ expect(drivenByBanner(session)).toBeNull()
+ })
+
+ test("undefined / garbage → no banner (tolerant, never throws)", () => {
+ expect(drivenByBanner(undefined)).toBeNull()
+ expect(drivenByBanner({} as DropdownSession)).toBeNull()
+ })
+
+ test("drivenByBannerFromProjection reads the overlay off the fleet projection by session id", () => {
+ const raw = {
+ sessions: [
+ {
+ id: "ses_local",
+ time: { created: 1 },
+ amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true },
+ amicode_controlled_by: { machine_id: "studio", machine_name: "Mac Studio" },
+ },
+ {
+ id: "ses_no_control",
+ time: { created: 2 },
+ amicode_owner: { owner_machine_id: "me", owner_name: "Me", is_local: true },
+ },
+ ],
+ }
+ expect(drivenByBannerFromProjection(raw, "ses_local")).toEqual({ machineId: "studio", machineName: "Mac Studio" })
+ expect(drivenByBannerFromProjection(raw, "ses_no_control")).toBeNull()
+ expect(drivenByBannerFromProjection(raw, "unknown")).toBeNull()
+ expect(drivenByBannerFromProjection(undefined, "x")).toBeNull()
+ })
+})
+
+// The Studio dropdown must show LOCAL sessions too — including local sessions in
+// directories the local store never iterates (a remote-created session that
+// landed in the owner's ambient temp cwd, or any project not in projects.list()).
+// peerSessionsFromProjection drops all is_local rows on the assumption "the local
+// list already carries those" — false for an unlisted-directory session, so such a
+// session was invisible on the owning machine's dropdown. allSessionsFromProjection
+// keeps local AND remote rows; mergePeerSessions then dedupes (local store wins),
+// so listed-dir sessions are unchanged and only the missing ones are surfaced.
+describe("allSessionsFromProjection — local + remote (the Studio-dropdown fix)", () => {
+ test("keeps BOTH local and remote rows (unlike peerSessionsFromProjection)", () => {
+ const raw = projection([
+ { id: "ses_local_temp", time: { created: 2 }, title: "Testing greeting", directory: "/var/folders/T/engine-x", amicode_owner: localTag },
+ { id: "ses_remote", time: { created: 1 }, title: "Peer one", amicode_owner: studioTag },
+ ])
+ const out = allSessionsFromProjection(raw)
+ const ids = out.map((s) => s.id).sort()
+ expect(ids).toEqual(["ses_local_temp", "ses_remote"])
+ })
+
+ test("surfaces a local temp-dir session the local store list is missing", () => {
+ // The local store list only has sessions from listed project dirs.
+ const localList: DropdownSession[] = [
+ { id: "ses_in_project", time: { created: 5 }, directory: "/Users/jj/harmoniqs/amicode" } as DropdownSession,
+ ]
+ // The projection (cross-project) carries that one PLUS the temp-dir local one.
+ const raw = projection([
+ { id: "ses_in_project", time: { created: 5 }, directory: "/Users/jj/harmoniqs/amicode", amicode_owner: localTag },
+ { id: "ses_local_temp", time: { created: 9 }, title: "Testing greeting", directory: "/var/folders/T/engine-x", amicode_owner: localTag },
+ ])
+ const merged = mergePeerSessions(localList, allSessionsFromProjection(raw))
+ expect(merged.map((s) => s.id)).toContain("ses_local_temp")
+ })
+
+ test("tolerant: garbage/empty ⇒ []", () => {
+ expect(allSessionsFromProjection(undefined)).toEqual([])
+ expect(allSessionsFromProjection({})).toEqual([])
+ expect(allSessionsFromProjection({ sessions: "nope" })).toEqual([])
+ })
+
+ // #archive-lag follow-up: the dropdown is a ROOT-session list. The fleet
+ // projection carries subagent CHILD sessions (parentID set — @explore /
+ // @implementer / critic sub-sessions); without filtering them they flooded the
+ // dropdown (76 of 100 sessions were children) and bloated every merge/render.
+ // Mirror the local path's roots() filter.
+ test("drops CHILD sessions (parentID set) — mirrors roots()", () => {
+ const raw = projection([
+ { id: "ses_root", time: { created: 2 }, title: "Root", directory: "/p", amicode_owner: localTag },
+ { id: "ses_child", time: { created: 3 }, title: "Lane A (@explore subagent)", directory: "/p", parentID: "ses_root", amicode_owner: localTag },
+ { id: "ses_remote_child", time: { created: 4 }, title: "slice (@implementer)", parentID: "ses_x", amicode_owner: studioTag },
+ ])
+ const ids = allSessionsFromProjection(raw).map((s) => s.id)
+ expect(ids).toEqual(["ses_root"])
+ expect(ids).not.toContain("ses_child")
+ expect(ids).not.toContain("ses_remote_child")
+ })
+})
+
+// #archive-lag ROOT CAUSE (measured): the dropdown merges the CACHED fleet
+// projection, which lists LOCAL sessions too (allSessionsFromProjection). Once a
+// local archive removes the row from the local store, mergePeerSessions re-fills
+// it from the stale projection until the next 3s poll lands — the "couple of
+// seconds" lag. Archive tombstones hide the id immediately and retire only once a
+// FRESH projection stops listing it (or on unarchive / failure / TTL).
+describe("#archive-lag archive tombstones — hide immediately, settle on a fresh projection", () => {
+ const row = (id: string, created: number, extra: Partial = {}): DropdownSession =>
+ ({ id, title: id, directory: "/d", time: { created }, ...extra }) as DropdownSession
+
+ test("a tombstoned id is hidden even when the stale projection re-surfaces it", () => {
+ // The local store has ALREADY dropped ses_archived (splice); the cached
+ // projection still lists it as active, so the merge alone resurrects it.
+ const local = [row("ses_keep", 2)]
+ const staleProjection = projection([
+ { id: "ses_keep", time: { created: 2 }, directory: "/d", amicode_owner: localTag },
+ { id: "ses_archived", time: { created: 3 }, directory: "/d", amicode_owner: localTag },
+ ])
+ const merged = mergePeerSessions(local, allSessionsFromProjection(staleProjection))
+ expect(merged.map((s) => s.id)).toContain("ses_archived") // the bug, reproduced
+ const shown = hideArchivedTombstones(merged, new Set(["ses_archived"]))
+ expect(shown.map((s) => s.id)).toEqual(["ses_keep"])
+ })
+
+ test("hides REMOTE rows too (one optimistic path for local + remote)", () => {
+ const merged = [row("ses_local", 2), row("ses_peer", 1, { amicode_owner: studioTag })]
+ expect(hideArchivedTombstones(merged, new Set(["ses_peer"])).map((s) => s.id)).toEqual(["ses_local"])
+ })
+
+ test("no tombstones ⇒ the SAME array (no needless downstream re-render)", () => {
+ const merged = [row("ses_a", 1)]
+ expect(hideArchivedTombstones(merged, new Set())).toBe(merged)
+ })
+
+ test("a fresh projection that no longer lists the id retires its tombstone", () => {
+ const fresh = projection([{ id: "ses_keep", time: { created: 2 }, amicode_owner: localTag }])
+ const next = settleArchivedTombstones(new Set(["ses_archived"]), fresh)
+ expect([...next]).toEqual([])
+ })
+
+ test("a STALE projection (in flight across the PATCH) still listing the id keeps it hidden", () => {
+ const tombstones = new Set(["ses_archived", "ses_other"])
+ const stale = projection([
+ { id: "ses_archived", time: { created: 3 }, amicode_owner: localTag },
+ { id: "ses_other", time: { created: 4 }, amicode_owner: studioTag },
+ ])
+ // nothing settled ⇒ the SAME set back (a signal write is a no-op)
+ expect(settleArchivedTombstones(tombstones, stale)).toBe(tombstones)
+ })
+
+ test("settles only the confirmed ids, keeps the rest", () => {
+ const fresh = projection([{ id: "ses_still_listed", time: { created: 1 }, amicode_owner: localTag }])
+ const next = settleArchivedTombstones(new Set(["ses_gone", "ses_still_listed"]), fresh)
+ expect([...next]).toEqual(["ses_still_listed"])
+ })
+
+ test("absent / errored / garbage projection settles NOTHING (keep hiding; TTL backstops)", () => {
+ const tombstones = new Set(["ses_archived"])
+ expect(settleArchivedTombstones(tombstones, undefined)).toBe(tombstones)
+ expect(settleArchivedTombstones(tombstones, {})).toBe(tombstones)
+ expect(settleArchivedTombstones(tombstones, { sessions: "nope" })).toBe(tombstones)
+ })
+})
+
+// amicode#1652: the identity cache that stops remounting (and flashing) a
+// tagged row on every 3s fleet poll. `mergePeerSessions` mints new objects each
+// poll; `stabilizeSessionIdentity` returns the PREVIOUS object when a row's
+// render signature is unchanged, so the reference (and its DOM node) is stable.
+describe("stabilizeSessionIdentity — stable row references across polls (#1652)", () => {
+ const row = (id: string, updated: number, extra: Partial = {}): DropdownSession =>
+ ({ id, title: id, directory: "/d", time: { created: 0, updated }, ...extra }) as DropdownSession
+
+ test("an unchanged row keeps the SAME object reference across two emits", () => {
+ const cache = new Map()
+ const first = [row("ses_a", 1, { amicode_owner: studioTag })]
+ const out1 = stabilizeSessionIdentity(first, cache)
+ // A fresh object with identical render-relevant content (as mergePeerSessions
+ // would mint on the next poll) — different reference, same signature.
+ const second = [row("ses_a", 1, { amicode_owner: { ...studioTag } })]
+ const out2 = stabilizeSessionIdentity(second, cache)
+ expect(out2[0]).toBe(out1[0]) // SAME reference → preserves the DOM node
+ expect(second[0]).not.toBe(out2[0]) // the fresh input object was discarded
+ })
+
+ test("a genuine content change (title/owner/control) allocates a NEW reference", () => {
+ const cache = new Map()
+ const out1 = stabilizeSessionIdentity([row("ses_a", 1, { title: "old" })], cache)
+ const changed = row("ses_a", 1, { title: "new" })
+ const out2 = stabilizeSessionIdentity([changed], cache)
+ expect(out2[0]).toBe(changed) // content changed → the new object is used
+ expect(out2[0]).not.toBe(out1[0])
+ })
+
+ test("time.updated churn alone does NOT re-key (streaming tokens must not remount)", () => {
+ const cache = new Map()
+ const out1 = stabilizeSessionIdentity([row("ses_a", 1)], cache)
+ const out2 = stabilizeSessionIdentity([row("ses_a", 99999)], cache) // only updated moved
+ expect(out2[0]).toBe(out1[0])
+ })
+
+ test("preserves order and evicts ids that leave the list (no unbounded growth)", () => {
+ const cache = new Map()
+ stabilizeSessionIdentity([row("ses_a", 1), row("ses_b", 2), row("ses_c", 3)], cache)
+ expect(cache.size).toBe(3)
+ const out = stabilizeSessionIdentity([row("ses_c", 3), row("ses_a", 1)], cache)
+ expect(out.map((s) => s.id)).toEqual(["ses_c", "ses_a"]) // order follows input
+ expect(cache.has("ses_b")).toBe(false) // evicted
+ expect(cache.size).toBe(2)
+ })
+
+ test("sessionRowSignature ignores time.updated but tracks owner + control", () => {
+ const base = row("ses_a", 1, { amicode_owner: studioTag })
+ expect(sessionRowSignature(base)).toBe(sessionRowSignature(row("ses_a", 12345, { amicode_owner: studioTag })))
+ expect(sessionRowSignature(base)).not.toBe(sessionRowSignature(row("ses_a", 1, { amicode_owner: localTag })))
+ const withControl = row("ses_a", 1, {
+ amicode_owner: studioTag,
+ amicode_control: { controlState: "interactive", reason: null, eligibility: "none" },
+ })
+ expect(sessionRowSignature(withControl)).not.toBe(sessionRowSignature(base))
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts
new file mode 100644
index 000000000..0e645e6f6
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-fleet-peers.ts
@@ -0,0 +1,684 @@
+/**
+ * session-fleet-peers.ts — #1525 B1 (read-only)
+ *
+ * Pure logic for merging PEER sessions from the fleet-wide projection
+ * (GET /amicode/fleet/sessions, W2 #1447) into the titlebar Sessions dropdown.
+ *
+ * B1 scope is READ-ONLY: a peer's sessions APPEAR in the dropdown, badged with
+ * the owner machine name, searchable, and de-duped against the local list.
+ * Owner-routed OPEN and remote control (prompt / archive / delete) are B2 —
+ * this module deliberately carries no control surface. `isRemotePeerSession`
+ * is the predicate the component uses to keep destructive/local-only actions
+ * off a remote row.
+ *
+ * The projection's session entries carry the FULL raw session fields (id,
+ * title, time, directory) PLUS the `amicode_owner` overlay
+ * (merged_projection.ts `tagSessionsWithOwner`), so a remote entry is directly
+ * renderable as a dropdown row. Every reader here is TOLERANT: a malformed /
+ * absent / errored response yields [] and never throws (the dropdown must
+ * degrade to the local list, never blank out).
+ */
+
+import type { Session } from "@opencode-ai/sdk/v2/client"
+
+/** The per-session owner overlay the projection tags each entry with
+ * (mirrors merged_projection.ts `SessionOwnerTag`). */
+export interface SessionOwnerTag {
+ owner_machine_id: string
+ owner_name: string
+ device_type?: string
+ directory?: string
+ is_local: boolean
+}
+
+/** #1568: the presence indicator overlay — which remote machine is controlling
+ * this (local) session. Stamped on LOCAL session entries only. */
+export interface ControlledByTag {
+ machine_id: string
+ machine_name: string
+}
+
+/** A dropdown session row — the SDK session shape plus the optional owner
+ * overlay a peer entry carries. */
+export type DropdownSession = Session & {
+ amicode_owner?: SessionOwnerTag
+ /** #1544 (slice 4): the state channel — the app-visible control shape the
+ * fleet projection stamps beside `amicode_owner` (see SessionControlProjection). */
+ amicode_control?: SessionControlProjection
+ /** #1568: the presence indicator — which remote machine is controlling this
+ * local session, if any. Absent on remote sessions and when no control grant
+ * is active. */
+ amicode_controlled_by?: ControlledByTag
+}
+
+// ── #1544 (slice 4): the CONTROL STATE CHANNEL (mirror of the extension's
+// remote_session_state.SessionControlProjection, carried on GET
+// /amicode/fleet/sessions as the `amicode_control` sibling of `amicode_owner`).
+//
+// This module is the SINGLE consumer of that channel on the session surface:
+// the fail-closed chip, the enable/request affordance, the persistent driving
+// banner, and the owner-routed remote-delete gate all derive from it. The chip
+// reason is the SoT reason (the extension's write gate collapses
+// revocation-pending→grant-revoked; this channel keeps them distinct — ADR 0034
+// D4/D5), so the UI never reads the raw gate reason.
+
+/** EXACTLY the five reasons the SoT emits — mirrors the extension's
+ * CONTROL_CHIP_REASONS (kept in sync as a literal, the same seam-crossing
+ * precedent SessionOwnerTag sets). */
+export const CONTROL_CHIP_REASONS = [
+ "no-control-grant",
+ "grant-revoked",
+ "revocation-pending",
+ "insufficient-scope",
+ "transport-down",
+] as const
+
+export type ControlChipReason = (typeof CONTROL_CHIP_REASONS)[number]
+
+/** Which control affordance should appear. */
+export type ControlEligibility = "enable-control" | "request-control" | "none"
+
+/** The app-visible control shape (mirror of the extension's projection). */
+export interface SessionControlProjection {
+ controlState: "local" | "interactive" | "read-only" | "suspended"
+ reason: ControlChipReason | null
+ eligibility: ControlEligibility
+}
+
+const CONTROL_STATES = new Set(["local", "interactive", "read-only", "suspended"])
+const CONTROL_REASONS = new Set(CONTROL_CHIP_REASONS)
+
+/** The default (control-held, no-affordance) projection — a local/unowned or
+ * malformed session degrades to this, never to a live erroring affordance. */
+const CONTROL_LOCAL_DEFAULT: SessionControlProjection = { controlState: "local", reason: null, eligibility: "none" }
+
+/** Human, DISTINCT chip labels — one per SoT reason. revocation-pending and
+ * grant-revoked are deliberately different (the write gate collapses them; the
+ * chip must not). */
+const CONTROL_CHIP_LABELS: Record = {
+ "no-control-grant": "Control not enabled",
+ "grant-revoked": "Control revoked",
+ "revocation-pending": "Revoking…",
+ "insufficient-scope": "Observe only",
+ "transport-down": "Peer unreachable",
+}
+
+/** The fail-closed default for a REMOTE peer session whose control channel is
+ * absent/malformed. A missing channel must NOT read as "control held" on a
+ * remote row — the server-side write gate (evaluateRemoteWriteGate) denies with
+ * `no-control-grant` in exactly that case, so defaulting the client to
+ * `local` (held) produced a delete button that silently failed against the
+ * owner (amicode#1544 follow-up). Fail-closed keeps the client honest: no
+ * live-erroring write button, a "Control not enabled" chip instead. */
+const CONTROL_REMOTE_FAIL_CLOSED: SessionControlProjection = {
+ controlState: "read-only",
+ reason: "no-control-grant",
+ eligibility: "enable-control",
+}
+
+/** Tolerant read of the control channel off a session. Absent / malformed →
+ * a default that DEPENDS on ownership: a local/unowned session degrades to the
+ * held no-affordance default (you own it); a REMOTE peer session degrades to
+ * fail-closed (you do NOT hold control until the channel says so), matching the
+ * server write gate. Never throws. */
+export function readSessionControl(
+ session: { amicode_control?: unknown; amicode_owner?: SessionOwnerTag } | undefined,
+): SessionControlProjection {
+ const missingDefault = isRemotePeerSession(session) ? CONTROL_REMOTE_FAIL_CLOSED : CONTROL_LOCAL_DEFAULT
+ const raw = session?.amicode_control
+ if (!raw || typeof raw !== "object") return missingDefault
+ const o = raw as Record
+ if (typeof o.controlState !== "string" || !CONTROL_STATES.has(o.controlState)) return missingDefault
+ const reason = typeof o.reason === "string" && CONTROL_REASONS.has(o.reason) ? (o.reason as ControlChipReason) : null
+ const eligibility =
+ o.eligibility === "enable-control" || o.eligibility === "request-control" ? o.eligibility : "none"
+ return { controlState: o.controlState as SessionControlProjection["controlState"], reason, eligibility }
+}
+
+/** Control is HELD when the session is local or interactively controlled. */
+export function isControlHeld(control: SessionControlProjection): boolean {
+ return control.controlState === "local" || control.controlState === "interactive"
+}
+
+/** Write affordances (composer→peer, archive, delete) are enabled ONLY under
+ * held control — otherwise disabled with a reason chip (never a live 500). */
+export function writeAffordanceEnabled(control: SessionControlProjection): boolean {
+ return isControlHeld(control)
+}
+
+/** The composer scrim's GATE decision, pure: a session is gated (composer
+ * blurred + made inert, CTA centered) IFF it is a REMOTE peer session whose
+ * control is NOT held (read-only / suspended). A local / unowned session, or a
+ * remote one already driving (interactive → control held), is NOT gated. This
+ * is the single home for the ungate-on-interactive rule the composer scrim and
+ * its test both read, so a projection that flips to `interactive` provably
+ * clears the scrim. */
+export function isComposerGated(
+ owner: SessionOwnerTag | undefined,
+ control: SessionControlProjection,
+): boolean {
+ return isRemotePeerSession({ amicode_owner: owner }) && !isControlHeld(control)
+}
+
+/** The fail-closed chip: disabled-with-reason when control is not held. Null
+ * when control is held (no chip). The label is derived from the SoT reason. */
+export function failClosedChip(control: SessionControlProjection): { reason: ControlChipReason; label: string } | null {
+ if (isControlHeld(control) || control.reason === null) return null
+ return { reason: control.reason, label: CONTROL_CHIP_LABELS[control.reason] }
+}
+
+/** The enable/request affordance derived from eligibility. `request-control` is
+ * present-but-INERT here — its backend (the request→approve handshake) is
+ * #1545; `enable-control` is live (the self-owned one-act enable). */
+export function controlAffordance(control: SessionControlProjection): {
+ kind: ControlEligibility
+ label: string
+ inert: boolean
+} {
+ if (control.eligibility === "enable-control") return { kind: "enable-control", label: "Enable control", inert: false }
+ if (control.eligibility === "request-control") return { kind: "request-control", label: "Request control", inert: true }
+ return { kind: "none", label: "", inert: true }
+}
+
+/** The persistent driving banner's target: the peer machineId being driven,
+ * or null when not interactively driving a peer. Read from the owner overlay
+ * (the state channel carries no machineId — the owner tag does). */
+export function drivingBanner(
+ session: { amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined,
+): { machineId: string } | null {
+ const control = readSessionControl(session)
+ if (control.controlState !== "interactive") return null
+ const machineId = session?.amicode_owner?.owner_machine_id
+ if (!machineId) return null
+ return { machineId }
+}
+
+/** The owner-routed remote-delete action, GATED on held control. When allowed,
+ * the request carries the owner machineId so the caller (and a reviewer) can
+ * see it is owner-routed — the #1542 write plane resolves the non-GET to the
+ * peer-owned session by pathname; this descriptor names the owner it targets.
+ * When control is not held, it is disallowed and carries the fail-closed reason
+ * (never a live erroring button). */
+export function remoteDeleteAction(
+ session: { id: string; directory?: string; amicode_owner?: SessionOwnerTag; amicode_control?: unknown } | undefined,
+): { allowed: boolean; request?: { sessionID: string; directory: string; ownerMachineId: string }; reason?: ControlChipReason } {
+ const control = readSessionControl(session)
+ if (!writeAffordanceEnabled(control) || !session) {
+ return { allowed: false, ...(control.reason ? { reason: control.reason } : {}) }
+ }
+ const ownerMachineId = session.amicode_owner?.owner_machine_id ?? ""
+ return {
+ allowed: true,
+ request: { sessionID: session.id, directory: session.directory ?? "", ownerMachineId },
+ }
+}
+
+/** Find a session's control off a raw GET /amicode/fleet/sessions response by
+ * id — the session surface's read for the CURRENT session's banner/affordance.
+ * Unknown id / garbage → the local default (never throws). */
+export function findSessionControlInProjection(raw: unknown, sessionId: string): SessionControlProjection {
+ return readSessionControl(findSessionEntryInProjection(raw, sessionId))
+}
+
+/** Find a raw projection entry by id (owner + control overlays intact), or
+ * undefined. Tolerant. */
+function findSessionEntryInProjection(
+ raw: unknown,
+ sessionId: string,
+): { amicode_owner?: SessionOwnerTag; amicode_control?: unknown; amicode_controlled_by?: unknown } | undefined {
+ if (!raw || typeof raw !== "object") return undefined
+ const sessions = (raw as { sessions?: unknown }).sessions
+ if (!Array.isArray(sessions)) return undefined
+ const hit = sessions.find((s) => s && typeof s === "object" && (s as { id?: unknown }).id === sessionId)
+ return hit as { amicode_owner?: SessionOwnerTag; amicode_control?: unknown; amicode_controlled_by?: unknown } | undefined
+}
+
+/** The driving banner for the CURRENT session id, read off the fleet projection.
+ * Non-null only when that session is interactively driving a remote peer — the
+ * persistent "driving " banner's data source. Unknown id / not-driving /
+ * garbage → null (no banner). */
+export function drivingBannerFromProjection(raw: unknown, sessionId: string): { machineId: string } | null {
+ return drivingBanner(findSessionEntryInProjection(raw, sessionId))
+}
+
+/** #1568: the REVERSE of drivingBanner — presence indicator for a locally-owned
+ * session being remotely controlled. Non-null only when the session is LOCAL
+ * (is_local === true) AND carries `amicode_controlled_by` (a remote machine
+ * holds an active control grant targeting this machine). Remote sessions never
+ * show this (they are the controller's sessions, not ours). */
+export function drivenByBanner(
+ session: { amicode_owner?: SessionOwnerTag; amicode_controlled_by?: unknown } | undefined,
+): { machineId: string; machineName: string } | null {
+ if (!session) return null
+ // Only local sessions can be "driven by" a remote controller
+ if (!session.amicode_owner || session.amicode_owner.is_local === false) return null
+ const cb = session.amicode_controlled_by
+ if (!cb || typeof cb !== "object") return null
+ const o = cb as Record
+ if (typeof o.machine_id !== "string" || typeof o.machine_name !== "string") return null
+ return { machineId: o.machine_id, machineName: o.machine_name }
+}
+
+/** #1568: the driven-by banner for the CURRENT session id, read off the fleet
+ * projection. Non-null only when that session is a LOCAL session being remotely
+ * controlled. Unknown id / no controller / garbage → null. */
+export function drivenByBannerFromProjection(raw: unknown, sessionId: string): { machineId: string; machineName: string } | null {
+ return drivenByBanner(findSessionEntryInProjection(raw, sessionId))
+}
+
+/** The owner tag for the CURRENT session id, read off the fleet projection — the
+ * composer scrim's source for the peer NAME (the CTA copy) and the owner
+ * machineId (the enable-control envelope's target). Unknown id / local /
+ * garbage → undefined. Tolerant (never throws). */
+export function findSessionOwnerInProjection(raw: unknown, sessionId: string): SessionOwnerTag | undefined {
+ return readOwnerTag(findSessionEntryInProjection(raw, sessionId)?.amicode_owner)
+}
+
+/** True when a session is a REMOTE peer session (has an owner overlay whose
+ * `is_local` is explicitly false). Local / unowned sessions are false —
+ * absence of an overlay means local (ADR 0031 §D6). B1 uses this to keep
+ * local-only actions (archive) and the local open OFF a remote row. */
+export function isRemotePeerSession(session: { amicode_owner?: SessionOwnerTag } | undefined): boolean {
+ return !!session?.amicode_owner && session.amicode_owner.is_local === false
+}
+
+/** The machine badge for a dropdown row: the owner machine name for a remote
+ * session, `undefined` for a local/unowned one (absence = local, unbadged). */
+export function deriveSessionBadge(session: { amicode_owner?: SessionOwnerTag } | undefined): string | undefined {
+ if (!isRemotePeerSession(session)) return undefined
+ return session!.amicode_owner!.owner_name
+}
+
+/** Filter dropdown rows to a specific owner machine (#1439, promoted here in
+ * #1537 B2a AC6 as the single real home for the dropdown's machine helpers).
+ * `null`/`undefined` machineId = all machines (the "clear filter" state). */
+export function filterSessionsByMachine(
+ sessions: T[],
+ machineId: string | null | undefined,
+): T[] {
+ if (machineId == null) return sessions
+ return sessions.filter((s) => s.amicode_owner?.owner_machine_id === machineId)
+}
+
+/** A grouped block of dropdown rows for display (#1562): either the LOCAL /
+ * unowned sessions (machineId null, unlabeled) or ONE peer machine's sessions
+ * (machineId set, labeled with the owner name). */
+export interface SessionGroup {
+ /** null for the local / unowned group; the owner machineId for a peer group. */
+ machineId: string | null
+ /** The owner machine name for a peer group; undefined for the local group. */
+ label?: string
+ sessions: DropdownSession[]
+}
+
+/** Group dropdown rows so a peer's sessions are not buried by recency under the
+ * local list (#1562): the LOCAL / unowned rows first as ONE unlabeled group
+ * (input order preserved — the caller's recency / open-tab-first ordering),
+ * then ONE labeled group per peer machine (by owner_machine_id), in first-seen
+ * order, each labeled with the owner name. Read-only presentation — no row is
+ * dropped or reordered within a group. The local group is ALWAYS present
+ * (possibly empty) so the caller renders its empty-state uniformly. Reuses
+ * filterSessionsByMachine + deriveSessionBadge. */
+export function groupSessionsByOwner(sessions: DropdownSession[]): SessionGroup[] {
+ const local = sessions.filter((s) => !isRemotePeerSession(s))
+ const peers = sessions.filter((s) => isRemotePeerSession(s))
+ const groups: SessionGroup[] = [{ machineId: null, sessions: local }]
+ const seen = new Set()
+ for (const s of peers) {
+ const machineId = s.amicode_owner!.owner_machine_id
+ if (seen.has(machineId)) continue
+ seen.add(machineId)
+ groups.push({
+ machineId,
+ label: deriveSessionBadge(s) ?? machineId,
+ sessions: filterSessionsByMachine(peers, machineId),
+ })
+ }
+ return groups
+}
+
+/** Tolerant reader for one raw owner overlay — returns the tag only when its
+ * load-bearing fields are well-typed, else undefined. */
+function readOwnerTag(raw: unknown): SessionOwnerTag | undefined {
+ if (!raw || typeof raw !== "object") return undefined
+ const o = raw as Record
+ if (typeof o.owner_machine_id !== "string") return undefined
+ if (typeof o.owner_name !== "string") return undefined
+ if (typeof o.is_local !== "boolean") return undefined
+ return {
+ owner_machine_id: o.owner_machine_id,
+ owner_name: o.owner_name,
+ ...(typeof o.device_type === "string" ? { device_type: o.device_type } : {}),
+ ...(typeof o.directory === "string" ? { directory: o.directory } : {}),
+ is_local: o.is_local,
+ }
+}
+
+/** Coerce one raw projection entry into a renderable DropdownSession, keeping
+ * only the fields the dropdown needs. Returns undefined when the entry has no
+ * usable id (never a fabricated row). */
+function readProjectionSession(raw: unknown): DropdownSession | undefined {
+ if (!raw || typeof raw !== "object") return undefined
+ const o = raw as Record
+ if (typeof o.id !== "string" || o.id === "") return undefined
+ const owner = readOwnerTag(o.amicode_owner)
+ const timeRaw = (o.time && typeof o.time === "object" ? o.time : {}) as Record
+ const created = typeof timeRaw.created === "number" ? timeRaw.created : 0
+ const updated = typeof timeRaw.updated === "number" ? timeRaw.updated : created
+ const time: DropdownSession["time"] = {
+ created,
+ updated,
+ ...(typeof timeRaw.archived === "number" ? { archived: timeRaw.archived } : {}),
+ }
+ return {
+ id: o.id,
+ ...(typeof o.title === "string" ? { title: o.title } : {}),
+ directory: typeof o.directory === "string" ? o.directory : "",
+ ...(typeof o.parentID === "string" ? { parentID: o.parentID } : {}),
+ time,
+ ...(owner ? { amicode_owner: owner } : {}),
+ // #1646: carry the raw control overlay onto the row. Without this the peer
+ // row has NO control channel, so readSessionControl always falls to the
+ // fail-closed default (`no-control-grant`) — the row could never reflect a
+ // real `interactive` grant, and archive/delete stayed refused forever. The
+ // overlay is passed through verbatim (an object) — readSessionControl is the
+ // single tolerant validator, so a malformed one still degrades safely there.
+ ...(o.amicode_control && typeof o.amicode_control === "object"
+ ? { amicode_control: o.amicode_control as SessionControlProjection }
+ : {}),
+ } as DropdownSession
+}
+
+/** From the raw GET /amicode/fleet/sessions response, extract the REMOTE peer
+ * sessions (owner overlay present, `is_local === false`) as renderable rows.
+ * A malformed / error / not-yet-resolved response → []. Local-owned and
+ * unowned entries are dropped (the local list already carries those). */
+export function peerSessionsFromProjection(raw: unknown): DropdownSession[] {
+ if (!raw || typeof raw !== "object") return []
+ const sessions = (raw as { sessions?: unknown }).sessions
+ if (!Array.isArray(sessions)) return []
+ return sessions.flatMap((s) => {
+ const row = readProjectionSession(s)
+ // Drop CHILD sessions (parentID set) — the dropdown is a ROOT-session list,
+ // mirroring the local path's roots() filter (pages/layout/helpers.ts). The
+ // fleet projection carries every session, INCLUDING subagent children
+ // (@explore / @implementer / critic sub-sessions), and without this filter
+ // they flood the dropdown (amicode#archive-lag follow-up: 76 of 100 sessions
+ // were subagent children). Owner-routing reads the raw projection directly,
+ // so filtering the display extractor here does not affect routing.
+ if (!row || row.parentID || !isRemotePeerSession(row)) return []
+ return [row]
+ })
+}
+
+/** From the raw GET /amicode/fleet/sessions response, extract ALL renderable
+ * rows — LOCAL and REMOTE alike (any entry with a usable id). This is the
+ * complete cross-project, cross-machine session set the projection already
+ * computes.
+ *
+ * Distinct from `peerSessionsFromProjection`, which keeps ONLY remote rows on
+ * the assumption that the dropdown's LOCAL list (iterated from the known
+ * project directories) already carries every local session. That assumption
+ * breaks for a local session in a directory the local store never iterates —
+ * a remote-created session that landed in the owner's ambient temp cwd, or any
+ * project not in `projects.list()`. Such a session is local (so dropped by the
+ * peer-only extractor) AND absent from the directory-scoped local list, so it
+ * was invisible on the owning machine's dropdown. Feeding THIS set through
+ * `mergePeerSessions` (local store wins an id collision) leaves listed-dir
+ * sessions unchanged and surfaces exactly the ones the local list is missing.
+ * Tolerant: malformed / absent / errored ⇒ []. */
+export function allSessionsFromProjection(raw: unknown): DropdownSession[] {
+ if (!raw || typeof raw !== "object") return []
+ const sessions = (raw as { sessions?: unknown }).sessions
+ if (!Array.isArray(sessions)) return []
+ return sessions.flatMap((s) => {
+ const row = readProjectionSession(s)
+ // Drop CHILD sessions (parentID set): the dropdown lists ROOT sessions only,
+ // mirroring the local path's roots() filter. The projection carries subagent
+ // children (@explore / @implementer / critic sub-sessions); without this they
+ // flood the dropdown AND bloat every merge/sort/render (the active list was
+ // ~100 rows instead of ~24). Owner-routing reads the raw projection directly.
+ return row && !row.parentID ? [row] : []
+ })
+}
+
+/** Merge peer sessions into the local active list: local wins on an id
+ * collision (a session that is somehow in both stays local, keeping its live
+ * state), and the result is sorted by last activity (updated ?? created)
+ * descending — the same order the dropdown already uses for the local list.
+ *
+ * #1599 (badge-on-open fix): when a local row collides with a peer that
+ * carries an `amicode_owner` overlay AND the local row has none, the local
+ * row is enriched with the peer's owner tag. Opening a remote session pulls
+ * an untagged copy into the local directory store (the intended open flow —
+ * `openSession` calls projects.open + session.sync to render it); without
+ * this enrichment that untagged copy would win the dedup and the machine
+ * badge would vanish the moment a remote session goes live. The projection is
+ * the source of truth for ownership, so carrying its tag onto the local copy
+ * is honest: the row stays local (live state) but keeps its remote badge.
+ *
+ * #1646 (stale-control fix): the projection is ALSO the source of truth for
+ * the control channel (`amicode_control`), which flips read-only→interactive
+ * when you enable control of a peer. A locally-pulled copy (from opening the
+ * remote session) has NO control overlay, so on an id collision the merge must
+ * carry the peer's control overlay across too — otherwise the merged row reads
+ * fail-closed (`no-control-grant`) even after control is enabled, and the
+ * archive/delete gate refuses with "Control not enabled" while the projection
+ * says interactive. Owner AND control are grafted together, from the same peer. */
+export function mergePeerSessions(local: DropdownSession[], peers: DropdownSession[]): DropdownSession[] {
+ const peerById = new Map(peers.map((p) => [p.id, p]))
+ const merged = local.map((s) => {
+ const peer = peerById.get(s.id)
+ if (!peer) return s
+ // Graft the projection's ownership + control overlays onto a local copy that
+ // lacks them (the projection is SoT for both). Owner grafts only when the
+ // local row has none (#1599); control grafts from the peer whenever the peer
+ // is a remote row carrying one (#1646) — a live local copy has no fleet
+ // control channel of its own, so the peer's is strictly fresher.
+ const needsOwner = !s.amicode_owner && !!peer.amicode_owner
+ const needsControl = isRemotePeerSession(peer) && peer.amicode_control !== undefined
+ if (!needsOwner && !needsControl) return s
+ const grafted: DropdownSession = { ...s }
+ if (needsOwner) grafted.amicode_owner = peer.amicode_owner
+ if (needsControl) grafted.amicode_control = peer.amicode_control
+ return grafted
+ })
+ const seen = new Set(local.map((s) => s.id))
+ for (const p of peers) {
+ if (seen.has(p.id)) continue
+ seen.add(p.id)
+ merged.push(p)
+ }
+ return merged.sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created))
+}
+
+/** #archive-lag (the measured root cause): hide optimistically-archived ids
+ * from the merged active list. The dropdown merges the CACHED fleet projection,
+ * and `allSessionsFromProjection` keeps LOCAL rows too — so once an archive
+ * drops a row from the local store, `mergePeerSessions` re-fills it from the
+ * stale projection (a twin row) until the next 3s poll lands. A tombstone hides
+ * the id from the click onward, for local and remote rows alike. No tombstones
+ * ⇒ the SAME array back, so the common path allocates nothing. */
+export function hideArchivedTombstones(rows: DropdownSession[], tombstones: ReadonlySet): DropdownSession[] {
+ if (tombstones.size === 0) return rows
+ return rows.filter((s) => !tombstones.has(s.id))
+}
+
+/** Retire the tombstones a FRESH active projection has confirmed: an id the
+ * projection no longer lists is archived server-side, so hiding it is no longer
+ * needed. An id still listed stays tombstoned — that is a stale response (e.g.
+ * a poll in flight across the archive PATCH), exactly the resurrection the
+ * tombstone exists to block. An absent / errored / malformed projection settles
+ * nothing (keep hiding; the caller's TTL is the backstop). Returns the SAME set
+ * when nothing settled, so writing it back to a signal is a no-op. */
+export function settleArchivedTombstones(tombstones: ReadonlySet, raw: unknown): ReadonlySet {
+ if (tombstones.size === 0 || !raw || typeof raw !== "object") return tombstones
+ const sessions = (raw as { sessions?: unknown }).sessions
+ if (!Array.isArray(sessions)) return tombstones
+ const listed = new Set()
+ for (const s of sessions) {
+ const id = s && typeof s === "object" ? (s as { id?: unknown }).id : undefined
+ if (typeof id === "string") listed.add(id)
+ }
+ let settled = false
+ const next = new Set()
+ for (const id of tombstones) {
+ if (listed.has(id)) next.add(id)
+ else settled = true
+ }
+ return settled ? next : tombstones
+}
+
+/** The render-relevant signature of a dropdown row — every field the
+ * SessionDropdownRow / ArchivedSessionDropdownRow actually paint. Two rows with
+ * the same signature are visually identical, so the `` should keep the
+ * same DOM node for them. `time.updated` is deliberately EXCLUDED: it churns on
+ * every streamed token but never changes what the row renders (the status dot
+ * is driven by the live avatar-state subscription, not by this object), and
+ * including it would defeat the whole point — a live row would re-key on every
+ * frame. */
+export function sessionRowSignature(s: DropdownSession): string {
+ const owner = s.amicode_owner
+ const control = s.amicode_control
+ return [
+ s.id,
+ s.title ?? "",
+ s.directory ?? "",
+ owner ? `${owner.is_local ? "L" : "R"}:${owner.owner_name ?? ""}:${owner.owner_machine_id ?? ""}` : "-",
+ control ? `${control.controlState}:${control.reason ?? ""}:${control.eligibility}` : "-",
+ ].join("\u0001")
+}
+
+/** Stabilize row object identity across polls so SolidJS `` (which keys by
+ * referential identity) preserves each row's DOM node instead of disposing and
+ * remounting it. Every 3s the fleet projection re-fetches and `mergePeerSessions`
+ * mints brand-new objects for peer / owner-tagged rows; `` then sees a new
+ * reference at that index and remounts the row, resetting its CSS `:hover` state
+ * and replaying the machine-tag opacity transition — the "flash" a user sees
+ * when the pointer is resting on a tagged row (amicode#1652).
+ *
+ * This keeps a caller-owned `Map` cache: when a row's
+ * render signature is unchanged since last emit, the PREVIOUS object is
+ * returned (stable reference → no remount); only a genuine content change (or a
+ * new id) allocates. Order follows `next` exactly; ids absent from `next` are
+ * evicted from the cache so it can't grow without bound. Pure aside from the
+ * passed-in cache — the cache is the reuse state the caller persists across
+ * memo runs. */
+export function stabilizeSessionIdentity(
+ next: readonly DropdownSession[],
+ cache: Map,
+): DropdownSession[] {
+ const nextIds = new Set()
+ const out = next.map((row) => {
+ nextIds.add(row.id)
+ const sig = sessionRowSignature(row)
+ const prev = cache.get(row.id)
+ if (prev && prev.sig === sig) return prev.row
+ cache.set(row.id, { sig, row })
+ return row
+ })
+ for (const id of cache.keys()) {
+ if (!nextIds.has(id)) cache.delete(id)
+ }
+ return out
+}
+
+/** #1647 (S3): the ARCHIVED list, fleet-aware. Given this machine's LOCAL
+ * archived sessions (authoritative, engine-paginated) and the raw
+ * `/amicode/fleet/sessions?archived=true` projection, return the merged
+ * archived list: local archived PLUS every peer-owned archived session
+ * (owner-tagged), local winning an id collision, sorted by last activity
+ * descending. Standalone / fetch-failure ⇒ raw is undefined ⇒
+ * `peerSessionsFromProjection` yields [] ⇒ the local list is unchanged
+ * (byte-identical to the pre-fleet behavior). This is the composition of the
+ * two already-tested peer helpers, named for the one place S3 consumes it. */
+export function archivedSessionsWithRemote(
+ localArchived: DropdownSession[],
+ archivedProjectionRaw: unknown,
+): DropdownSession[] {
+ return mergePeerSessions(localArchived, peerSessionsFromProjection(archivedProjectionRaw))
+}
+
+/** The action the dropdown's openSession() dispatches on when a row is clicked.
+ * A READ-ONLY union — there is no delete/archive/prompt variant, so a reviewer
+ * can confirm this open path carries no remote-write surface (#1537 B2a). */
+export type DropdownOpenAction =
+ | { type: "select-tab"; sessionId: string }
+ | { type: "navigate"; path: string }
+
+// ── Sort utilities (amicode#1599) ─────────────────────────────────────────────
+
+export type SortMode = "recent" | "alpha" | "machine"
+
+/** Title extraction for sort — falls back to id when title is empty/absent. */
+const sortTitle = (s: DropdownSession): string =>
+ typeof s.title === "string" && s.title ? s.title : s.id
+
+/**
+ * Sort a flat list of dropdown sessions by the given mode.
+ * Returns a new array — never mutates the input.
+ *
+ * - `recent`: updated (or created) desc, tie-break by id asc
+ * - `alpha`: title case-insensitive asc, tie-break by recency desc
+ * - `machine`: local/unowned first, then by owner_name asc, recency desc within group
+ */
+export function sortDropdownSessions(
+ sessions: readonly DropdownSession[],
+ mode: SortMode,
+): DropdownSession[] {
+ const sorted = [...sessions]
+ switch (mode) {
+ case "recent":
+ return sorted.sort((a, b) => {
+ const diff = (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
+ if (diff !== 0) return diff
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
+ })
+ case "alpha":
+ return sorted.sort((a, b) => {
+ const cmp = sortTitle(a).toLowerCase().localeCompare(sortTitle(b).toLowerCase())
+ if (cmp !== 0) return cmp
+ return (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
+ })
+ case "machine": {
+ const isLocal = (s: DropdownSession): boolean =>
+ !s.amicode_owner || s.amicode_owner.is_local === true
+ const ownerName = (s: DropdownSession): string =>
+ s.amicode_owner?.owner_name ?? ""
+ return sorted.sort((a, b) => {
+ const aLocal = isLocal(a)
+ const bLocal = isLocal(b)
+ if (aLocal !== bLocal) return aLocal ? -1 : 1
+ if (!aLocal) {
+ const nameCmp = ownerName(a).localeCompare(ownerName(b))
+ if (nameCmp !== 0) return nameCmp
+ }
+ return (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)
+ })
+ }
+ }
+}
+
+/** Resolve how to open a dropdown row — the seam #1537 B2a wires for BOTH local
+ * and REMOTE peer rows.
+ *
+ * Before B2a, a remote (peer-owned) row short-circuited to a "lives on
+ * " guard toast because opening a remote session was never wired.
+ * B2a removes that dead end: a remote row resolves to the SAME owner-routed
+ * navigate as a local one — the session store lives on the owner, and the
+ * multiplex routes reads by owner at the API boundary, so navigating to the
+ * row's raw `directory`/`id` IS the owner-routed open. This is a read: no
+ * branch mutates a remote session.
+ *
+ * - an already-open tab → select it (no re-navigate, no transition);
+ * - otherwise → navigate to the encoded `directory`/`id` path.
+ *
+ * Local-row behavior is byte-unchanged from B1 (same two outcomes). */
+export function resolveDropdownOpenAction(
+ session: DropdownSession,
+ hasExistingTab: boolean,
+ encodePath: (directory: string, id: string) => string,
+): DropdownOpenAction {
+ if (hasExistingTab) return { type: "select-tab", sessionId: session.id }
+ return { type: "navigate", path: encodePath(session.directory, session.id) }
+}
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx
new file mode 100644
index 000000000..ccb658459
--- /dev/null
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.test.tsx
@@ -0,0 +1,263 @@
+import { describe, expect, test } from "bun:test"
+import { readFileSync } from "node:fs"
+import { resolve } from "node:path"
+import {
+ isComposerGated,
+ findSessionControlInProjection,
+ findSessionOwnerInProjection,
+} from "./session-fleet-peers"
+
+// #1544 (slice 4): the session-surface control wiring. Following the repo's
+// component-source-assertion pattern (vscode-explorer-file-icon.test.tsx): the
+// DECISION logic is pure + unit-tested in session-fleet-peers.ts; here we assert
+// the SolidJS wiring binds those pure functions to the session surface — the
+// fail-closed reason chip, the owner-routed archive on an active remote row, and
+// the owner-routed delete that now lives on the Archived tab (remote matches
+// local: an active row archives, the Archived row deletes).
+//
+// The "Driving " indicator moved from a fixed banner Portal in
+// session-header.tsx to a monitor icon on the session tab in the titlebar
+// (titlebar-tab-nav.tsx). The session header no longer renders any driving
+// banner; the shared control projection was extracted to its own module.
+const source = readFileSync(resolve(__dirname, "session-header.tsx"), "utf8")
+const tabNavSource = readFileSync(resolve(__dirname, "../titlebar-tab-nav.tsx"), "utf8")
+const tabStripSource = readFileSync(resolve(__dirname, "../titlebar-tab-strip.tsx"), "utf8")
+const projectionSource = readFileSync(resolve(__dirname, "session-fleet-control-projection.ts"), "utf8")
+
+describe("#1544 session-header control wiring", () => {
+ test("the driving banner Portal is REMOVED from session-header (moved to the tab strip)", () => {
+ // The fixed-position "Driving " banner no longer lives here —
+ // the indicator is now a monitor icon on the session tab itself.
+ expect(source).not.toContain('data-slot="amicode-driving-banner"')
+ expect(source).not.toContain("data-driving-peer=")
+ expect(source).not.toContain("drivingBannerFromProjection")
+ })
+
+ test("the driving indicator lives on the session tab in the titlebar", () => {
+ // The tab strip derives the driving state from the shared projection
+ expect(tabStripSource).toContain("drivingBannerFromProjection(controlProjection()")
+ expect(tabStripSource).toContain("drivingMachineId")
+ // The tab nav item renders a monitor icon with the driving machine tooltip
+ expect(tabNavSource).toContain('data-slot="tab-driving-remote"')
+ expect(tabNavSource).toContain("drivingMachineId")
+ expect(tabNavSource).toContain('name="monitor"')
+ })
+
+ test("Enable control is re-homed onto the composer scrim, NOT a fixed top-right Portal (#1551 DEFECT 2)", () => {
+ // The old placement (a Portal to document.body at top:8px/right:12px over
+ // the titlebar) is REMOVED — no top-right affordance, no command-lane post.
+ expect(source).not.toContain('data-action="session-enable-control"')
+ expect(source).not.toContain('top: "8px"')
+ expect(source).not.toContain("postAmicode(ENABLE_CONTROL_COMMAND)")
+ expect(source).not.toContain("ENABLE_CONTROL_COMMAND")
+ })
+
+ test("the composer-anchored scrim gates the composer + centers the CTA when control is not held (#1551)", () => {
+ // The affordance now lives on a composer-anchored scrim.
+ expect(source).toContain("export function SessionComposerControlScrim")
+ expect(source).toContain('data-slot="amicode-composer-control-scrim"')
+ expect(source).toContain('data-action="composer-enable-control"')
+ // the composer subtree is BLURRED + made non-editable (inert) while gated
+ expect(source).toContain("blur(")
+ expect(source).toContain("inert")
+ // gating is derived from the SHARED pure gate (remote peer + control not
+ // held) — one home for the ungate-on-interactive rule, never the titlebar
+ expect(source).toContain("isComposerGated(")
+ // the CTA names the peer the session lives on
+ expect(source).toContain("This session lives on")
+ // the CTA click posts DEFECT 1's payload envelope (not the retired command lane)
+ expect(source).toContain("postAmicodeFleetEnableControl(")
+ })
+
+ test("a not-held remote row shows a disabled reason chip (never a live erroring button)", () => {
+ expect(source).toContain("failClosedChip(control())")
+ expect(source).toContain('data-slot="session-control-chip"')
+ expect(source).toContain("data-control-reason={c.reason}")
+ // writes are gated on held control
+ expect(source).toContain("writeAffordanceEnabled(control())")
+ })
+
+ test("the active remote row is ARCHIVE-ONLY — remote delete moved to the Archived tab", () => {
+ // A remote peer row on the ACTIVE list offers only the owner-routed archive,
+ // gated on held control. Remote now matches local: no delete on an active
+ // row — archive first, then delete from the Archived tab.
+ expect(source).toContain('data-action="session-remote-archive"')
+ expect(source).toContain("")
+ // the active-row remote-delete + its arm→confirm interaction are GONE
+ expect(source).not.toContain('data-action="session-remote-delete"')
+ expect(source).not.toContain('data-action="session-remote-delete-confirm"')
+ expect(source).not.toContain("armRemoteDelete")
+ expect(source).not.toContain("confirmRemoteDelete")
+ expect(source).not.toContain("remoteDeleteSession")
+ })
+
+ test("remote delete lives on the Archived row — owner-routed AND control-gated", () => {
+ // deleteArchivedSession routes a remote delete to the owner (same SDK delete
+ // surface, intercepted by the #1542 write plane) and is gated on held control
+ // — a control-less remote archived row shows the fail-closed chip instead.
+ expect(source).toContain("async function deleteArchivedSession")
+ expect(source).toContain("remoteDeleteAction(dropdownSession).allowed")
+ // the archived row renders the arm→confirm permanent delete
+ expect(source).toContain('data-action="session-dropdown-delete"')
+ expect(source).toContain('data-action="session-dropdown-delete-confirm"')
+ })
+
+ // #archive-lag cleanup: the archive handler removes the row by splicing it out
+ // of its directory store, NOT by re-fetching + reconciling the whole session
+ // list (`loadSessions` re-fetched + re-`remember`ed 100+ sessions on every
+ // archive — wasted work, though NOT the measured lag: that was the cached
+ // fleet projection re-filling the row; see the tombstone test below). The
+ // composer's archived banner still flips via the INFO-ONLY resolve (metadata;
+ // no message re-fetch). Mirrors the Rail + chat-menu archive paths
+ // (layout.tsx / message-timeline.tsx), which splice.
+ test("#archive-lag archive splices the row from the store (no full list reload) + flips the composer info-only", () => {
+ // the archived row is removed by a targeted splice, not a whole-list reload
+ expect(source).toContain("draft.session.splice(")
+ // …AND the composer's archived banner flips via the INFO-ONLY resolve
+ expect(source).toContain("session.resolve(session.id, { force: true })")
+ // …and must NOT use the heavy sync(force), which re-pulls the full message
+ // history + parents and was the earlier (wrong) suspected cause.
+ expect(source).not.toContain("session.sync(session.id, { force: true })")
+ })
+
+ // #archive-lag ROOT CAUSE (measured in headless Chromium against the live
+ // service): the row's DOM node detached in 14ms, but a TWIN row re-minted from
+ // the CACHED fleet projection (allSessionsFromProjection lists LOCAL sessions
+ // too) stayed visible until the next 3s poll landed (1.3–2.2s). Remote archive
+ // felt instant only because it refetched the projection. Fix: tombstone the id
+ // at click, filter the merged list through it, settle on a fresh projection,
+ // and refetch the projection on LOCAL archive too.
+ test("#archive-lag archive tombstones the row so the stale projection cannot resurrect it", () => {
+ // the merged active list is filtered through the tombstones
+ expect(source).toContain("hideArchivedTombstones(")
+ // tombstones settle only when a FRESH projection lands
+ expect(source).toContain("settleArchivedTombstones(")
+ // the archive handler tombstones optimistically and un-tombstones on failure
+ expect(source).toContain("tombstoneArchived(session.id)")
+ expect(source).toContain("untombstoneArchived(session.id)")
+ // a LOCAL archive refetches the projection too (not only the remote path)
+ const archiveBody = source.slice(source.indexOf("async function archiveSession("), source.indexOf("async function unarchiveSession("))
+ expect(archiveBody).toContain("void refetchFleetProjection()")
+ expect(archiveBody).not.toContain("} else {\n // #1647 (S9)")
+ })
+})
+
+// amicode#1652: the owner tag is a SINGLE element that slides left by the action
+// cluster's exact rendered width — the cluster reveals IN FLOW via a 0fr→1fr grid
+// column (no hardcoded pixel shift), which shrinks the row button and pushes the
+// tag over. The tag is never faded out and never truncated on hover, and the
+// archive stays pinned at the row's right edge (it does not move/teleport).
+describe("#1652 owner-tag shifts (does not teleport) on hover", () => {
+ test("there is ONE owner badge per row — no duplicate 'teleported' copy", () => {
+ expect(source).toContain('data-slot="session-owner-badge"')
+ expect(source).toContain('data-slot="archived-session-owner-badge"')
+ // the earlier fade-out + second-copy approach is gone
+ expect(source).not.toContain('data-slot="session-owner-badge-resting"')
+ expect(source).not.toContain('data-slot="session-owner-badge-teleported"')
+ expect(source).not.toContain('data-slot="archived-session-owner-badge-teleported"')
+ })
+
+ test("the tag is NOT faded out on hover (it shifts, it does not vanish)", () => {
+ expect(source).not.toContain("group-hover/session:opacity-0")
+ expect(source).not.toContain("group-focus-within/session:opacity-0")
+ expect(source).not.toContain("group-hover/archived:opacity-0")
+ })
+
+ test("the action cluster reveals in flow via a 0fr→1fr grid column (reserves exact width)", () => {
+ // active row (session group) and archived row (archived group)
+ expect(source).toContain("grid-cols-[0fr]")
+ expect(source).toContain("group-hover/session:grid-cols-[1fr]")
+ expect(source).toContain("group-hover/archived:grid-cols-[1fr]")
+ // the archive controls no longer overlay via an absolute right-edge box
+ expect(source).not.toContain("absolute right-1.5 top-1/2 flex -translate-y-1/2")
+ })
+})
+
+// amicode#1652: the flyout list must not remount (and flash) tagged rows on the
+// 3s fleet poll. The dropdown feeds through stabilizeSessionIdentity with a
+// component-scoped id-keyed cache, so unchanged rows keep their object reference.
+describe("#1652 stable row identity across the fleet poll", () => {
+ test("the dropdown stabilizes row identity before (active + archived)", () => {
+ expect(source).toContain("stabilizeSessionIdentity")
+ expect(source).toContain("activeIdentityCache")
+ expect(source).toContain("archivedIdentityCache")
+ })
+})
+
+// #1562-followup (Slice A): the interactive flip must be OBSERVED. The shared
+// control projection was extracted to session-fleet-control-projection.ts — the
+// same ref-counted singleton the session header, the composer scrim, and now the
+// titlebar tab strip all consume.
+describe("Slice A — the control projection is polled + shared (the interactive flip is observed)", () => {
+ test("the shared polled accessor lives in its own module and is consumed by session-header", () => {
+ // session-header imports the shared accessor
+ expect(source).toContain("useSharedControlProjection")
+ // the projection module has the poll machinery
+ expect(projectionSource).toContain("setInterval(")
+ expect(projectionSource).toContain('addEventListener("focus"')
+ expect(projectionSource).toContain("clearInterval(")
+ })
+
+ test("the tab strip also consumes the shared projection (for the driving indicator)", () => {
+ expect(tabStripSource).toContain("useControlProjectionForConnection")
+ })
+
+ test("the duplicated one-shot createResource(() => server.current, …) is GONE from header + scrim", () => {
+ // the bare `() => server.current` resource source appeared twice (header +
+ // scrim); both are replaced by the shared polled accessor. (The dropdown's
+ // `() => (open() ? server.current : undefined)` source is a different string
+ // and is intentionally untouched.)
+ expect(source).not.toContain("() => server.current,")
+ })
+
+ test("a projection reporting `interactive` ungates the composer scrim (gated === false)", () => {
+ const interactive = {
+ sessions: [
+ {
+ id: "ses_studio",
+ amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false },
+ amicode_control: { controlState: "interactive", reason: null, eligibility: "none" },
+ },
+ ],
+ }
+ const owner = findSessionOwnerInProjection(interactive, "ses_studio")
+ const control = findSessionControlInProjection(interactive, "ses_studio")
+ expect(isComposerGated(owner, control)).toBe(false)
+
+ // and a read-only projection for the same remote session STILL gates it
+ const readOnly = {
+ sessions: [
+ {
+ id: "ses_studio",
+ amicode_owner: { owner_machine_id: "studio", owner_name: "Studio", is_local: false },
+ amicode_control: { controlState: "read-only", reason: "no-control-grant", eligibility: "enable-control" },
+ },
+ ],
+ }
+ expect(
+ isComposerGated(
+ findSessionOwnerInProjection(readOnly, "ses_studio"),
+ findSessionControlInProjection(readOnly, "ses_studio"),
+ ),
+ ).toBe(true)
+
+ // a LOCAL/unowned session is never gated regardless of control read
+ expect(isComposerGated(undefined, control)).toBe(false)
+ })
+})
+
+// #1568: the "Driven by {machine}" presence indicator — shows on the session tab
+// when a remote machine holds an active control grant targeting this machine.
+describe("#1568 driven-by presence indicator wiring", () => {
+ test("the tab strip derives driven-by state from the shared fleet projection", () => {
+ expect(tabStripSource).toContain("drivenByBannerFromProjection(controlProjection()")
+ expect(tabStripSource).toContain("drivenByMachine")
+ })
+
+ test("the tab nav item renders an eye icon with the driven-by machine tooltip", () => {
+ expect(tabNavSource).toContain('data-slot="tab-driven-by-remote"')
+ expect(tabNavSource).toContain("drivenByMachine")
+ expect(tabNavSource).toContain('name="eye"')
+ expect(tabNavSource).toContain("Driven by")
+ })
+})
diff --git a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
index e51273f55..e9c787d42 100644
--- a/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
+++ b/packages/app-bundle/overlay/packages/app/src/components/session/session-header.tsx
@@ -10,9 +10,10 @@ import { showToast } from "@/utils/toast"
import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip"
import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
import { getFilename } from "@opencode-ai/core/util/path"
-import { batch, createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"
-import { createStore } from "solid-js/store"
+import { batch, createEffect, createMemo, createResource, createRoot, createSignal, For, on, onCleanup, onMount, Show, untrack, type JSX } from "solid-js"
+import { createStore, produce } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { Portal } from "solid-js/web"
import { useCommand } from "@/context/command"
@@ -41,6 +42,40 @@ import { base64Encode } from "@opencode-ai/core/util/encode"
import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers"
import { useNavigate } from "@solidjs/router"
import type { Session } from "@opencode-ai/sdk/v2/client"
+import { amicodeGet } from "@/utils/amicode-fetch"
+import { postAmicodeFleetEnableControl } from "@/utils/amicode-bridge"
+import {
+ allSessionsFromProjection,
+ mergePeerSessions,
+ hideArchivedTombstones,
+ settleArchivedTombstones,
+ stabilizeSessionIdentity,
+ archivedSessionsWithRemote,
+ deriveSessionBadge,
+ isRemotePeerSession,
+ isComposerGated,
+ resolveDropdownOpenAction,
+ readSessionControl,
+ writeAffordanceEnabled,
+ failClosedChip,
+ controlAffordance,
+
+ findSessionControlInProjection,
+ findSessionOwnerInProjection,
+ remoteDeleteAction,
+ sortDropdownSessions,
+ type DropdownSession,
+ type SortMode,
+} from "./session-fleet-peers"
+
+// AMICODE #1551 (DEFECT 2): the self-owned Enable-control affordance no longer
+// lives as a fixed top-right Portal over the titlebar. It is re-homed onto a
+// COMPOSER-ANCHORED scrim (SessionComposerControlScrim, below) that blurs +
+// gates the composer and centers the CTA while control is not held. The CTA
+// posts the #1551 payload envelope (postAmicodeFleetEnableControl); the extension
+// host shows the ADR 0034 D4 native modal and mints the grant. On success the
+// next fleet-projection poll flips the projected control to `interactive` — the
+// app never self-declares interactive.
// AMICODE: the MCP/LSP/Plugins/Vaults status popover is opencode-operator
// noise here ("No MCPs configured"). Hidden, not deleted — the trigger slot is
@@ -55,6 +90,14 @@ import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { reviewTooltipKeybind } from "../command-tooltip-keybind"
import { useTitlebarRightMount, useTitlebarControlMount } from "../titlebar"
+// #1577: portal dedup registry — enforces exactly one portal source per
+// session-scoped mount point across concurrent SessionHeader mounts
+// (the startTransition double-mount during remote session focus).
+import { claimPortalMount, isPortalOwner, releasePortalMount } from "../titlebar-portal-registry"
+// #1562-followup (Slice A): the shared control-projection poll — extracted to
+// session-fleet-control-projection.ts so the titlebar tab strip can share the
+// same ref-counted singleton without importing this component file.
+import { useSharedControlProjection } from "./session-fleet-control-projection"
const OPEN_APPS = [
"vscode",
@@ -170,6 +213,18 @@ export function SessionHeader() {
const terminal = useTerminal()
const { params, view } = useSessionLayout()
+ // #1544 (slice 4): the control state channel for the CURRENT session, read off
+ // the fleet projection (GET /amicode/fleet/sessions — the SAME carrier the
+ // Sessions dropdown consumes). It drives the persistent "driving "
+ // banner and the Enable/Request-control affordance on the SESSION SURFACE
+ // (control affordances live here + in Fleet Manager, NEVER the read-only
+ // sidebar — ADR 0034 D7). Tolerant: a 404 / no-fleet resolves to undefined and
+ // every derived value degrades to "no banner / no affordance".
+ // #1562-followup (Slice A): read via the SHARED polled accessor so the
+ // interactive flip (grant landed) is actually OBSERVED — the banner lights on
+ // the next poll instead of never (the B2b one-shot never re-fetched).
+ const controlProjection = useSharedControlProjection()
+
const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => {
const directory = projectDirectory()
@@ -318,12 +373,34 @@ export function SessionHeader() {
const sessionsMount = useTitlebarControlMount("sessions")
const statusMount = useTitlebarControlMount("status")
const sidePanelMount = useTitlebarControlMount("side-panel")
+
+ // #1577: claim exclusive ownership of each session-scoped mount point.
+ // During startTransition, a new SessionHeader can mount while the old one
+ // is still alive — both render portals into the same mount divs, doubling
+ // controls. The registry makes isPortalOwner() return false for the stale
+ // component, so its portals conditionally hide.
+ const sessionsToken = claimPortalMount("sessions")
+ const statusToken = claimPortalMount("status")
+ const sidePanelToken = claimPortalMount("side-panel")
+ onCleanup(() => {
+ releasePortalMount("sessions", sessionsToken)
+ releasePortalMount("status", statusToken)
+ releasePortalMount("side-panel", sidePanelToken)
+ })
+
onMount(() => {
setCenterMount(document.getElementById("opencode-titlebar-center"))
})
return (
<>
+ {/* #1544 (slice 4): the "driving " indicator moved from a fixed
+ banner Portal to a monitor icon on the session tab in the titlebar
+ (titlebar-tab-nav.tsx). The tab strip now consumes the shared fleet
+ projection directly. The composer scrim control gate is unchanged. */}
+ {/* #1551 (DEFECT 2): the Enable-control affordance moved OFF the titlebar
+ and onto the composer scrim (SessionComposerControlScrim, below). No
+ fixed top-right Portal renders here anymore. */}
{(mount) => (
@@ -562,9 +639,11 @@ export function SessionHeader() {
)}
- {/* V2 per-button portals — each session-scoped control portals to its own mount point */}
+ {/* V2 per-button portals — each session-scoped control portals to its own mount point.
+ #1577: each portal is gated on isPortalOwner so that during startTransition
+ (two concurrent SessionHeaders), only the latest owner renders. */}
-
+
{(mount) => (
@@ -574,7 +653,7 @@ export function SessionHeader() {
)}
-
+
{(mount) => (
@@ -587,7 +666,7 @@ export function SessionHeader() {
-
+
{(mount) => (
+ findSessionControlInProjection(controlProjection(), params.id ?? ""),
+ )
+ const owner = createMemo(() => findSessionOwnerInProjection(controlProjection(), params.id ?? ""))
+ // Gated iff a REMOTE peer session whose control is NOT held (read-only /
+ // suspended). Local / unowned / already-driving (interactive) → not gated.
+ // The rule lives in one pure place (isComposerGated), shared with the test.
+ const gated = createMemo(() => isComposerGated(owner(), control()))
+ const peerLabel = createMemo(() => owner()?.owner_name || owner()?.owner_machine_id || "this peer")
+ const enableControl = () => {
+ const o = owner()
+ const id = params.id
+ if (!o || !id) return
+ // The CTA click path is identical to DEFECT 1's envelope.
+ postAmicodeFleetEnableControl({ ownerMachineId: o.owner_machine_id, sessionID: id })
+ }
+ return (
+
+ {/* The composer subtree — blurred + inert (non-editable, unfocusable)
+ while gated; untouched otherwise. */}
+
+ {props.children}
+
+
+
+
+
+
+ This session lives on {peerLabel()} — Enable control to drive it
+
+
+
+
+
+
+ )
+}
+
type SessionHeaderV2ActionsState = {
/** AMICODE (#174 AC2): the trigger itself always renders in a session; the
* show-status setting only drives this health-dot flag. */
@@ -692,7 +885,7 @@ function SessionHeaderV2Actions(props: { state: SessionHeaderV2ActionsState }) {
// (amicode#273) inside the session header: tabbed Active/Archived, search,
// open-tab indicators, archive/unarchive actions, and cursor-based pagination.
const SESSION_DROPDOWN_ROW =
- "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-sm bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base"
+ "flex min-w-0 flex-1 cursor-default items-center rounded-sm bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover hover:text-v2-text-text-base focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:text-v2-text-text-base"
export function SessionChatsDropdown(props: { currentSessionID?: string } = {}) {
const tabs = useTabs()
@@ -711,16 +904,159 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
const [archivedHasMore, setArchivedHasMore] = createSignal(true)
const ARCHIVED_PAGE_SIZE = 20
+ // --- Sort mode (amicode#1599 part C) ---
+ const [sortMode, setSortMode] = createSignal(
+ (() => {
+ try {
+ const stored = localStorage.getItem("amicode:sessions-dropdown-sort")
+ if (stored === "recent" || stored === "alpha" || stored === "machine") return stored
+ } catch { /* localStorage unavailable */ }
+ return "recent"
+ })(),
+ )
+ createEffect(() => {
+ try { localStorage.setItem("amicode:sessions-dropdown-sort", sortMode()) } catch { /* best-effort */ }
+ })
+
+ // --- Active tab pagination (amicode#1599 part B) ---
+ const ACTIVE_PAGE_SIZE = 50
+ const [activeLimit, setActiveLimit] = createSignal(ACTIVE_PAGE_SIZE)
+ const [loadingMore, setLoadingMore] = createSignal(false)
+
let flyoutRoot: HTMLDivElement | undefined
+ let triggerRef: HTMLButtonElement | undefined
+ let scrollContainerRef: HTMLDivElement | undefined
+
+ /** Save scrollTop and restore it on the next animation frame.
+ * Call before any action that triggers a reactive list rebuild
+ * (archive / delete / unarchive) to prevent the scroll container
+ * from jumping to the top when diffs the updated array. */
+ function restoreScrollAfter(fn: () => Promise): Promise {
+ const saved = scrollContainerRef?.scrollTop ?? 0
+ return fn().finally(() => {
+ requestAnimationFrame(() => {
+ if (scrollContainerRef) scrollContainerRef.scrollTop = saved
+ })
+ })
+ }
+
+ // --- Cached flyout position (amicode#1599 part A) ---
+ const [flyoutPos, setFlyoutPos] = createSignal<{ top: number; right: number }>({ top: 0, right: 0 })
+ const recomputePos = () => {
+ if (!triggerRef) return
+ const rect = triggerRef.getBoundingClientRect()
+ setFlyoutPos({
+ top: rect.bottom + 8,
+ right: document.documentElement.clientWidth - rect.right,
+ })
+ }
+ createEffect(() => {
+ if (!open()) return
+ recomputePos()
+ window.addEventListener("resize", recomputePos)
+ onCleanup(() => window.removeEventListener("resize", recomputePos))
+ })
const currentSessionID = createMemo(() => props.currentSessionID)
+ // #1525 B1 (read-only): peer sessions from the fleet-wide projection
+ // (GET /amicode/fleet/sessions). Fetched only while the flyout is open;
+ // tolerant — a 404 (route not mounted / no fleet) or any error resolves to
+ // undefined, so the dropdown degrades to the local list, never blank. The
+ // merge below adds REMOTE (is_local:false) sessions, badged with the owner
+ // machine name; owner-routed open + remote control (prompt/archive/delete)
+ // are B2 (#1525) and deliberately absent here.
+ const [fleetProjection, { refetch: refetchFleetProjection }] = createResource(
+ () => (open() ? ([server.current, fleetPoll()] as const) : undefined),
+ ([conn]) => amicodeGet(conn, "/amicode/fleet/sessions").catch(() => undefined),
+ )
+ // #1647 (S10): poll the fleet projection while the flyout is open. It is the
+ // SOURCE of a peer row's owner badge (peerSessionsFromProjection); without a
+ // poll it was fetched ONCE on open, so a REMOTE archive/unarchive done on the
+ // OWNER machine (which this machine only learns of via the SSE event) left the
+ // projection stale — the row reappeared from the local store on unarchive but
+ // WITHOUT its machine tag until the flyout was reopened. A 3s poll (matching
+ // the control projection) refreshes the owner overlay so the badge returns.
+ const [fleetPoll, setFleetPoll] = createSignal(0)
+ createEffect(() => {
+ if (!open()) return
+ const timer = setInterval(() => setFleetPoll((t) => t + 1), 3000)
+ onCleanup(() => clearInterval(timer))
+ })
+
+ // #archive-lag (the measured root cause — round 6). The active list merges the
+ // CACHED fleet projection, which lists LOCAL sessions too. Removing an archived
+ // row from the local store (splice or reload — either) is therefore NOT enough:
+ // mergePeerSessions re-fills the id from the stale projection as a twin row
+ // until the next 3s poll lands. Measured in headless Chromium against the live
+ // service: the original DOM node detached in 14ms, the twin stayed visible
+ // 1.3–2.2s — the "couple of seconds". A REMOTE archive only felt instant because
+ // its path refetched the projection immediately.
+ //
+ // Tombstones hide an archived id from the CLICK onward (local and remote alike)
+ // and retire when (a) a fresh projection response no longer lists it, (b) it is
+ // unarchived, (c) the archive PATCH fails, or (d) a TTL backstop fires (so a
+ // session unarchived elsewhere before the projection ever confirmed can't stay
+ // hidden forever).
+ const ARCHIVE_TOMBSTONE_TTL_MS = 15_000
+ const [archivedTombstones, setArchivedTombstones] = createSignal>(new Set())
+ const tombstoneTimers = new Map>()
+ function untombstoneArchived(id: string) {
+ const timer = tombstoneTimers.get(id)
+ if (timer) clearTimeout(timer)
+ tombstoneTimers.delete(id)
+ setArchivedTombstones((prev) => {
+ if (!prev.has(id)) return prev
+ const next = new Set(prev)
+ next.delete(id)
+ return next
+ })
+ }
+ function tombstoneArchived(id: string) {
+ setArchivedTombstones((prev) => (prev.has(id) ? prev : new Set(prev).add(id)))
+ const existing = tombstoneTimers.get(id)
+ if (existing) clearTimeout(existing)
+ tombstoneTimers.set(id, setTimeout(() => untombstoneArchived(id), ARCHIVE_TOMBSTONE_TTL_MS))
+ }
+ // Settle ONLY when a new projection response lands — not when a tombstone is
+ // added (untracked), or the still-cached pre-archive projection would be judged
+ // against a fresh tombstone before the PATCH even returned. An id whose PATCH is
+ // still in flight is never settled: its local-store row has not been removed
+ // yet, so retiring the tombstone early would flash the row back.
+ const archivePending = new Set()
+ createEffect(
+ on(
+ () => fleetProjection.latest,
+ (latest) => {
+ const current = untrack(archivedTombstones)
+ const next = settleArchivedTombstones(current, latest)
+ if (next === current) return
+ for (const id of current) if (!next.has(id) && !archivePending.has(id)) untombstoneArchived(id)
+ },
+ { defer: true },
+ ),
+ )
+ onCleanup(() => {
+ for (const timer of tombstoneTimers.values()) clearTimeout(timer)
+ tombstoneTimers.clear()
+ })
+
// Active sessions — only computed when the flyout is open to avoid
// triggering reactive subscriptions (serverSync().child pins the directory
// and can cascade re-renders to the parent Portal).
// Source from ALL project directories (same as dashboard) — not just the
// server's cwd, which may not be where sessions live (amicode#138).
- const activeSessions = createMemo(() => {
+ //
+ // PERF (amicode#1599): reading each store's session rows subscribes this memo
+ // to per-row fields including `time.updated`. A LIVE session streaming tokens
+ // bumps `time.updated` on nearly every frame, which would re-run the whole
+ // dropdown pipeline (merge → sort → filter → re-diff) at frame rate and
+ // make the open flyout lag. We split the compute in two:
+ // • activeSessionsRaw — the reactive read (subscribes to the stores).
+ // • activeSessions — a throttled mirror that only re-emits at most once
+ // per THROTTLE_MS, EXCEPT it passes through immediately when the session
+ // SET changes (ids/titles/count) so open/close/rename stay instant.
+ const activeSessionsRaw = createMemo(() => {
if (!open()) return []
try {
const conn = server.current
@@ -744,6 +1080,57 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
})
+ // The SET signature: what actually needs an instant re-render (rows added /
+ // removed / renamed). Streaming token churn only moves `time.updated`, which
+ // is deliberately NOT in the signature — those updates ride the throttle.
+ const activeSetSignature = createMemo(() =>
+ activeSessionsRaw()
+ .map((s) => `${s.id}:${s.title ?? ""}`)
+ .join("|"),
+ )
+
+ const ACTIVE_THROTTLE_MS = 500
+ const [activeThrottleTick, setActiveThrottleTick] = createSignal(0)
+ createEffect(() => {
+ if (!open()) return
+ // Depend on the raw list so the timer runs while updates are flowing.
+ activeSessionsRaw()
+ const t = setTimeout(() => setActiveThrottleTick((n) => n + 1), ACTIVE_THROTTLE_MS)
+ onCleanup(() => clearTimeout(t))
+ })
+
+ const activeSessions = createMemo(() => {
+ if (!open()) return []
+ // Re-emit ONLY on a set change (add/remove/rename) or a throttle tick.
+ activeSetSignature()
+ activeThrottleTick()
+ // Read the raw list WITHOUT subscribing to its per-row fields — otherwise a
+ // streaming `time.updated` would re-run this memo and defeat the throttle.
+ return untrack(activeSessionsRaw)
+ })
+
+
+ // #1525 B1: fold the projection's sessions into the active list (deduped
+ // against local, sorted by last activity). Empty/errored projection → the
+ // local list unchanged. Use the FULL projection set (local + remote): the
+ // local list is iterated only over KNOWN project directories, so a local
+ // session in a directory the store never iterates (a remote-created session
+ // that landed in the owner's ambient temp cwd, any unlisted project) was
+ // absent from BOTH the local list and the peer-only extractor → invisible on
+ // the owning machine's dropdown. mergePeerSessions dedupes (local store wins),
+ // so listed-dir rows are unchanged and only the missing ones are surfaced.
+ const activeSessionsWithPeers = createMemo(() => {
+ if (!open()) return []
+ const projectionSessions = allSessionsFromProjection(fleetProjection.latest)
+ // #archive-lag: the projection above is a CACHE (≤3s old) that lists LOCAL
+ // sessions too, so a just-archived row the local store already dropped would
+ // be re-filled from it by the merge. Tombstones hide it from the click on.
+ return hideArchivedTombstones(
+ mergePeerSessions(activeSessions() as DropdownSession[], projectionSessions),
+ archivedTombstones(),
+ )
+ })
+
// Fresh clients have no bootstrapped child stores for the fallback
// directories (the dropdown reads with bootstrap: false) — kick the loads
// once per open. Converges: re-runs find the stores populated and skip.
@@ -759,10 +1146,10 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
})
- // Sort: open-tab sessions first
+ // Sort: open-tab sessions first, each partition sorted by the active mode.
const sortedActiveSessions = createMemo(() => {
if (!open()) return []
- const all = activeSessions()
+ const all = activeSessionsWithPeers()
const openTabs: Session[] = []
const rest: Session[] = []
for (const session of all) {
@@ -772,7 +1159,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
rest.push(session)
}
}
- return [...openTabs, ...rest]
+ const mode = sortMode()
+ return [
+ ...sortDropdownSessions(openTabs as DropdownSession[], mode),
+ ...sortDropdownSessions(rest as DropdownSession[], mode),
+ ]
})
// Search filtering
@@ -786,13 +1177,47 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
return title.toLowerCase().includes(q)
})
})
+ // amicode#1599 follow-up — actually paginate the DOM. `activeLimit` bounded the
+ // backend fetch and the Show-more button, but the rendered EVERY loaded
+ // + peer row: hundreds of rows, each carrying the per-row
+ // useSessionTabAvatarState subscriptions, re-diffed on every 3s fleet poll —
+ // the source of the flyout lag. Slice the rendered list to the limit. Search
+ // spans the full loaded set, so slice AFTER the filter (a query renders all
+ // matches; Show-more is hidden while searching).
+ // amicode#1652 — the 3s fleet poll (fleetPoll) re-fetches the projection, and
+ // mergePeerSessions mints a NEW object for every peer / owner-tagged row on each
+ // fetch. SolidJS keys by referential identity, so those new references make
+ // it dispose + remount the row — resetting the CSS :hover state and replaying the
+ // machine-tag opacity transition under a resting pointer (the "flash"). An
+ // id-keyed identity cache returns the PREVIOUS row object when a row's render
+ // signature is unchanged, so unchanged rows keep their reference (and DOM node)
+ // across the poll. The cache is component-scoped so it survives memo re-runs; it
+ // self-evicts ids that leave the list.
+ const activeIdentityCache = new Map()
+ const pagedActiveSessions = createMemo(() => {
+ const sliced = searchQuery()
+ ? filteredActiveSessions()
+ : filteredActiveSessions().slice(0, activeLimit())
+ return stabilizeSessionIdentity(sliced as DropdownSession[], activeIdentityCache)
+ })
+ // "More" = rows hidden by the slice, OR the backend fetch cap was hit (more may
+ // be loadable). Show-more reveals the next page and fetches the next backend page.
+ const hasMoreActive = createMemo(
+ () => filteredActiveSessions().length > pagedActiveSessions().length || activeSessions().length >= activeLimit(),
+ )
+ // amicode#1652 — same identity-stabilization as the active list: the archived
+ // tab has its own 5s poll (loadArchivedSessions) that setArchivedSessions with
+ // fresh objects, remounting owner-tagged archived rows and flashing their tag.
+ const archivedIdentityCache = new Map()
const filteredArchivedSessions = createMemo(() => {
const q = searchQuery()
- if (!q) return archivedSessions()
- return archivedSessions().filter((session) => {
- const title = sessionTitle(session.title) || session.id
- return title.toLowerCase().includes(q)
- })
+ const list = !q
+ ? archivedSessions()
+ : archivedSessions().filter((session) => {
+ const title = sessionTitle(session.title) || session.id
+ return title.toLowerCase().includes(q)
+ })
+ return stabilizeSessionIdentity(list as DropdownSession[], archivedIdentityCache)
})
// SDK access for archived sessions
@@ -811,14 +1236,22 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
const result = await ctx.sdk.client.experimental.session.list(
{ archived: true, limit: ARCHIVED_PAGE_SIZE, ...(cursor ? { cursor: Number(cursor) } : {}) },
)
- const sessions: Session[] = (result.data ?? []) as Session[]
+ const local: Session[] = (result.data ?? []) as Session[]
if (reset) {
- setArchivedSessions(sessions)
+ // #1647 (S3): on the first page, fold in peer-owned archived sessions
+ // from the fleet projection (owner-tagged). Bounded recent window — not
+ // per-peer cursor-paginated (pragmatic first cut). A standalone machine
+ // or a fetch failure yields no remote rows → local list unchanged.
+ const proj = await amicodeGet(server.current, "/amicode/fleet/sessions?archived=true").catch(() => undefined)
+ setArchivedSessions(archivedSessionsWithRemote(local as DropdownSession[], proj) as Session[])
} else {
- setArchivedSessions((prev) => [...prev, ...sessions])
+ // Load-more appends the next LOCAL page; remote rows were merged on reset.
+ const have = new Set(archivedSessions().map((s) => s.id))
+ setArchivedSessions((prev) => [...prev, ...local.filter((s) => !have.has(s.id))])
}
- const lastSession = sessions[sessions.length - 1]
- if (sessions.length >= ARCHIVED_PAGE_SIZE && lastSession) {
+ // Cursor tracks the LOCAL engine's pagination only (remote is a fixed window).
+ const lastSession = local[local.length - 1]
+ if (local.length >= ARCHIVED_PAGE_SIZE && lastSession) {
setArchivedCursor(String(lastSession.time.updated ?? lastSession.time.created))
setArchivedHasMore(true)
} else {
@@ -832,69 +1265,190 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
}
+ // #1647 (S6): keep the Archived tab live while open (local + fan-in-relayed
+ // remote archive/unarchive), on the same 5s cadence the fleet projection uses.
+ // Bounded real-time (≤5s), robust, cleaned up on tab-close / unmount.
+ createEffect(() => {
+ if (flyoutTab() !== "archived") return
+ const timer = setInterval(() => void loadArchivedSessions(true), 5000)
+ onCleanup(() => clearInterval(timer))
+ })
+
async function archiveSession(session: Session) {
const ctx = getServerCtx()
if (!ctx) return
- try {
- await (ctx.sdk.client.session.update as Function)({
- sessionID: session.id,
- directory: session.directory,
- time: { archived: Date.now() },
- })
- setArchivedSessions((prev) => [session, ...prev])
- // Reload active sessions for this session's directory
- await serverSync().project.loadSessions(session.directory, { limit: 64 })
- } catch (cause) {
- showToast({
- title: language.t("common.requestFailed"),
- description: String(cause),
- })
+ const dropdownSession = session as DropdownSession
+ const isRemote = isRemotePeerSession(dropdownSession)
+ // A REMOTE peer session is archived on its OWNER: the same session.update
+ // PATCH, keyed on the session's own id+directory, is intercepted by the
+ // #1542 observation WRITE plane (any non-GET to a peer-owned session routes
+ // to the owner by pathname) — provided control is held. The row only shows
+ // the archive affordance for a remote row when canWrite() is true, so this
+ // is reached only under held control; a control-less remote row shows the
+ // fail-closed chip instead and never gets here.
+ if (isRemote) {
+ const action = remoteDeleteAction(dropdownSession) // reuse the gate: allowed iff control held
+ if (!action.allowed) {
+ showToast({
+ title: "Control not enabled",
+ description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to archive its sessions from here.`,
+ })
+ return
+ }
}
+ // #archive-lag: hide the row NOW — before the PATCH round-trip — for local
+ // and remote alike. The tombstone also blocks the cached fleet projection from
+ // re-minting the row (the measured couple-of-seconds lag); see the tombstone
+ // block above. A failed PATCH un-tombstones so the row comes back.
+ tombstoneArchived(session.id)
+ archivePending.add(session.id)
+ let patched = false
+ await restoreScrollAfter(async () => {
+ try {
+ await (ctx.sdk.client.session.update as Function)({
+ sessionID: session.id,
+ directory: session.directory,
+ time: { archived: Date.now() },
+ })
+ patched = true
+ if (!isRemote) {
+ // A LOCAL archive moves the row into THIS machine's archived tab.
+ setArchivedSessions((prev) => [session, ...prev])
+ // Remove the archived row from its directory store DIRECTLY instead of
+ // re-fetching + reconciling the whole session list (`loadSessions`
+ // re-`remember`ed every row on every archive — wasted work). roots()
+ // (pages/layout/helpers.ts) filters archived rows anyway, and the next
+ // natural loadSessions reconciles from the server. Matches the Rail
+ // (layout.tsx) and chat-menu (message-timeline.tsx) archive paths.
+ const [, setChildStore] = serverSync().child(session.directory, { bootstrap: false })
+ setChildStore(
+ produce((draft) => {
+ const index = draft.session.findIndex((s) => s.id === session.id)
+ if (index !== -1) draft.session.splice(index, 1)
+ }),
+ )
+ }
+ // Refetch the fleet projection for BOTH paths. #1647 (S9): a remote row
+ // lives only in the projection. #archive-lag: a LOCAL row is ALSO in the
+ // projection (allSessionsFromProjection keeps local rows), so the cache
+ // must converge too — the fresh response is what retires the tombstone.
+ void refetchFleetProjection()
+ // Refresh the Archived tab so the just-archived session appears there too.
+ void loadArchivedSessions(true)
+ // #1646: refresh the PER-SESSION store so the composer flips to its
+ // archived read-only banner immediately. The removal above touches the
+ // LIST store, but the composer's `archived` memo reads the per-session
+ // store (sync().session.get(id).time.archived) — which the list change
+ // does not touch. Without this the banner appeared only when the change
+ // lazily propagated (SSE / next projection cycle), so a remote
+ // (owner-routed) archive left the composer live for seconds.
+ // INFO-ONLY `resolve` (session.get → remember), NOT `sync`: the archived
+ // flag is METADATA, so re-fetching the whole message page is pure waste.
+ // `resolve` lands the flag without touching messages, matching the
+ // composer controller's own convergence (session-composer-region-controller).
+ await serverSync().session.resolve(session.id, { force: true }).catch(() => {})
+ } catch (cause) {
+ // Only a FAILED archive brings the row back; a post-PATCH hiccup must not
+ // resurrect a session the server has already archived.
+ if (!patched) untombstoneArchived(session.id)
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: String(cause),
+ })
+ } finally {
+ archivePending.delete(session.id)
+ }
+ })
}
async function unarchiveSession(session: Session) {
const ctx = getServerCtx()
if (!ctx) return
- try {
- await (ctx.sdk.client.session.update as Function)({
- sessionID: session.id,
- directory: session.directory,
- time: { archived: null },
- })
- setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id))
- await serverSync().project.loadSessions(session.directory, { limit: 64 })
- } catch (cause) {
+ // #1647 (S4): a REMOTE archived row unarchives on its OWNER (the same
+ // owner-routed session.update, now routable because the owner map unions
+ // archived ids — S2). Gate on control exactly like the active archive path;
+ // a control-less remote row shows the fail-closed chip and never reaches here.
+ const dropdownSession = session as DropdownSession
+ if (isRemotePeerSession(dropdownSession) && !remoteDeleteAction(dropdownSession).allowed) {
showToast({
- title: language.t("common.requestFailed"),
- description: String(cause),
+ title: "Control not enabled",
+ description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to unarchive its sessions from here.`,
})
+ return
}
+ await restoreScrollAfter(async () => {
+ try {
+ await (ctx.sdk.client.session.update as Function)({
+ sessionID: session.id,
+ directory: session.directory,
+ time: { archived: null },
+ })
+ // #archive-lag: a quick archive→unarchive must not leave the row hidden
+ // behind a still-live tombstone.
+ untombstoneArchived(session.id)
+ setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id))
+ await serverSync().project.loadSessions(session.directory, { limit: 64 })
+ // #1647 (S9): a remote unarchive re-activates the session on its owner —
+ // refetch the fleet projection so it reappears in the active peer list.
+ if (isRemotePeerSession(dropdownSession)) void refetchFleetProjection()
+ } catch (cause) {
+ showToast({
+ title: language.t("common.requestFailed"),
+ description: String(cause),
+ })
+ }
+ })
}
// amicode#255: permanently delete an archived session (inline confirm in row).
async function deleteArchivedSession(session: Session) {
const ctx = getServerCtx()
if (!ctx) return
- try {
- await (ctx.sdk.client.session.delete as Function)({
- sessionID: session.id,
- directory: session.directory,
- })
- setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id))
- } catch (cause) {
+ // #1647 (S4): remote delete routes to the owner; gate on control.
+ const dropdownSession = session as DropdownSession
+ if (isRemotePeerSession(dropdownSession) && !remoteDeleteAction(dropdownSession).allowed) {
showToast({
- title: language.t("session.delete.failed.title"),
- description: String(cause),
+ title: "Control not enabled",
+ description: `Enable control of ${dropdownSession.amicode_owner?.owner_name ?? "the owner machine"} to delete its sessions from here.`,
})
+ return
}
+ await restoreScrollAfter(async () => {
+ try {
+ await (ctx.sdk.client.session.delete as Function)({
+ sessionID: session.id,
+ directory: session.directory,
+ })
+ setArchivedSessions((prev) => prev.filter((s) => s.id !== session.id))
+ // #1647 (S9): drop the deleted peer session from the active list too.
+ if (isRemotePeerSession(dropdownSession)) void refetchFleetProjection()
+ } catch (cause) {
+ showToast({
+ title: language.t("session.delete.failed.title"),
+ description: String(cause),
+ })
+ }
+ })
}
async function openSession(session: Session) {
+ // #1537 B2a: OPEN a peer (remote) session by routing through the owner.
+ // Open is a READ — the session store lives on the owner machine and the
+ // multiplex routes reads by owner at the API boundary, so navigating to the
+ // row's raw directory/id IS the owner-routed open. B1's guard toast ("lives
+ // on , coming soon") was the dead end this replaces; a remote row
+ // now lands in the normal session view, byte-for-byte the same navigate a
+ // local row takes. No remote-write surface is added here (prompt / archive /
+ // delete of a peer row remain absent — that is B2b, design-gated). The row
+ // still hides its LOCAL-only archive action via `isRemotePeerSession`.
+
// Close flyout first so its Portal unmounts cleanly.
setOpen(false)
// Mirror the dashboard's project setup: ensure the directory is registered and
- // touched so the workspace context is warm when the session page mounts.
+ // touched so the workspace context is warm when the session page mounts. For a
+ // remote row this registers the owner's directory string locally — harmless
+ // bookkeeping; the actual session reads route to the owner via the multiplex.
const conn = server.current
if (conn) {
const ctx = globalCtx.ensureServerCtx(conn)
@@ -919,11 +1473,15 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
const existingTab = tabs.store.find(
(t) => t.type === "session" && t.sessionId === session.id,
)
- if (existingTab) {
- tabs.select(existingTab)
+ const action = resolveDropdownOpenAction(
+ session as DropdownSession,
+ !!existingTab,
+ (dir, id) => `/${base64Encode(dir)}/session/${id}`,
+ )
+ if (action.type === "select-tab") {
+ tabs.select(existingTab!)
} else {
- const path = `/${base64Encode(session.directory)}/session/${session.id}`
- navigate(path)
+ navigate(action.path)
}
}
@@ -943,7 +1501,7 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
if (flyoutRoot?.contains(target)) return
if (triggerRef?.contains(target)) return
// Don't dismiss if the click landed inside a dialog (e.g. delete confirmation)
- if (target instanceof Element && target.closest("[data-dialog-layer], [data-component='dialog-overlay']")) return
+ if (target instanceof Element && target.closest("[data-dialog-layer], [data-component='dialog-overlay'], [data-component='menu-v2-content']")) return
setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
@@ -959,8 +1517,6 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
onCleanup(() => clearTimeout(timer))
})
- let triggerRef: HTMLButtonElement | undefined
-
return (
<>
@@ -984,8 +1540,8 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
aria-label={language.t("sidebar.project.recentSessions")}
style={{
position: "fixed",
- top: `${(triggerRef?.getBoundingClientRect().bottom ?? 0) + 8}px`,
- right: `${document.documentElement.clientWidth - (triggerRef?.getBoundingClientRect().right ?? 0)}px`,
+ top: `${flyoutPos().top}px`,
+ right: `${flyoutPos().right}px`,
"z-index": "9999",
width: "min(440px, 88vw)",
"max-height": "min(70vh, 680px)",
@@ -1053,6 +1609,41 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
+ {/* modal={false} is load-bearing: a modal Kobalte DropdownMenu
+ sets document.body { pointer-events: none } while open, so
+ clicks on the flyout (rendered in a solid Portal under body)
+ retarget to — which the flyout's outside-click
+ dismiss handler reads as "outside" and closes the whole
+ session list. Non-modal keeps body interactive; the menu
+ still dismisses itself on outside-click via its own layer. */}
+
+
+ Sort by
+
+
+
+ setSortMode(v as SortMode)}>
+ Recent
+ A–Z
+ Machine
+
+
+
{/* Tab content */}
-
+
+
+
@@ -1150,6 +1767,19 @@ function SessionDropdownRow(props: {
}) {
const language = useLanguage()
const title = createMemo(() => sessionTitle(props.session.title) || props.session.id)
+ // #1525 B1: a remote peer session shows the owner machine name as a badge and
+ // hides local-only actions (archive is B2). Local/unowned rows are unbadged.
+ const badge = createMemo(() => deriveSessionBadge(props.session as DropdownSession))
+ const isRemote = createMemo(() => isRemotePeerSession(props.session as DropdownSession))
+ // #1544 (slice 4): the control state channel for THIS remote row. When control
+ // is NOT held, the write affordance is DISABLED with a visible reason chip
+ // (failClosedChip) — never a live erroring button. When held, the owner-routed
+ // ARCHIVE affordance appears. Delete is NOT offered on an active row: a remote
+ // session (like a local one) is archived first, then deleted from the Archived
+ // tab (ArchivedSessionDropdownRow, owner-routed via deleteArchivedSession).
+ const control = createMemo(() => readSessionControl(props.session as DropdownSession))
+ const chip = createMemo(() => failClosedChip(control()))
+ const canWrite = createMemo(() => writeAffordanceEnabled(control()))
const rowServer = useServer()
// #1292 hover prewarm: a hovered row is a click away — pull its first
// message page the instant the pointer lands, so the open renders from
@@ -1196,23 +1826,87 @@ function SessionDropdownRow(props: {
{title()}
+ {/* The owner machine tag — the row's resting identity. On hover the
+ action cluster to the right reveals IN FLOW (a 0fr→1fr grid column,
+ so it reserves exactly the controls' rendered width), which shrinks
+ this button and slides the whole tag left by that width. The tag is
+ never faded and never truncated on hover — it just moves over to make
+ room, and the archive stays pinned at the row's right edge. */}
+
+
+
+ {badge()}
+
+
-
+
+ {/* #1544 (slice 4): a REMOTE peer row's control affordances. Control NOT
+ held → the write affordance is DISABLED with a visible reason chip
+ (failClosedChip, derived from the SoT reason) — never a live erroring
+ button. Control HELD → the owner-routed ARCHIVE. Delete lives on the
+ Archived tab (remote matches local: archive first, then delete). */}
+
+