@phnx-labs/agents-cli 1.22.73 → 1.22.75

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,72 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.75
4
+
5
+ - **Account labels are per-harness, and survive a daemon publish (PHNX-3887).** One
6
+ identity signed into several harnesses can now carry the SAME label on each —
7
+ `claude#icloud`, `codex#icloud`, and `grok#icloud` all resolve to that harness's own
8
+ login, instead of the first-labelled harness squatting the name and forcing prefixes
9
+ (`cxicloud`, `gkicloud`) on the rest. Uniqueness is scoped to `(agent, label)`;
10
+ provider accounts, selected by bare name via `--account`, stay globally unique, and a
11
+ duplicate label within one harness is still refused. Separately, `agents accounts
12
+ label` wrote version-scoped rows into the central `agents.yaml` as a plain file write
13
+ with no commit, so the daemon's `publish <device> daemon state` tick — which committed
14
+ only the per-device doc and then rebased `--autostash` over the dirty central file —
15
+ silently destroyed them: every box lost its account labels on its next publish. The
16
+ publish now commits `agents.yaml` alongside the device doc, so labels persist and
17
+ actually sync fleet-wide as `accounts label --help` already promised. Source:
18
+ `cli/src/lib/account-registry.ts`, `cli/src/lib/fleet-shared-repo-sync.ts`.
19
+
20
+ - **Keep one daemon alive across concurrent stop/start (PHNX-3941).** Prevent a replacement daemon from exiting while stop holds the lifecycle lock, so concurrent stop/start cannot leave zero instances, and extend real-boot supervisor-wiring coverage to all 20 services. Source: `cli/src/lib/daemon/daemon.ts`.
21
+
22
+ - **`agents computer` can now drive a Linux GUI desktop over VNC/RFB (PHNX-3948).**
23
+ A third computer backend joins the macOS (Accessibility) and Windows (UIA) native
24
+ helpers — this one speaks the RFB/VNC protocol directly to an x11vnc/Xvnc server, so
25
+ a headless Linux desktop (or an LXD container exposing x11vnc on the host's Tailscale
26
+ IP) becomes drivable with no per-box native helper install. Select it with
27
+ `agents computer --vnc <host:port>` (port defaults to 5901; password via
28
+ `--vnc-password` or `COMPUTER_HELPER_VNC_PASSWORD`), then use the same verbs as the
29
+ other backends: `screenshot`, `click`, `right-click`, `type`, `type-text`, `key`,
30
+ `scroll`, `wait`. It is coordinate-based — RFB carries a framebuffer and pointer/key
31
+ events, not an accessibility tree — so the AX-tree verbs (`describe`, `get-text`,
32
+ `ax-action`, element-id targeting) and `launch` fail loud with a clear message rather
33
+ than silently no-op'ing. VNC authentication uses a bundled pure-JS DES (Node 24's
34
+ OpenSSL 3 keeps DES in the off-by-default legacy provider). Source:
35
+ `cli/src/lib/computer/rfb-client.ts`, `cli/src/lib/computer/des.ts`,
36
+ `cli/src/lib/computer/computer-rpc.ts`, `cli/src/commands/computer.ts`.
37
+
38
+ ## 1.22.74
39
+
40
+ - **Launching an agent no longer dirties your tracked `.gitignore` (PHNX-3718).**
41
+ The per-harness resource sync self-manages ignore rules for the generated
42
+ `.claude/`, `.cursor/`, `.codex/`, … dirs it writes on every launch. Those
43
+ rules used to be written into the tracked `<project>/.gitignore`, but they are
44
+ never committed upstream — so every launch left the repo with a permanent
45
+ `M .gitignore` (one managed block per harness that had run there) that in turn
46
+ blocked `git pull` with *"local changes to .gitignore would be overwritten by
47
+ merge"*. The managed block now lives in `.git/info/exclude` (git's per-clone,
48
+ uncommitted ignore file, resolved via `git rev-parse --git-path info/exclude`
49
+ so it works from a subdir, linked worktree, or submodule), keeping the
50
+ generated dirs hidden while leaving the working tree clean. On the next launch,
51
+ a repo already dirtied by the old behavior self-heals: the leftover managed
52
+ block is stripped from the tracked `.gitignore` (hand-written rules untouched;
53
+ an untracked file that held only our block is removed). Source:
54
+ `cli/src/lib/project-resources.ts`.
55
+
56
+ - **`sessions preview` of a remote session no longer dead-ends on the dispatcher
57
+ (PHNX-3890).** A box that launched a session running on another device kept a
58
+ transcript-less live row for it whose machine defaulted to itself, so
59
+ `agents sessions preview <full-uuid>` — and the interactive browser's preview
60
+ pane — rendered `Live session — full transcript not indexed here.` instead of
61
+ fetching the owning peer's digest. A passive peer rendered the same session
62
+ fine. The launcher shim is now reconciled against the fleet's own view of which
63
+ device runs the session, and a full-UUID hit only skips the fleet fan-out when
64
+ this box can actually answer for it (a transcript on disk, or a row that already
65
+ names another device). Remote previews fill in asynchronously over SSH; a
66
+ locally readable transcript or synced mirror still renders with no hop, and an
67
+ unreachable owner still fails loud. Source: `cli/src/commands/sessions.ts`,
68
+ `cli/src/lib/session/live-metadata.ts`.
69
+
3
70
  ## 1.22.73
4
71
 
