feat(devices): --device interactive resolves the machine the human is at - #2949
Conversation
`--device auto` picks a box by load. This picks the one box someone is actually looking at, pinned as `interactive.host` — a key that already existed and that nothing consumed for routing. It exists because a skill cannot teach a host name. Guidance that says "deliver it to zion" is wrong on every other fleet and stale the moment the pin changes, so agents were left to infer the target or skip the step entirely. A fixed token is something a SKILL.md can state literally and have be correct everywhere. Resolved in the shared host matcher rather than per command, so `browser`, `run`, `teams`, `sessions` and `ssh` all get it from one place. Resolution happens at the dispatch site ahead of the isSelfHost check, exactly like `auto`: a pin naming this machine runs locally instead of self-SSHing, and the forwarded argv has the routing flag stripped, which is what keeps it from recursing. Never falls back to the local machine when unset — it refuses and names the command that fixes it. Rendering to a screen nobody is watching is the exact failure the sentinel prevents, and a silent local fallback would fail invisibly. Driven end to end against a temp HOME, not just unit-tested: no pin -> "needs an interactive host pinned, and none is set", exit 1 pinned -> "[agents] device=interactive → mac-mini", then dials it
Probed the case I had flagged as untested. `interactive.host = "interactive"` does NOT recurse — resolution happens once at the dispatch site — but it did resolve to a literal host named "interactive" and print [agents] device=interactive → interactive interactive: unreachable over SSH ... which is a confusing dead end for what is really a misconfiguration. Now treated as unset, so the caller prints the actionable "pin a host" message and exits 1. `auto` is rejected for the same reason: the two sentinels mean opposite things and chaining them is a mistake, not a request. Verified live: with the pin set to "interactive", `view --device interactive` now prints the unset message and exits 1.
Non-author review — CHANGES REQUESTEDReviewed at head Rubric: repo-root Conformance to the stated goal: PARTIAL. The changelog asserts "It works on every command that takes BLOCKER —
|
…ctive in teams Non-author review found two blockers. Both were mine, and the first was the band-aid the rubric names. - My previous commit rejected a reserved pin on READ. That is the wrong layer: `agents config set interactive.host auto` still succeeded, and every call site could then only report "none is set" — telling the user to run the command they had just run. The guard now lives in `assertValidDeviceName` as a shared RESERVED_DEVICE_NAMES set (auto, interactive, all), consumed by both the `interactive.host` validate and device registration, so a bad pin cannot be stored and a device cannot be registered under a name that would be unreachable. The read-side check stays purely defensive, for a config an older version wrote, and says so. Verified: `config set interactive.host auto` now fails with `"auto" is a reserved --device value, not a device name. Reserved: auto, interactive, all.` - `teams add --device interactive` pinned the teammate to an SSH hop into the local machine. `teams add`/`create` bail out of the fleet passthrough before the sentinel block runs, so teams.ts is the only resolver and it compared the literal string: on the pinned box `'interactive' !== machineId()` took the remote branch, hostName went truthy, and three local-only preflights were skipped. Added the arm next to the existing `auto` one it mirrors. Verified on the pinned box: `[teams] device=interactive → local`. - Adds the call-site coverage the review found missing entirely — three matchHost tests (resolves the pin, case-insensitive, throws with the fix command when unset). Mutation-checked: removing the sentinel from matchHost fails all three. My first attempt at that mutation silently did not apply and I nearly recorded a false green; the script now asserts the file actually changed. - docs/hosts.md's canonical grammar list now names both sentinels and their difference (auto picks by load, interactive picks the pinned box), and states that both names are reserved. - Moves the stray `it()` that sat outside its describe block. Companion guidance: phnx-labs/.agents-system#372 de-hardcodes "zion when interactive" in the loop skill.
|
Checking my own open question about reserving
// passthrough.ts:249-254
return (
hostFlag?.toLowerCase() === 'all' ||
deviceFlag?.toLowerCase() === 'all' || ...and the fan-out branch resolves devices from the registry without ever validating the literal string No existing device would be orphaned. Probed the live registry — no device is named Callers of $ npx vitest run src/lib/devices src/lib/hosts src/lib/device-config.test.ts
Tests 802 passed | 11 skipped (813)Still worth an independent check on the pool form ( |
Answering my own open question rather than leaving it for the reviewer. The pool form validated `interactive` (resolveHost now resolves it, so the check passed) but persisted the raw token, while the single `--device` pin resolves up front. That inconsistency matters for a pool specifically: it is a set of concrete machines the scheduler picks between, so a stored sentinel would let membership change silently the next time `interactive.host` is re-pinned, and every later friction message would quote "interactive" instead of a box the user can act on. Verified against a temp HOME — `teams create pooltest --devices interactive` with the pin on `testbox` persists: "devices": [ "testbox" ]
Review found that folding the reserved check into assertValidDeviceName put policy on every READ path. One pre-existing node named `auto` would abort the whole `agents devices sync` (the upsert loop has no per-node catch), and pure reads like `configuredDeviceRole` would throw — so a machine whose own hostname collided would fail its auth gate. That is a latent break I introduced, on a name nobody has today but which the code had always allowed. assertValidDeviceName is shape-only again and safe on reads. The reserved check moves to assertRegistrableDeviceName, called only where a name is being CHOSEN: upsertDevice, addIgnored, setDeviceDiscoveryStatus, and the interactive.host / usage.primary-host config validates. discovery-policy's read of the SYNCED discovery map stays shape-only, or one bad key would throw for the whole map. Caught while verifying: my blanket replace had made assertRegistrableDeviceName call itself. Probed both functions directly rather than trusting the edit — registrable rejects auto/interactive and accepts mac-mini; shape-only accepts `auto` (so reads keep working) and still rejects `bad name`. Also from the review: - `--device` help (commands/exec.ts:781) enumerates its accepted values and omitted the new one. It names `interactive` now. The reviewer cited lib/exec.ts; the string lives in commands/exec.ts. - The changelog claimed the sentinel "works on every command that takes --device". It does not: teams and ssh resolve it explicitly because they leave the passthrough first, and a few narrower surfaces reject the token (loudly, not mis-routed). Narrowed to what actually resolves. - Pool failures use the file's `pool-` friction prefix, matching pool-device-not-resolvable. Dropped a dead non-null assertion — dieFriction is typed `never`.
…not observed
I raised addIgnored as a possible mistake in my own last commit and then checked
it. It was — and so was upsertDevice.
Every production caller of both passes a name the fleet OBSERVED, not one a user
typed: `devices sync` upserts each tailnet node in a loop (sync.ts:197), the
discovery flow upserts an approved node (discovery-policy.ts:122), and the ignore
path feeds `plan.toIgnore` straight through (ssh.ts:451). One node named `auto`
would have thrown and aborted the whole sync — exactly the break the review
warned about, which I then re-introduced one layer down while fixing it.
A name the fleet observes is not a name anyone chose. Policy now sits at the two
kinds of place where someone actually picks one:
- `agents devices add <name>` (ssh.ts:2095)
- the config keys that point AT a device: interactive.host, usage.primary-host
upsertDevice, addIgnored and every discovery writer are shape-only again.
Verified both directions rather than reasoning about them:
devices add auto -> "auto" is a reserved --device value, not a device name.
devices add mybox -> Added device 'mybox'
upsertDevice('auto') -> accepted, sync survives
addIgnored('auto') -> accepted, ignore flow survives
802 passed | 11 skipped.
Re-review —
|
GitHub did not enqueue a workflow for 68fc0eb or e61aa9a — the tests workflow uses concurrency cancel-in-progress and two rapid pushes appear to have cancelled each other without a replacement being queued. Verified: PR head and origin head both e61aa9a, zero status checks, and no run exists for either sha while other branches were running normally. Close/reopen did not re-fire either. Empty commit to force a fresh synchronize event.
…e policy
Two things, both from review, and the first is the third silent no-op I have
shipped this session.
- The changelog narrowing existed only in a commit message. My `str.replace`
anchor did not match the file's actual line wrapping, the script printed
success anyway, and I reported it as done. Line 3 still promised "every
command that takes --device", which is false: teams and ssh resolve the
sentinel explicitly because they leave the passthrough first, and the narrower
surfaces reject the token. Now corrected IN THE FILE, with the edit asserting
the content actually changed before writing.
- The reserved-name split had zero tests, and its failure mode is invisible to
CI: it only bites a fleet that already owns a node named `auto`, so nothing
would have caught a re-merge of the two validators. Four tests in
registry.test.ts pin both halves — shape-only accepts an observed `auto` while
still rejecting `bad name`; registrable rejects the sentinels; and, the two
that matter, `upsertDevice('auto')` and `addIgnored('auto')` both resolve, so
`devices sync` cannot abort and a node can still be dismissed.
Mutation-checked: folding the reserved check back into assertValidDeviceName
fails 4 of them.
One of those tests caught a wrong assumption of my own while being written — I
expected `" Interactive "` to be rejected as reserved, but it fails the SHAPE
check first, which is correct. Asserting /reserved/ there would have been
asserting the wrong guard, so it now asserts the shape error explicitly.
Also corrects a claim I made to the reviewer: e5c4e2e is a DESCENDANT of
e76bd82, so the pool arm genuinely was uncommitted when it read the PR. My
"you read a stale sha" was wrong.
The PR went mergeStateStatus=DIRTY after my own browser PRs (#2928, #2932, #2934, #2937) landed on main, and GitHub stops running PR checks on a conflicted merge — which is why four pushes produced no CI run while other branches ran normally. Merging main in rather than rebasing (rebase is blocked on the agent shell, and a merge keeps the reviewed history intact). # Conflicts: # apps/cli/docs/command-reference.html
|
CI was not silent because of Correcting my own earlier diagnosis, since I stated it in a commit message: $ gh pr view 2949 --json mergeStateStatus
mergeState=DIRTYGitHub does not run PR checks on a conflicted merge, which is why four consecutive pushes ( The conflict is self-inflicted: this branch was cut before my own browser PRs (#2928, #2932, #2934, #2937) landed, and main moved 33 commits underneath it. Resolved by merging Post-merge, against the tree that now includes the viewer seam: $ npx tsc --noEmit -p tsconfig.json # clean
$ npx vitest run src/lib/devices src/lib/hosts src/lib/device-config.test.ts src/lib/open-url.test.ts
Tests 826 passed | 11 skipped (837)Worth noting for the review: the merge brings |
Re-review —
|
|
Answering the last question — "anything else in this PR that exists only in a commit message and not in a file. Assume there is." — by auditing rather than asserting, since that is exactly what I got wrong last round.
Strict validator call sites — exactly three, all user-typed: and every observed-name path is shape-only: State: |
…t write
None blocking; all three are the kind of thing that rots into a wrong belief.
- interactive-host.test.ts credited assertValidDeviceName with rejecting the
reserved set, inside the very test about splitting those two apart. It is
assertRegistrableDeviceName that rejects; the shape-only one deliberately
accepts an observed `auto` so `devices sync` keeps working.
- The changelog claimed the narrower --device surfaces "fail loud rather than
mis-routing". True for monitors and computer; `devices harnesses` takes a csv
and filters to an empty result at exit 0. The substantive half — nothing
mis-routes — holds, so this is wording. Now says what the code does.
- registry.test.ts's addIgnored('auto') was never swept. The ignore list lives
in `fleet.ignored` in agents.yaml, keyed off HOME rather than
AGENTS_DEVICES_DIR, so this file's beforeEach cannot reach it. Harmless while
it is the last test; a trap for whoever appends the next one. Now removed in a
finally.
Each edit asserts the file content actually changed before writing, and each is
verified by grep afterward — the discipline the three silent no-ops earned.
…372) `zion when interactive` is correct on exactly one fleet and stale the moment the pin changes. `--device interactive` resolves `interactive.host`, so the guidance stays right everywhere. Companion to phnx-labs/agi-cli#2949, which adds the sentinel. Co-authored-by: Muqsit <muqsit@getrush.ai>
--device autopicks a box by load. This picks the one box a human is actually looking at, pinned asinteractive.host.Why a sentinel and not just a host name
A skill cannot teach a host name. Guidance that says "deliver it to
zion" is wrong on every other fleet and stale the moment the pin changes, so agents were left inferring the target or skipping the step. A fixed token is something aSKILL.mdcan state literally and have be correct everywhere.interactive.hostalready existed (lib/device-config.ts, described as "Device that shows the user artifacts (browser opens, dashboards)") and nothing consumed it for routing — the only non-reporting reader wasresolveUsagePrimaryHost.Run result
Driven end to end against a temp
HOMErunning the realsrc/index.ts, not unit-tested in isolation:The second command then dials
mac-minifor real (it fails onControlPath too longonly because the tempHOMEmakes the socket path exceed 104 bytes — a fixture artifact, not the code).Design
Resolved in the shared host matcher, not per command.
browser,run,teams,sessionsandsshall get it from one place —matchHostinlib/hosts/registry.ts— plus the pre-parse dispatch site inlib/hosts/passthrough.tsand the one incommands/ssh.ts, which is whereautois handled too.No recursion risk. Resolution happens at the dispatch site ahead of the
isSelfHostcheck, exactly likeauto: a pin naming this machine runs locally instead of self-SSHing, and--deviceis stripped from the forwarded argv (passthrough.ts:183), so the remote never re-enters the passthrough.Never falls back to local when unset. It refuses and names the command that fixes it. Running on a headless worker with nobody watching is the exact failure the sentinel exists to prevent, and a silent local fallback would fail invisibly. Exit code 1, verified above.
Tests
isDeviceInteractivematches case- and space-insensitively, and specifically does not matchauto— the two sentinels mean opposite things and must stay distinct.resolveInteractiveDevicereturns null when unset, the pin when set. One test documents where the blank-host guard actually lives:interactive.hostvalidates the device name at write time, so a blank pin never reaches this module and the trim is defensive rather than the guard.Related
--device <host>; this gives that guidance a token it can state literally.agents browser showis a natural pairing (--device interactive+show).