5
72
  - **Ctrl-C works again while `agents sessions` is reaching other machines (PHNX-3791).** The static session list, `Searching the fleet…`, and the cloud session/fetch paths wrap their long SSH/network waits in `ora(...)`. ora defaults to `discardStdin: true`, which on a TTY flips stdin into raw mode for the spinner's lifetime (via `stdin-discarder`) — and raw mode disables the terminal's own ISIG, so a Ctrl-C keystroke no longer raises SIGINT. ora's compensating "re-emit SIGINT when I read a 0x03 byte" only fires when stdin is in *flowing* mode, but a fresh `process.stdin` has `flowing === null`, `isPaused()` returns false, the discarder skips its `resume()`, and `prependListener('data')` doesn't auto-resume — so the byte is never read and SIGINT is never raised. The global handler in `index.ts` (`process.exit(130)`) was correct; it simply never fired. The user-visible result: a sweep that stalls on offline/unreachable boxes (each burning a ~12s SSH `ConnectTimeout`) could not be aborted — measured, `agents sessions --flat` had to be SIGKILLed. New `lib/spinner.ts#interruptibleSpinner` is a drop-in for `ora(text)` built with `discardStdin: false`: the terminal stays in cooked mode, Ctrl-C raises SIGINT normally (and the terminal SIGINTs the whole foreground group, reaping the outstanding `ssh` children), and the process exits promptly — only cosmetic stray-keystroke discarding during the spin is lost. The five cross-machine/network spinners in `sessions.ts` route through it; short local spinners are unchanged. Source: `cli/src/lib/spinner.ts`, `cli/src/lib/spinner.test.ts`, `cli/src/commands/sessions.ts`.
package/README.md CHANGED
@@ -1756,7 +1756,7 @@ Agents are defined in [src/lib/agents.ts](cli/src/lib/agents.ts) -- each is a co
1756
1756
  |---|---|
1757
1757
  | [`cli`](cli) | **The CLI** (this README) — version management, config sync, sessions, teams, cloud, browser, computer, secrets. |
1758
1758
  | [phnx-labs/agi-ext](https://github.com/phnx-labs/agi-ext) | **AGI EXT** — the VS Code extension (agent terminals as tabs + the Fleet dashboard). Moved to its own repo (RUSH-3189); separate product, own publish identity. |
1759
- | [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). |
1759
+ | [`native/computer-mac`](native/computer-mac) · [`native/computer-win`](native/computer-win) | Native backends behind `agents computer` — Swift (macOS Accessibility + screen capture) and C#/.NET (Windows UI Automation). A third, install-free backend drives a **Linux GUI desktop over VNC/RFB**: `agents computer --vnc <host:port> screenshot` (coordinate-based — screenshot/click/type/key/scroll against any x11vnc/Xvnc server). |
1760
1760
  | [`packages/session-tracker`](packages/session-tracker) | The `SessionStart` hook that writes live-session state **AGI EXT** reads back (agi-ext `src/core/liveSession.ts`) — not the CLI, which reads transcripts. |
1761
1761
 
1762
1762
  ## Contributing
@@ -10,6 +10,7 @@ import { resolveHelperExec, resolveSocketPath } from '../lib/computer/computer-r
10
10
  export declare function shouldBlockOffPlatform(opts: {
11
11
  platform: NodeJS.Platform;
12
12
  tcpConfigured: boolean;
13
+ vncConfigured?: boolean;
13
14
  device?: string;
14
15
  }): boolean;
15
16
  /**
@@ -3,7 +3,7 @@ import * as fs from 'fs';
3
3
  import * as os from 'os';
4
4
  import * as path from 'path';
5
5
  import { registerCommandGroups } from '../lib/help.js';
6
- import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
6
+ import { openComputerClient, resolveHelperApp, resolveHelperExec, resolveSocketPath, resolveLogPath, resolvePolicyPath, resolvePeersPath, resolveTcpEndpoint, resolveVncEndpoint, loadComputerAllowList, loadDefaultPeers, writeComputerPolicy, writeComputerPeers, } from '../lib/computer/computer-rpc.js';
7
7
  import { setupRemoteHelper, startRemoteTunnel, stopRemoteHelper, hydrateRemoteEnvFromState, readRemoteState, resolveRemoteDevice, REMOTE_TASK_NAME, WIN_HELPER_EXE, } from '../lib/computer/ssh-tunnel.js';
8
8
  import { sshExec } from '../lib/ssh-exec.js';
9
9
  import { encodePowershell } from '../lib/hosts/remote-cmd.js';
@@ -41,6 +41,8 @@ export function shouldBlockOffPlatform(opts) {
41
41
  return false;
42
42
  if (opts.tcpConfigured)
43
43
  return false; // remote (Windows) daemon over a tunnel
44
+ if (opts.vncConfigured)
45
+ return false; // RFB/VNC desktop (Linux GUI over the wire)
44
46
  if (opts.device)
45
47
  return false; // remote path resolves its own endpoint
46
48
  return true;
@@ -84,20 +86,37 @@ export function reconcileScreenshotExt(outPath, buf) {
84
86
  export function registerComputerCommand(program) {
85
87
  const computer = program
86
88
  .command('computer')
87
- .description('Drive macOS apps via Accessibility, or a remote Windows device with --device — list, screenshot, click, type')
89
+ .description('Drive macOS apps via Accessibility, a Linux GUI desktop with --vnc, or a remote Windows device with --device — screenshot, click, type')
90
+ // A VNC/RFB desktop is driven over the wire (--vnc host:port, e.g. an x11vnc
91
+ // server on a headless Linux box or an LXD container). Set it before the gate.
92
+ .option('--vnc <host:port>', 'Drive a GUI desktop over VNC/RFB (x11vnc/Xvnc; port defaults to 5901) instead of a native helper')
93
+ .option('--vnc-password <password>', 'VNC password for --vnc (or set COMPUTER_HELPER_VNC_PASSWORD)')
88
94
  // The whole subsystem is macOS Accessibility / TCC for LOCAL driving. Off
89
- // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, or
90
- // a `--device <name>` invocation). Fail fast with a clear message only when
91
- // neither remote path is available, instead of a downstream launchctl error.
95
+ // macOS it still works against a remote daemon (COMPUTER_HELPER_TCP set, a
96
+ // --vnc desktop, or a `--device <name>` invocation). Fail fast with a clear
97
+ // message only when no remote path is available, instead of a downstream error.
92
98
  .hook('preAction', async (_thisCommand, actionCommand) => {
93
- const device = actionCommand.opts().device;
99
+ const globals = actionCommand.optsWithGlobals();
100
+ // --vnc selects the RFB transport for every verb under this command.
101
+ if (globals.vnc) {
102
+ process.env.COMPUTER_HELPER_VNC = globals.vnc;
103
+ if (globals.vncPassword)
104
+ process.env.COMPUTER_HELPER_VNC_PASSWORD = globals.vncPassword;
105
+ }
106
+ const device = globals.device;
94
107
  // Verbs with --device reconnect to the tunnel `start --device` recorded;
95
108
  // this sets COMPUTER_HELPER_TCP so the shared client picks the TCP transport.
96
109
  if (device && !REMOTE_LIFECYCLE.has(actionCommand.name())) {
97
110
  hydrateRemoteEnvFromState(device);
98
111
  }
99
- if (shouldBlockOffPlatform({ platform: process.platform, tcpConfigured: resolveTcpEndpoint() != null, device })) {
112
+ if (shouldBlockOffPlatform({
113
+ platform: process.platform,
114
+ tcpConfigured: resolveTcpEndpoint() != null,
115
+ vncConfigured: resolveVncEndpoint() != null,
116
+ device,
117
+ })) {
100
118
  console.error('agents computer: macOS only for local driving — it uses the macOS Accessibility API.');
119
+ console.error('For a Linux GUI desktop over VNC: `agents computer --vnc <host:port> screenshot`.');
101
120
  console.error('For a remote Windows device: register it with `agents devices`, then use --device (or set COMPUTER_HELPER_TCP).');
102
121
  process.exit(1);
103
122
  }
@@ -896,9 +896,58 @@ export declare function metadataResolveOutcome(localMatches: SessionMeta[], remo
896
896
  sessions: SessionMeta[];
897
897
  unreachable: string[];
898
898
  }, selector: string): MetadataResolveOutcome;
899
- /** Injectable dependency for the live-registry cold-miss fallback (RUSH-2682). */
899
+ /**
900
+ * Whether a local candidate answers a full-UUID selector **definitively** — i.e.
901
+ * whether finding it on this box is the whole answer, so the fleet fan-out can be
902
+ * skipped (PHNX-3890).
903
+ *
904
+ * Two shapes qualify, and both are claims this box can actually back:
905
+ *
906
+ * - **A real transcript on this disk** (`filePath`). It renders locally, whoever
907
+ * owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
908
+ * - **A genuine peer attribution** (`machine` names another box). The row already
909
+ * routes the read to its owner through `transcriptOnPeerOf`, so the peer that
910
+ * would answer the fan-out is the peer the render already dials.
911
+ *
912
+ * What does NOT qualify is the launcher-shim shape: a transcript-less live row
913
+ * whose `machine` is this box only because it DEFAULTED there
914
+ * (`activeSessionToSessionMeta`'s `active.machine ?? self`,
915
+ * `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
916
+ * exactly that row for a session whose agent and transcript live on a peer: the
917
+ * launch process is here, the conversation is not. Treating it as definitive is
918
+ * what dead-ended `agents sessions preview <full-uuid>` on the local
919
+ * "Live session — full transcript not indexed here." stub while a passive peer —
920
+ * which has no local row at all, so it fans out — rendered the real digest.
921
+ * Process locality is not transcript locality.
922
+ */
923
+ export declare function isLocallyDefinitiveMatch(session: SessionMeta, self: string): boolean;
924
+ /**
925
+ * Drop the launcher-shim local rows that a peer has since answered for
926
+ * (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
927
+ * machine and every consumer reads `hits[0]`, with local rows passed first — so
928
+ * simply fanning out is not enough: the self-defaulted shim would still win the
929
+ * attribution and route the read back to a box with no transcript. When the peer
930
+ * that actually owns the session has answered the sweep, its row is the strictly
931
+ * better one, and the shim carries no information the candidate loses (same
932
+ * logical id, so the candidate — and any ambiguity it is part of — survives
933
+ * through the remote hit).
934
+ *
935
+ * A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
936
+ * readable transcript or a synced mirror still renders here with no SSH hop. When
937
+ * no peer answered for that id, the shim is kept: with no fleet evidence, a
938
+ * transcript-less self-attributed row is indistinguishable from a session THIS box
939
+ * just started, and dropping it would re-break the cold-index lookup RUSH-2682
940
+ * fixed.
941
+ */
942
+ export declare function preferOwnerAttribution(localMatches: SessionMeta[], remoteSessions: SessionMeta[], self: string): SessionMeta[];
943
+ /** Injectable dependencies for the live-registry cold-miss fallback (RUSH-2682)
944
+ * and the fleet-attribution reconciliation (PHNX-3890). */
900
945
  export type LiveMetadataDeps = {
901
946
  loadActive?: typeof loadLocalActiveSessions;
947
+ /** The fleet-active snapshot rows used to attribute a self-defaulted launcher
948
+ * shim to its true execution host. Defaults to the cached fleet snapshot; the
949
+ * cache is warmed by any fleet-wide `agents sessions --active`. */
950
+ loadFleetActive?: () => ActiveSession[];
902
951
  };
903
952
  /**
904
953
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when
@@ -29,14 +29,14 @@ import { mapPanesToTargets, listClients } from '../lib/tmux/session.js';
29
29
  import { resolveViewingIn, viewingInLabel } from '../lib/session/viewing-in.js';
30
30
  import { machineId, normalizeHost } from '../lib/session/sync/config.js';
31
31
  import { gatherRemoteActive, NO_FANOUT_ENV } from '../lib/session/remote-active.js';
32
- import { loadFleetActiveSessions, loadLocalActiveSessions, } from '../lib/session/session-cache.js';
32
+ import { loadFleetActiveSessions, loadLocalActiveSessions, readActiveSessionsCache, } from '../lib/session/session-cache.js';
33
33
  import { gatherRemoteList, gatherRemoteToolProgramCounts, gatherRemoteToolSearch, runOnPeer } from '../lib/session/remote-list.js';
34
34
  import { gatherRemoteAgentsJson } from '../lib/remote-agents-json.js';
35
35
  import { stringWidth, truncateToWidth, padToWidth, terminalWidth } from '../lib/session/width.js';
36
36
  import { inferSessionState } from '../lib/session/state.js';
37
37
  import { discoverSessions, queryIndexedSessions, countSessionsInScope, resolveSessionById, isCompleteSessionId, looksLikeSessionId, searchContentIndex, getSessionRoots, scopeToManaged } from '../lib/session/discover.js';
38
38
  import { findSessionsById, querySessions, readSessionContent, readArchivedSessionPreview } from '../lib/session/db.js';
39
- import { liveSessionMetas } from '../lib/session/live-metadata.js';
39
+ import { liveSessionMetas, fleetExecutionMachineById, reconcileLiveMetaMachine } from '../lib/session/live-metadata.js';
40
40
  import { filterTeamSessions, shouldShowTeamSessions, safeTeamText, groupSessionsByTeam, NO_TEAM_GROUP_KEY, } from '../lib/session/team-filter.js';
41
41
  import { parseSession } from '../lib/session/parse.js';
42
42
  import { runRemoteSessions, buildForwardedArgs, ensureWholeIndex } from '../lib/session/remote.js';
@@ -4844,6 +4844,59 @@ export function metadataResolveOutcome(localMatches, remote, selector) {
4844
4844
  return { kind: 'ambiguous', candidates };
4845
4845
  return { kind: 'resolved', session: candidates[0].hits[0].session };
4846
4846
  }
4847
+ /**
4848
+ * Whether a local candidate answers a full-UUID selector **definitively** — i.e.
4849
+ * whether finding it on this box is the whole answer, so the fleet fan-out can be
4850
+ * skipped (PHNX-3890).
4851
+ *
4852
+ * Two shapes qualify, and both are claims this box can actually back:
4853
+ *
4854
+ * - **A real transcript on this disk** (`filePath`). It renders locally, whoever
4855
+ * owns it — a local session or a fleet-synced mirror — so no SSH hop is needed.
4856
+ * - **A genuine peer attribution** (`machine` names another box). The row already
4857
+ * routes the read to its owner through `transcriptOnPeerOf`, so the peer that
4858
+ * would answer the fan-out is the peer the render already dials.
4859
+ *
4860
+ * What does NOT qualify is the launcher-shim shape: a transcript-less live row
4861
+ * whose `machine` is this box only because it DEFAULTED there
4862
+ * (`activeSessionToSessionMeta`'s `active.machine ?? self`,
4863
+ * `computeLocalMetadataMatches`'s `machine || localMachine`). A dispatcher holds
4864
+ * exactly that row for a session whose agent and transcript live on a peer: the
4865
+ * launch process is here, the conversation is not. Treating it as definitive is
4866
+ * what dead-ended `agents sessions preview <full-uuid>` on the local
4867
+ * "Live session — full transcript not indexed here." stub while a passive peer —
4868
+ * which has no local row at all, so it fans out — rendered the real digest.
4869
+ * Process locality is not transcript locality.
4870
+ */
4871
+ export function isLocallyDefinitiveMatch(session, self) {
4872
+ if (session.filePath)
4873
+ return true;
4874
+ return !!session.machine && session.machine !== self;
4875
+ }
4876
+ /**
4877
+ * Drop the launcher-shim local rows that a peer has since answered for
4878
+ * (PHNX-3890). `fleetCandidatesByQuery` groups a logical session's copies per
4879
+ * machine and every consumer reads `hits[0]`, with local rows passed first — so
4880
+ * simply fanning out is not enough: the self-defaulted shim would still win the
4881
+ * attribution and route the read back to a box with no transcript. When the peer
4882
+ * that actually owns the session has answered the sweep, its row is the strictly
4883
+ * better one, and the shim carries no information the candidate loses (same
4884
+ * logical id, so the candidate — and any ambiguity it is part of — survives
4885
+ * through the remote hit).
4886
+ *
4887
+ * A row that is {@link isLocallyDefinitiveMatch} is never dropped, so a locally
4888
+ * readable transcript or a synced mirror still renders here with no SSH hop. When
4889
+ * no peer answered for that id, the shim is kept: with no fleet evidence, a
4890
+ * transcript-less self-attributed row is indistinguishable from a session THIS box
4891
+ * just started, and dropping it would re-break the cold-index lookup RUSH-2682
4892
+ * fixed.
4893
+ */
4894
+ export function preferOwnerAttribution(localMatches, remoteSessions, self) {
4895
+ if (remoteSessions.length === 0)
4896
+ return localMatches;
4897
+ const answeredByPeer = new Set(remoteSessions.map(session => session.id.toLowerCase()));
4898
+ return localMatches.filter(session => isLocallyDefinitiveMatch(session, self) || !answeredByPeer.has(session.id.toLowerCase()));
4899
+ }
4847
4900
  /**
4848
4901
  * Local metadata candidates for a selector: the indexed SQLite rows, plus — when
4849
4902
  * an id-shaped selector misses the index entirely — the live-session registry
@@ -4881,7 +4934,20 @@ export async function computeLocalMetadataMatches(selector, scope, deps = {}) {
4881
4934
  */
4882
4935
  export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4883
4936
  const load = deps.loadActive ?? loadLocalActiveSessions;
4884
- const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(metas, scope), selector, scope);
4937
+ const loadFleet = deps.loadFleetActive ?? (() => readActiveSessionsCache('fleet')?.sessions ?? []);
4938
+ // A launcher-shim row whose `machine` self-defaulted to this box gets its true
4939
+ // EXECUTION host from the fleet snapshot, so a session dispatched to a peer is
4940
+ // read on that peer instead of dead-ending on the local stub (PHNX-3890). The
4941
+ // read is best-effort: an absent/cold snapshot leaves the row untouched and the
4942
+ // fleet fan-out in resolveSessionMetadataValue supplies the owner instead.
4943
+ let fleetExecMachine;
4944
+ try {
4945
+ fleetExecMachine = fleetExecutionMachineById(loadFleet());
4946
+ }
4947
+ catch {
4948
+ fleetExecMachine = new Map();
4949
+ }
4950
+ const match = (metas) => resolveIndexedMetadataRows(applyScopeFilters(reconcileLiveMetaMachine(metas, fleetExecMachine, self), scope), selector, scope);
4885
4951
  try {
4886
4952
  const cached = liveSessionMetas((await load()).sessions, self, Date.now());
4887
4953
  const hit = match(cached);
@@ -4898,16 +4964,24 @@ export async function liveMetadataMatches(selector, scope, self, deps = {}) {
4898
4964
  * Full UUIDs hit the local SQLite index without any SSH fan-out. */
4899
4965
  export async function resolveSessionMetadataValue(selector, scope = {}, deps = { gatherRemoteList }) {
4900
4966
  const localMatches = await computeLocalMetadataMatches(selector, scope, deps);
4967
+ const localMachine = machineId();
4901
4968
  // A full-UUID local hit resolves with ZERO SSH: a UUID is globally unique, so
4902
4969
  // finding it locally is the whole answer and no peer is dialed. (A label is
4903
4970
  // NOT resolved local-only here — it can collide with a same-label session on a
4904
4971
  // peer, so it must consult the fleet; see isDefinitiveMatch, RUSH-2203.) The
4905
4972
  // local index lookup is synchronous and completes before any fan-out spawns,
4906
4973
  // so this local hit never waits on a peer.
4974
+ //
4975
+ // "Found it locally" must mean this box can actually ANSWER for it, though —
4976
+ // see isLocallyDefinitiveMatch. A transcript-less launcher shim for a session
4977
+ // executing on a peer is a local row that is not a local answer, and
4978
+ // short-circuiting on it skipped the one fan-out that could have named the
4979
+ // owner (PHNX-3890).
4907
4980
  if (FULL_SESSION_ID_RE.test(selector)) {
4908
4981
  const localOutcome = metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
4909
- if (localOutcome.kind === 'resolved')
4982
+ if (localOutcome.kind === 'resolved' && isLocallyDefinitiveMatch(localOutcome.session, localMachine)) {
4910
4983
  return localOutcome;
4984
+ }
4911
4985
  }
4912
4986
  if (scope.local === true)
4913
4987
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [] }, selector);
@@ -4920,7 +4994,7 @@ export async function resolveSessionMetadataValue(selector, scope = {}, deps = {
4920
4994
  const remote = await deps.gatherRemoteList(forwarded, scope.hosts, selectorAllowsEarlyExit(selector)
4921
4995
  ? { isDefinitive: (session) => isDefinitiveMatch(session, selector) }
4922
4996
  : undefined);
4923
- return metadataResolveOutcome(localMatches, remote, selector);
4997
+ return metadataResolveOutcome(preferOwnerAttribution(localMatches, remote.sessions, localMachine), remote, selector);
4924
4998
  }
4925
4999
  catch (error) {
4926
5000
  return metadataResolveOutcome(localMatches, { sessions: [], unreachable: [error?.message ?? 'fleet fan-out'] }, selector);
@@ -187,11 +187,32 @@ function nativeRowsForNameOrId(meta, name) {
187
187
  return [];
188
188
  return nativeIdentityRows(meta, found.agent, found.identityKey);
189
189
  }
190
- function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
190
+ /**
191
+ * Native label names are unique per HARNESS, not globally (PHNX-3887).
192
+ *
193
+ * One human identity is commonly signed into several harnesses —
194
+ * `muqsitnawaz@icloud.com` is a claude login AND a codex login AND a grok login.
195
+ * A global namespace let whichever harness was labelled first squat the good
196
+ * name, forcing prefixed junk (`cxicloud`, `gkicloud`) on the rest. Nothing is
197
+ * actually ambiguous at the point of use: the selector is `<harness>#<label>`,
198
+ * and `findUnifiedAccount` already disambiguates via `preferAgent`.
199
+ *
200
+ * Pass `agent` to scope the check to that harness. Omit it for management
201
+ * lookups with no harness in hand (rename/remove), which keep the old
202
+ * fleet-wide check so a rename cannot collide with an unrelated harness's row.
203
+ *
204
+ * Provider (non-native) accounts stay globally unique — they are selected by
205
+ * bare name via `--account`, with no harness to scope them by.
206
+ */
207
+ function assertUniqueUnifiedName(name, meta, doc, exceptIds, agent) {
191
208
  const needle = name.toLowerCase();
192
- const nativeHits = listNativeAccounts(meta).filter(account => account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle);
193
- if (nativeHits.some(account => !exceptIds?.has(account.id)))
194
- throw new Error(`Account '${name}' already exists.`);
209
+ const nativeHits = listNativeAccounts(meta).filter(account => (agent === undefined || account.agent === agent)
210
+ && (account.id === name || account.name.toLowerCase() === needle || account.identityLabel?.toLowerCase() === needle));
211
+ if (nativeHits.some(account => !exceptIds?.has(account.id))) {
212
+ throw new Error(agent === undefined
213
+ ? `Account '${name}' already exists.`
214
+ : `Account '${name}' already exists for the ${agent} harness.`);
215
+ }
195
216
  // Same laziness as findUnifiedAccount: a native row that already owns this
196
217
  // name (even one we are mutating) means we never open the provider store.
197
218
  if (nativeHits.length > 0)
@@ -203,7 +224,7 @@ function assertUniqueUnifiedName(name, meta, doc, exceptIds) {
203
224
  export function addNativeAccount(name, agent, identityKey, identityLabel, scope) {
204
225
  assertNativeLabel(name);
205
226
  const meta = readMeta();
206
- assertUniqueUnifiedName(name, meta);
227
+ assertUniqueUnifiedName(name, meta, undefined, undefined, agent);
207
228
  const duplicate = listNativeAccounts(meta).find(account => account.agent === agent && account.identityKey === identityKey);
208
229
  if (duplicate)
209
230
  throw new Error(`This ${agent} login is already named '${duplicate.name}'.`);
@@ -240,7 +261,7 @@ export function labelNativeAccount(agent, identityKey, identityLabel, label, sco
240
261
  assertNativeLabel(resolvedLabel);
241
262
  const meta = readMeta();
242
263
  const matches = nativeIdentityRows(meta, agent, identityKey);
243
- assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)));
264
+ assertUniqueUnifiedName(resolvedLabel, meta, undefined, new Set(matches.map(account => account.id)), agent);
244
265
  if (matches.length === 0)
245
266
  return addNativeAccount(resolvedLabel, agent, identityKey, identityLabel, scope);
246
267
  // Sweep every row for this identity (PHNX-3206), routing the whole sweep to the
@@ -7,7 +7,7 @@
7
7
  import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
8
8
  /**
9
9
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
10
- * the token has not passed its expires_at timestamp (Unix seconds). A missing
10
+ * the token has not passed its expires_at timestamp (Unix milliseconds). A missing
11
11
  * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
12
12
  * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
13
13
  * to override the default path in tests.
@@ -19,7 +19,7 @@ const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
19
19
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
20
20
  /**
21
21
  * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
22
- * the token has not passed its expires_at timestamp (Unix seconds). A missing
22
+ * the token has not passed its expires_at timestamp (Unix milliseconds). A missing
23
23
  * expires_at, or `expires_at: 0` (a non-expiring Phoenix `pid_` bearer), is
24
24
  * treated as non-expired (see isRushSessionExpired, PHNX-3645). Pass yamlPath
25
25
  * to override the default path in tests.
@@ -58,7 +58,7 @@ export function readToken(yamlPath = USER_YAML) {
58
58
  }
59
59
  const expiresAt = data.session?.expires_at;
60
60
  if (isRushSessionExpired(expiresAt)) {
61
- const expiredAt = new Date(expiresAt * 1000).toISOString();
61
+ const expiredAt = new Date(expiresAt).toISOString();
62
62
  throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
63
63
  }
64
64
  return token;
@@ -25,10 +25,15 @@ export declare function resolveTcpEndpoint(): {
25
25
  port: number;
26
26
  token: string | null;
27
27
  } | null;
28
+ export declare function resolveVncEndpoint(): {
29
+ host: string;
30
+ port: number;
31
+ password: string;
32
+ } | null;
28
33
  export declare function openComputerClient(): ComputerClient;
29
34
  export declare const RPC_TIMEOUT_MS = 30000;
30
35
  export declare function resolveRpcTimeoutMs(env: string | undefined): number;
31
36
  export declare function describeTransport(): {
32
- kind: 'socket' | 'stdio' | 'tcp' | 'none';
37
+ kind: 'socket' | 'stdio' | 'tcp' | 'vnc' | 'none';
33
38
  path: string | null;
34
39
  };
@@ -17,6 +17,7 @@ import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { fileURLToPath } from 'url';
19
19
  import { getHelpersDir, getLogsDir, getUserPermissionsDir, getPermissionsDir } from '../state.js';
20
+ import { RfbClient, parseVncEndpoint } from './rfb-client.js';
20
21
  // Resolve the socket path used by the launchd-managed daemon. Internal
21
22
  // scratch — lives under getHelpersDir() (~/.agents/.cache/helpers/),
22
23
  // matching browser.sock.
@@ -209,11 +210,28 @@ export function resolveTcpEndpoint() {
209
210
  const token = process.env.COMPUTER_HELPER_TOKEN;
210
211
  return { host: hostPart || '127.0.0.1', port, token: token && token.length > 0 ? token : null };
211
212
  }
213
+ // Resolve the VNC/RFB endpoint for driving a remote GUI desktop over the RFB
214
+ // protocol (an x11vnc/Xvnc server — e.g. a headless Linux desktop or an LXD
215
+ // container exposing x11vnc on the host's Tailscale IP). COMPUTER_HELPER_VNC is
216
+ // "host:port" (port defaults to 5901); COMPUTER_HELPER_VNC_PASSWORD is the VNC
217
+ // password. Unlike the native helpers this transport carries a framebuffer, not
218
+ // an accessibility tree, so it is coordinate-based (see rfb-client.ts).
219
+ export function resolveVncEndpoint() {
220
+ const parsed = parseVncEndpoint(process.env.COMPUTER_HELPER_VNC);
221
+ if (!parsed)
222
+ return null;
223
+ return { ...parsed, password: process.env.COMPUTER_HELPER_VNC_PASSWORD ?? '' };
224
+ }
212
225
  // Pick the best transport. Precedence:
213
- // 1. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
214
- // 2. the macOS launchd socket if it exists.
215
- // 3. spawning the helper as a subprocess (legacy/dev fallback).
226
+ // 1. COMPUTER_HELPER_VNC -> an RFB/VNC desktop (Linux GUI over the wire).
227
+ // 2. COMPUTER_HELPER_TCP -> the Windows daemon over a (tunneled) TCP port.
228
+ // 3. the macOS launchd socket if it exists.
229
+ // 4. spawning the helper as a subprocess (legacy/dev fallback).
216
230
  export function openComputerClient() {
231
+ const vnc = resolveVncEndpoint();
232
+ if (vnc) {
233
+ return new RfbClient(vnc.host, vnc.port, vnc.password);
234
+ }
217
235
  const tcp = resolveTcpEndpoint();
218
236
  if (tcp) {
219
237
  return new TcpClient(tcp.host, tcp.port, tcp.token);
@@ -415,6 +433,8 @@ class StdioClient extends BaseClient {
415
433
  // .app) must not be told "no transport". `path` is null: the endpoint is an
416
434
  // env-configured host:port, not an on-disk path.
417
435
  export function describeTransport() {
436
+ if (resolveVncEndpoint())
437
+ return { kind: 'vnc', path: null };
418
438
  if (resolveTcpEndpoint())
419
439
  return { kind: 'tcp', path: null };
420
440
  const sockPath = resolveSocketPath();
@@ -0,0 +1 @@
1
+ export declare function desEncryptBlock(key8: Buffer, block8: Buffer): Buffer;
@@ -0,0 +1,114 @@
1
+ // Minimal, dependency-free single-block DES (ECB) — used only for VNC
2
+ // authentication's challenge/response. Node 24 ships OpenSSL 3, whose DES lives
3
+ // in the legacy provider that is off by default, so `createCipheriv('des-ecb')`
4
+ // throws `digital envelope routines::unsupported`. VNC clients have always
5
+ // bundled their own DES for exactly this reason. This is a textbook FIPS-46 DES
6
+ // implementation over bit arrays — verified against the standard test vector in
7
+ // des.test.ts. It encrypts one 8-byte block; VNC auth encrypts the two halves
8
+ // of the 16-byte challenge independently (see rfb-client.ts).
9
+ const IP = [
10
+ 58, 50, 42, 34, 26, 18, 10, 2, 60, 52, 44, 36, 28, 20, 12, 4,
11
+ 62, 54, 46, 38, 30, 22, 14, 6, 64, 56, 48, 40, 32, 24, 16, 8,
12
+ 57, 49, 41, 33, 25, 17, 9, 1, 59, 51, 43, 35, 27, 19, 11, 3,
13
+ 61, 53, 45, 37, 29, 21, 13, 5, 63, 55, 47, 39, 31, 23, 15, 7,
14
+ ];
15
+ const FP = [
16
+ 40, 8, 48, 16, 56, 24, 64, 32, 39, 7, 47, 15, 55, 23, 63, 31,
17
+ 38, 6, 46, 14, 54, 22, 62, 30, 37, 5, 45, 13, 53, 21, 61, 29,
18
+ 36, 4, 44, 12, 52, 20, 60, 28, 35, 3, 43, 11, 51, 19, 59, 27,
19
+ 34, 2, 42, 10, 50, 18, 58, 26, 33, 1, 41, 9, 49, 17, 57, 25,
20
+ ];
21
+ const E = [
22
+ 32, 1, 2, 3, 4, 5, 4, 5, 6, 7, 8, 9, 8, 9, 10, 11, 12, 13, 12, 13, 14, 15, 16, 17,
23
+ 16, 17, 18, 19, 20, 21, 20, 21, 22, 23, 24, 25, 24, 25, 26, 27, 28, 29, 28, 29, 30, 31, 32, 1,
24
+ ];
25
+ const P = [
26
+ 16, 7, 20, 21, 29, 12, 28, 17, 1, 15, 23, 26, 5, 18, 31, 10,
27
+ 2, 8, 24, 14, 32, 27, 3, 9, 19, 13, 30, 6, 22, 11, 4, 25,
28
+ ];
29
+ const PC1 = [
30
+ 57, 49, 41, 33, 25, 17, 9, 1, 58, 50, 42, 34, 26, 18, 10, 2, 59, 51, 43, 35, 27, 19, 11, 3, 60, 52, 44, 36,
31
+ 63, 55, 47, 39, 31, 23, 15, 7, 62, 54, 46, 38, 30, 22, 14, 6, 61, 53, 45, 37, 29, 21, 13, 5, 28, 20, 12, 4,
32
+ ];
33
+ const PC2 = [
34
+ 14, 17, 11, 24, 1, 5, 3, 28, 15, 6, 21, 10, 23, 19, 12, 4, 26, 8, 16, 7, 27, 20, 13, 2,
35
+ 41, 52, 31, 37, 47, 55, 30, 40, 51, 45, 33, 48, 44, 49, 39, 56, 34, 53, 46, 42, 50, 36, 29, 32,
36
+ ];
37
+ const SHIFTS = [1, 1, 2, 2, 2, 2, 2, 2, 1, 2, 2, 2, 2, 2, 2, 1];
38
+ const SBOX = [
39
+ [14, 4, 13, 1, 2, 15, 11, 8, 3, 10, 6, 12, 5, 9, 0, 7, 0, 15, 7, 4, 14, 2, 13, 1, 10, 6, 12, 11, 9, 5, 3, 8, 4, 1, 14, 8, 13, 6, 2, 11, 15, 12, 9, 7, 3, 10, 5, 0, 15, 12, 8, 2, 4, 9, 1, 7, 5, 11, 3, 14, 10, 0, 6, 13],
40
+ [15, 1, 8, 14, 6, 11, 3, 4, 9, 7, 2, 13, 12, 0, 5, 10, 3, 13, 4, 7, 15, 2, 8, 14, 12, 0, 1, 10, 6, 9, 11, 5, 0, 14, 7, 11, 10, 4, 13, 1, 5, 8, 12, 6, 9, 3, 2, 15, 13, 8, 10, 1, 3, 15, 4, 2, 11, 6, 7, 12, 0, 5, 14, 9],
41
+ [10, 0, 9, 14, 6, 3, 15, 5, 1, 13, 12, 7, 11, 4, 2, 8, 13, 7, 0, 9, 3, 4, 6, 10, 2, 8, 5, 14, 12, 11, 15, 1, 13, 6, 4, 9, 8, 15, 3, 0, 11, 1, 2, 12, 5, 10, 14, 7, 1, 10, 13, 0, 6, 9, 8, 7, 4, 15, 14, 3, 11, 5, 2, 12],
42
+ [7, 13, 14, 3, 0, 6, 9, 10, 1, 2, 8, 5, 11, 12, 4, 15, 13, 8, 11, 5, 6, 15, 0, 3, 4, 7, 2, 12, 1, 10, 14, 9, 10, 6, 9, 0, 12, 11, 7, 13, 15, 1, 3, 14, 5, 2, 8, 4, 3, 15, 0, 6, 10, 1, 13, 8, 9, 4, 5, 11, 12, 7, 2, 14],
43
+ [2, 12, 4, 1, 7, 10, 11, 6, 8, 5, 3, 15, 13, 0, 14, 9, 14, 11, 2, 12, 4, 7, 13, 1, 5, 0, 15, 10, 3, 9, 8, 6, 4, 2, 1, 11, 10, 13, 7, 8, 15, 9, 12, 5, 6, 3, 0, 14, 11, 8, 12, 7, 1, 14, 2, 13, 6, 15, 0, 9, 10, 4, 5, 3],
44
+ [12, 1, 10, 15, 9, 2, 6, 8, 0, 13, 3, 4, 14, 7, 5, 11, 10, 15, 4, 2, 7, 12, 9, 5, 6, 1, 13, 14, 0, 11, 3, 8, 9, 14, 15, 5, 2, 8, 12, 3, 7, 0, 4, 10, 1, 13, 11, 6, 4, 3, 2, 12, 9, 5, 15, 10, 11, 14, 1, 7, 6, 0, 8, 13],
45
+ [4, 11, 2, 14, 15, 0, 8, 13, 3, 12, 9, 7, 5, 10, 6, 1, 13, 0, 11, 7, 4, 9, 1, 10, 14, 3, 5, 12, 2, 15, 8, 6, 1, 4, 11, 13, 12, 3, 7, 14, 10, 15, 6, 8, 0, 5, 9, 2, 6, 11, 13, 8, 1, 4, 10, 7, 9, 5, 0, 15, 14, 2, 3, 12],
46
+ [13, 2, 8, 4, 6, 15, 11, 1, 10, 9, 3, 14, 5, 0, 12, 7, 1, 15, 13, 8, 10, 3, 7, 4, 12, 5, 6, 11, 0, 14, 9, 2, 7, 11, 4, 1, 9, 12, 14, 2, 0, 6, 10, 13, 15, 3, 5, 8, 2, 1, 14, 7, 4, 10, 8, 13, 15, 12, 9, 0, 3, 5, 6, 11],
47
+ ];
48
+ function bytesToBits(buf) {
49
+ const bits = [];
50
+ for (let i = 0; i < buf.length; i++) {
51
+ for (let b = 7; b >= 0; b--)
52
+ bits.push((buf[i] >> b) & 1);
53
+ }
54
+ return bits;
55
+ }
56
+ function bitsToBytes(bits) {
57
+ const out = Buffer.alloc(bits.length / 8);
58
+ for (let i = 0; i < out.length; i++) {
59
+ let byte = 0;
60
+ for (let b = 0; b < 8; b++)
61
+ byte = (byte << 1) | bits[i * 8 + b];
62
+ out[i] = byte;
63
+ }
64
+ return out;
65
+ }
66
+ function permute(bits, table) {
67
+ return table.map((pos) => bits[pos - 1]);
68
+ }
69
+ function keySchedule(key8) {
70
+ const keyBits = bytesToBits(key8);
71
+ const pc1 = permute(keyBits, PC1);
72
+ let c = pc1.slice(0, 28);
73
+ let d = pc1.slice(28, 56);
74
+ const subkeys = [];
75
+ for (let round = 0; round < 16; round++) {
76
+ const s = SHIFTS[round];
77
+ c = c.slice(s).concat(c.slice(0, s));
78
+ d = d.slice(s).concat(d.slice(0, s));
79
+ subkeys.push(permute(c.concat(d), PC2));
80
+ }
81
+ return subkeys;
82
+ }
83
+ function feistel(r, subkey) {
84
+ const expanded = permute(r, E);
85
+ const xored = expanded.map((bit, i) => bit ^ subkey[i]);
86
+ const out = [];
87
+ for (let box = 0; box < 8; box++) {
88
+ const chunk = xored.slice(box * 6, box * 6 + 6);
89
+ const row = (chunk[0] << 1) | chunk[5];
90
+ const col = (chunk[1] << 3) | (chunk[2] << 2) | (chunk[3] << 1) | chunk[4];
91
+ const val = SBOX[box][row * 16 + col];
92
+ for (let b = 3; b >= 0; b--)
93
+ out.push((val >> b) & 1);
94
+ }
95
+ return permute(out, P);
96
+ }
97
+ // Encrypt one 8-byte block with DES-ECB under an 8-byte key.
98
+ export function desEncryptBlock(key8, block8) {
99
+ if (key8.length !== 8)
100
+ throw new Error(`des key must be 8 bytes, got ${key8.length}`);
101
+ if (block8.length !== 8)
102
+ throw new Error(`des block must be 8 bytes, got ${block8.length}`);
103
+ const subkeys = keySchedule(key8);
104
+ const bits = permute(bytesToBits(block8), IP);
105
+ let l = bits.slice(0, 32);
106
+ let r = bits.slice(32, 64);
107
+ for (let round = 0; round < 16; round++) {
108
+ const f = feistel(r, subkeys[round]);
109
+ const next = l.map((bit, i) => bit ^ f[i]);
110
+ l = r;
111
+ r = next;
112
+ }
113
+ return bitsToBytes(permute(r.concat(l), FP));
114
+ }