@phnx-labs/agents-cli 1.22.52 → 1.22.53

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.
Files changed (69) hide show
  1. package/CHANGELOG.md +146 -0
  2. package/README.md +1 -1
  3. package/dist/commands/accounts.js +1 -1
  4. package/dist/commands/exec.js +16 -9
  5. package/dist/commands/fleet-capture.js +7 -0
  6. package/dist/commands/focus.js +2 -0
  7. package/dist/commands/go.js +2 -1
  8. package/dist/commands/sessions-inject.js +8 -3
  9. package/dist/commands/sessions-picker.js +2 -1
  10. package/dist/commands/sessions.js +30 -20
  11. package/dist/commands/ssh.js +35 -12
  12. package/dist/commands/sync.js +44 -0
  13. package/dist/lib/account-registry.d.ts +15 -5
  14. package/dist/lib/account-registry.js +150 -50
  15. package/dist/lib/answer-router.js +2 -1
  16. package/dist/lib/browser/profiles.d.ts +18 -0
  17. package/dist/lib/browser/profiles.js +26 -1
  18. package/dist/lib/browser/registry.d.ts +44 -14
  19. package/dist/lib/browser/registry.js +141 -45
  20. package/dist/lib/daemon/runner.js +10 -2
  21. package/dist/lib/device-config.js +3 -2
  22. package/dist/lib/devices/config-migration.js +147 -1
  23. package/dist/lib/devices/device-docs.d.ts +35 -0
  24. package/dist/lib/devices/device-docs.js +163 -0
  25. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  26. package/dist/lib/devices/discovery-policy.js +31 -21
  27. package/dist/lib/devices/registry.d.ts +11 -5
  28. package/dist/lib/devices/registry.js +46 -18
  29. package/dist/lib/exec.d.ts +60 -30
  30. package/dist/lib/exec.js +65 -27
  31. package/dist/lib/feed/feed.d.ts +10 -2
  32. package/dist/lib/feed/feed.js +12 -1
  33. package/dist/lib/hosts/dispatch.d.ts +4 -3
  34. package/dist/lib/hosts/dispatch.js +12 -8
  35. package/dist/lib/hosts/providers/local.d.ts +9 -3
  36. package/dist/lib/hosts/providers/local.js +23 -12
  37. package/dist/lib/hosts/reconnect.d.ts +7 -4
  38. package/dist/lib/hosts/reconnect.js +29 -25
  39. package/dist/lib/hosts/registry.js +4 -1
  40. package/dist/lib/hosts/remote-os.js +3 -1
  41. package/dist/lib/session/active.d.ts +10 -1
  42. package/dist/lib/session/active.js +7 -1
  43. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  44. package/dist/lib/session/actor-sidecar.js +2 -0
  45. package/dist/lib/session/db.d.ts +1 -1
  46. package/dist/lib/session/db.js +39 -3
  47. package/dist/lib/session/discover.js +7 -12
  48. package/dist/lib/session/live-metadata.js +1 -0
  49. package/dist/lib/session/pid-registry.d.ts +7 -0
  50. package/dist/lib/session/prompt.d.ts +15 -0
  51. package/dist/lib/session/prompt.js +21 -0
  52. package/dist/lib/session/types.d.ts +17 -0
  53. package/dist/lib/session/types.js +10 -0
  54. package/dist/lib/share/worker-template.js +12 -7
  55. package/dist/lib/state.d.ts +8 -0
  56. package/dist/lib/state.js +143 -11
  57. package/dist/lib/terminal/resolve.d.ts +7 -0
  58. package/dist/lib/terminal/resolve.js +41 -2
  59. package/dist/lib/traces/insights.d.ts +67 -0
  60. package/dist/lib/traces/insights.js +178 -0
  61. package/dist/lib/traces/phenotype.d.ts +67 -0
  62. package/dist/lib/traces/phenotype.js +437 -0
  63. package/dist/lib/traces/segments.d.ts +133 -0
  64. package/dist/lib/traces/segments.js +301 -0
  65. package/dist/lib/traces/sync.d.ts +33 -0
  66. package/dist/lib/traces/sync.js +11 -2
  67. package/dist/lib/types.d.ts +47 -1
  68. package/dist/lib/watchdog/runner.js +18 -4
  69. package/package.json +1 -1
package/CHANGELOG.md CHANGED
@@ -1,5 +1,151 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.22.53
4
+
5
+ - **Custom-harness runs launched via a profile now show their real harness name in agents sessions instead of 'claude' (PHNX-2935).**
6
+
7
+ - **Watchdog un-addressable stalls now include the same recovery command as `agents focus` (PHNX-3070).**
8
+ Rotate, needs-human declared-block, and nudge-refuse paths append `addressabilityRecoveryHint` so an un-addressable session prints `agents sessions resume <id>` / tmux wrap instead of a reason-only skip. Source: `cli/src/lib/watchdog/runner.ts`.
9
+
10
+ - **Un-addressable live sessions now surface a clear recovery hint at the point of use** — `agents focus`, `sessions inject`, and the session picker report when a bare interactive session has no addressable terminal rail (not tmux/iTerm/an IDE terminal/a pty sidecar) instead of failing silently, and the hint now names the session's real id in `agents sessions resume <id>` rather than a placeholder (PHNX-3070).
11
+
12
+ - **Vitest can no longer launch the developer's browser (PHNX-3072).** `tests/setup.ts`
13
+ sandboxed `HOME` but not desktop-opener binaries, so a test that reached a real
14
+ `spawn('open' | 'xdg-open')` opened the URL on the developer's machine — green on
15
+ Linux CI, where `xdg-open` is absent, and disruptive locally. The harness now
16
+ prepends stub `open` / `xdg-open` / `gnome-open` binaries to `PATH` so a spawn
17
+ cannot reach the real handler; an unauthorized spawn fails the file. Source:
18
+ `cli/tests/opener-sandbox.ts`.
19
+
20
+ - **Feed attention stamps `sourceCursor` at block-write time (PHNX-3073).**
21
+ `coveredByResolution` conservatively suppresses a candidate with no comparable
22
+ cursor when a tombstone exists, which is correct for a stale lifecycle re-read
23
+ but buried a genuinely new open block whenever `session.lastActivityMs` was
24
+ unresolvable (cloud / remote / index-lag) because neither writer stamped a
25
+ cursor. `buildDeclaredBlock` and the feed-publish hook now stamp
26
+ `sourceCursor.lastActivityMs` from the write instant, so a fresh generation
27
+ compares strictly newer than any prior tombstone. Source:
28
+ `cli/src/lib/feed/feed.ts`.
29
+
30
+ - **Cursor auto-titles go through the same skill-scaffolding cleaner as Claude (PHNX-3123).**
31
+ `readCursorMeta` took `chatMeta.title` verbatim, so a Cursor session whose
32
+ server-generated title echoed the injected `Base directory for this skill: …`
33
+ line kept that path as `SessionMeta.label` — the field that wins on every
34
+ surface for the session's whole life. Claude's `ai-title` was already collapsed
35
+ to `/<skill>` at label composition (PR #2995); that collapse now lives in one
36
+ shared helper (`cleanGeneratedSessionLabel`) and Cursor's title takes the same
37
+ path. An ordinary Cursor title, and one that merely names a `skills/…` path,
38
+ is unchanged. A Claude `/rename` (`custom-title`) is still never rewritten.
39
+ Codex / OpenCode / Kimi / Droid put auto-titles on `topic`, not `label`, so
40
+ they are out of this change. **Not retroactive:** already-indexed labels stay
41
+ until the transcript next changes (RUSH-3122). Source:
42
+ `cli/src/lib/session/prompt.ts`, `cli/src/lib/session/discover.ts`.
43
+
44
+ - **Traces insight engine — cross-session failure clustering + wasted time (PHNX-3141).** `agents traces sync`'s `index.json` now carries `failurePatterns[]` (top-K, ranked by wasted time, not raw occurrence count — a rare 8-hour retry loop still outranks a frequent 2-minute one), `wastedMsTotal`, and `latency` (time-to-first-tool p50/p90/p99/max). Failed `tool_calls` are grouped by `(tool, cause, normalized error)` so near-identical failures (a GitHub rate-limit message with a different countdown each time) fold into one pattern instead of a wall of near-duplicates. Per-session `SessionDetail` also gains `surfacedToolFailures` — every failed step, listed regardless of the run's overall outcome, so a session that ultimately succeeded still shows the tool failures it hit along the way. Source: `cli/src/lib/traces/insights.ts`, `cli/src/lib/traces/sync.ts`.
45
+
46
+ - **Traces insight group-by dimensions (PHNX-3141).** `cli/src/lib/traces/segments.ts` derives the console bar axes — agent (model × harness), task type, failure timing — and the time-to-first-tool latency insight (`firstToolMs` p50/p90/p99/max). Pure functions; the sync integrator wires them into the shard. Source: `cli/src/lib/traces/segments.ts`.
47
+
48
+ - **Native-account labels live on the central `accounts.native` rows in agents.yaml (already fleet-synced via `agents repo push/pull`). `remove`/`rename`/`label` operate on every row matching `(agent, identityKey)`, so a post-merge duplicate cannot silently survive (PHNX-3206).**
49
+
50
+ - **The top-level `agents.yaml` header heals itself instead of freezing (PHNX-3315).**
51
+ `serializeCentral` parses the committed file to preserve its hand-written body
52
+ comments, but that also preserved the leading metadata header verbatim — so a
53
+ top-level `agents.yaml` written before the `agi-cli` rename kept its stale
54
+ 3-line header (repo `agents-cli`, no `$schema:` line) forever while every
55
+ freshly-written device doc got the current header. The header is now rewritten
56
+ to the current `META_HEADER` on the next central write, textually (the `yaml`
57
+ library folds the leading comment onto the first key when that key already
58
+ carries a comment, so `doc.commentBefore` is unreliable), leaving body comments
59
+ and every config key intact, and byte-stable once healed so it never re-churns.
60
+ Source: `cli/src/lib/state.ts`.
61
+ - **The legacy central `browser:` tombstone drains itself on `agents sync` (PHNX-3315).**
62
+ New browser profiles write the per-device doc, but profiles created before the
63
+ device-scoped store lingered in the shared top-level `agents.yaml` and churned
64
+ every fleet pull until someone ran `agents browser profiles claim` by hand.
65
+ `agents sync` now auto-evicts a lingering central profile into this device's
66
+ doc and clears it from central — but ONLY for remote (`ssh://`) profiles. An
67
+ `ssh://` endpoint names a specific host, so the profile is fungible by design:
68
+ any box resolves it to the same browser, so a concurrent cross-machine
69
+ double-claim is harmless. A local/`cdp://` profile has no per-machine ownership
70
+ signal ("that browser is installed here" is not "I hold this profile's
71
+ credentialed session"), so auto-claiming it would let two boxes with the same
72
+ common browser each claim the same tombstone and flip a credentialed profile
73
+ from identity-bearing to fungible fleet-wide — the exact logged-out-browser
74
+ failure this module exists to prevent. Local/cdp tombstones are left central for
75
+ an explicit `agents browser profiles claim`. The claim is selected and committed
76
+ inside a single meta-lock acquisition, and non-throwing (a config conflict or
77
+ unhostable profile is left central rather than wedging the sync). `profileRegistry` is left as the single source of truth: a
78
+ claimed profile lives in the device doc alone, never double-counted across two
79
+ stores. Source: `cli/src/lib/browser/registry.ts`, `cli/src/commands/sync.ts`,
80
+ `cli/src/lib/state.ts`.
81
+
82
+ - **`fleet.discovery` / `fleet.ignored` are device-scoped with a union-on-read view (PHNX-3315).**
83
+ Every box used to rewrite one shared `fleet.discovery`/`fleet.ignored` map in the
84
+ top-level `agents.yaml` (each dismissal stamped `ignoredOn:<host>`), so two boxes
85
+ dismissing different peers produced guaranteed git line-conflicts on the next
86
+ pull. Each box now records ONLY its own discovery decisions and dismissals in its
87
+ tracked device doc (`devices/<host>/agents.yaml` `fleet:` block), and the
88
+ effective fleet view is recomputed as a deterministic, order-independent UNION
89
+ across every device doc at read time — `ignored` beats `approved` for a
90
+ conflicting name, and the newest `ignoredAt` wins a shared dismissal — so every
91
+ box computes the identical view and no box ever rewrites another's file. A
92
+ fold-then-delete migration moves the existing central maps into the owning box's
93
+ device doc before the central keys are dropped (the device-doc write lands before
94
+ the central strip, so a crash re-folds idempotently). `agents devices
95
+ register/ignore/unignore/ignored`, discovery reconcile, the sync pending-diff, and
96
+ `agents fleet capture` all read the union. Source:
97
+ `cli/src/lib/devices/{discovery-policy,registry,device-docs,config-migration}.ts`,
98
+ `cli/src/commands/{ssh,fleet-capture}.ts`, `cli/src/lib/state.ts`.
99
+ - **Agent-host registrations are device-scoped (`deviceHosts`) with a merged read (PHNX-3315).**
100
+ Locally-discovered SSH hosts and inline registrations landed in one shared
101
+ `hosts:` map in the top-level `agents.yaml`, so every box that enrolled a host
102
+ rewrote the fleet-shared file. Registrations now write this box's device doc
103
+ `hosts:` block; the effective host directory is the cross-box union (newest
104
+ `addedAt` wins a name collision), and `remove` only edits this box's own doc. A
105
+ fold-then-delete migration relocates the existing central `hosts:` map into this
106
+ box's device doc and drops the central key. Source:
107
+ `cli/src/lib/hosts/{providers/local,registry,remote-os}.ts`,
108
+ `cli/src/lib/devices/{device-docs,config-migration}.ts`, `cli/src/lib/state.ts`.
109
+ - **`scope:'device'` native accounts move off the shared file into the device doc (PHNX-3315).**
110
+ `addNativeAccount` accepted `scope:'device'` but always wrote the central
111
+ `accounts.native` map, so a per-box login (and its email / `identityKey` PII)
112
+ landed in the git-tracked, fleet-synced `agents.yaml`. A native login is
113
+ machine-local, so its home now follows its scope: a `scope:'device'` identity
114
+ (and any binding that targets it) writes this box's device doc `accounts:` block,
115
+ keeping its identity PII off the shared file; genuinely fleet-shared account
116
+ `defaults` and `scope:'version'` identities stay central. Every account reader
117
+ (`listNativeAccounts`, `findUnifiedAccount`, binding resolution, rename/remove)
118
+ merges the fleet-shared central store with this box's device slice. A
119
+ fold-then-delete migration relocates existing `scope:'device'` natives and their
120
+ bindings out of central (forward-only; no history rewrite). Source:
121
+ `cli/src/lib/account-registry.ts`, `cli/src/lib/devices/config-migration.ts`,
122
+ `cli/src/lib/state.ts`.
123
+
124
+ - **`tmux.enabled` now gates remote `--device` runs too — no more forced tmux wrap (PHNX-3316).**
125
+ `resolveTmuxWrap` (`cli/src/lib/exec.ts`) no longer lets `remoteDispatch`
126
+ override the device's `tmux.enabled`: off (the default) means a followed
127
+ `agents run --device <host>` spawns the agent directly, with no tmux session
128
+ on the peer. Durability for a bare remote run comes from the existing
129
+ auto-reconnect in `cli/src/lib/hosts/reconnect.ts`, whose target
130
+ (`agents sessions focus <id> --local`) already resumes the harness session in
131
+ place when no pane exists — a dropped link costs the in-flight turn, not the
132
+ conversation. One case still wraps regardless: a followed remote run whose
133
+ launcher has no TTY (CI, scripts, another agent driving the CLI), where the
134
+ detached pane is the run's only interface.
135
+ `undurable` is now reached only when a wrap was actually wanted (opted-in
136
+ device, or the no-TTY case) but the peer has no tmux; its refusal message
137
+ names the `agents ssh <host> 'agents devices config <host> tmux.enabled off'`
138
+ alternative (the key is machine-local, so it must be set on the peer)
139
+ alongside `--raw`.
140
+ RUSH-3125's forced wrap conflated durability with an ergonomics preference
141
+ and wrapped boxes whose operator had explicitly left tmux off.
142
+ `hosts/reconnect.ts`'s header premise, the `hosts/dispatch.ts`
143
+ REMOTE_INTERACTIVE_ENV comments, and the `tmux.enabled` help text
144
+ (`device-config.ts`) are updated to match; wrap-matrix tests cover the new
145
+ gate.
146
+
147
+ - **Share attribution bar: full-width chrome, not a floating box.** The bar was injected `position:sticky` inside `<body>`, so on a page whose body is a narrow centered column (`max-width` + `margin:auto`) it inherited that width and rendered as a floating black rectangle. It's now `position:fixed`, full viewport width, with an injected `html{padding-top}` pushing the page content down beneath it — clean top chrome on any page layout. `flex-wrap:nowrap` + left-side ellipsis keep it one line so the push height stays correct. Source: `cli/src/lib/share/worker-template.ts`.
148
+
3
149
  ## 1.22.52
4
150
 
5
151
  - **`agents artifacts share --visibility me|org` — identity-gated share pages (PHNX-3260).**
package/README.md CHANGED
@@ -1085,7 +1085,7 @@ agents harness add deepinfra --account deepinfra
1085
1085
 
1086
1086
  One provider account **is** one `agents secrets` bundle -- `agents accounts add` creates it with secrets policy `never`, so a background agent launch on that account never raises Touch ID. `agents accounts` (no subcommand) lists provider bundles next to harness-native signed-in identities so you see both kinds of credential together; `accounts list` / `inspect <name>` / `set-key <name>` (rotate) / `rename` / `remove` manage a bundle by its stable id, independent of its current label. `accounts switch <harness>` is the fast picker over that default -- it writes the same binding as `set-default`, and balanced rotation already honors it.
1087
1087
 
1088
- Harness-native OAuth logins (Claude Code's own `/login`, `codex login`, and so on) stay exactly where the harness put them -- agi-cli discovers and displays them but never copies, renames, or converts them into a provider bundle. Native `accounts name` / `attach` is only for harnesses agents-cli can isolate today (claude, codex, grok; muse when an email is present). A device-scoped login (kimi, droid, …) is refused with a named reason; provider `accounts add --provider` is unrestricted. `accounts sync <name> --device <device>` is the only way a provider account crosses machines, and it's explicit: nothing syncs automatically. Selection order for a run is explicit `--account`, then `accounts set-default` / `switch` for that harness, then the harness's native/balanced account behavior.
1088
+ Harness-native OAuth logins (Claude Code's own `/login`, `codex login`, and so on) stay exactly where the harness put them -- agi-cli discovers and displays them but never copies, renames, or converts them into a provider bundle. Native `accounts name` / `attach` is only for harnesses agents-cli can isolate today (claude, codex, grok; muse when an email is present). A device-scoped login (kimi, droid, …) is refused with a named reason; provider `accounts add --provider` is unrestricted. Native **labels** (`agents accounts label codex personal`) bind to a stable identityKey on the central account row in `agents.yaml`, which `agents repo push/pull` already syncs fleet-wide, so `codex#personal` selects the same login on every box. `accounts sync <name> --device <device>` is the only way a **provider** account credential crosses machines, and it's explicit: nothing copies OAuth automatically. Selection order for a run is explicit `--account`, then `accounts set-default` / `switch` for that harness, then the harness's native/balanced account behavior.
1089
1089
 
1090
1090
  ---
1091
1091
 
@@ -464,7 +464,7 @@ export function registerAccountsCommand(program) {
464
464
  agents accounts label codex@0.146.0 personal
465
465
  agents accounts label codex work --account you@example.com
466
466
  agents run codex#work`,
467
- notes: 'The label binds to the signed-in account identity, not the version — codex#work keeps selecting that account after it moves to a newer install. One signed-in login needs no selector; with several, an interactive terminal opens a picker, while scripts pass --account or <harness>@<version>.',
467
+ notes: 'The label binds to the signed-in account identity, not the version — codex#work keeps selecting that account after it moves to a newer install. Labels live on the central account rows in agents.yaml, which `agents repo push/pull` already syncs fleet-wide, keyed by (agent, identityKey). One signed-in login needs no selector; with several, an interactive terminal opens a picker, while scripts pass --account or <harness>@<version>.',
468
468
  });
469
469
  accounts.command('attach <account> <target>')
470
470
  .description('Attach a named account to a native installation or custom harness')
@@ -1569,15 +1569,19 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1569
1569
  });
1570
1570
  }
1571
1571
  }
1572
- // A network drop kills the local ssh client (exit 255) but the remote
1573
- // agent survives in its detached tmux session. With a known session id
1574
- // (Claude's forced id, a resumed run, or a non-Claude id we just
1575
- // resolved from the remote hook record) and a tmux-hosted run,
1576
- // re-attach the live pane automatically instead of exiting — the user
1577
- // never has to notice the drop and `agents sessions focus` by hand.
1578
- // `raw` runs aren't tmux wrapped, so there is nothing to reconnect to.
1579
- // For `run auto` prefer the join-resolved id (the harness the remote
1580
- // ACTUALLY picked) over the explicit --session-id only claude adopts.
1572
+ // A network drop kills the local ssh client (exit 255). What the
1573
+ // remote agent does next depends on the peer's tmux.enabled
1574
+ // (PHNX-3316): wrapped, it survives in its detached pane and the
1575
+ // reconnect rejoins it; bare (the default), the drop SIGHUPs it and
1576
+ // the reconnect resumes the session in place from disk. Either way,
1577
+ // with a known session id (Claude's forced id, a resumed run, or a
1578
+ // non-Claude id we just resolved from the remote hook record) we
1579
+ // reconnect automatically instead of exiting — the user never has
1580
+ // to notice the drop and `agents sessions focus` by hand.
1581
+ // `raw` runs opted out of all of this, so there is nothing to
1582
+ // reconnect to. For `run auto` prefer the join-resolved id (the
1583
+ // harness the remote ACTUALLY picked) over the explicit
1584
+ // --session-id only claude adopts.
1581
1585
  const { pickReconnectTarget, reconnectInteractiveSession, afterInteractiveRemoteExit, SSH_CONN_FAILURE } = await import('../lib/hosts/reconnect.js');
1582
1586
  const reconnectTarget = pickReconnectTarget({
1583
1587
  agent: runAgent,
@@ -1814,6 +1818,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1814
1818
  let accountConfigVersion;
1815
1819
  let profileProvider;
1816
1820
  let fromProfile = false;
1821
+ let profileName;
1817
1822
  let profileFallbackModel;
1818
1823
  let workflowModel;
1819
1824
  // WORKFLOW.md capability scoping, translated to Claude headless flags below.
@@ -1897,6 +1902,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
1897
1902
  profileProvider = readProfile(rawAgent).provider;
1898
1903
  profileFallbackModel = resolved.fallbackModel;
1899
1904
  fromProfile = true;
1905
+ profileName = resolved.profileName;
1900
1906
  process.stderr.write(chalk.gray(`Resolved custom harness '${resolved.profileName}' -> ${agent}${version ? `@${version}` : ''}\n`));
1901
1907
  if (resolved.tierNote) {
1902
1908
  process.stderr.write(chalk.gray(`[agents] ${resolved.tierNote}\n`));
@@ -2745,6 +2751,7 @@ agents run auto --device yosemite-s0 "fix the flaky test" # pin the device
2745
2751
  }
2746
2752
  const execOptions = {
2747
2753
  agent,
2754
+ harnessName: profileName,
2748
2755
  version,
2749
2756
  configVersion: accountConfigVersion,
2750
2757
  prompt,
@@ -71,6 +71,13 @@ async function runCapture(opts) {
71
71
  secretsBundles: listBundles().map((b) => b.name),
72
72
  routines: listJobs().map((j) => j.name),
73
73
  };
74
+ // Discovery decisions and dismissals are device-scoped now (PHNX-3315): they
75
+ // live in each box's device doc and sync via the repo, so `capture` must NOT
76
+ // hoist the cross-box union back into the shared `agents.yaml` `fleet:` block
77
+ // (that would recreate the N-boxes-rewrite-one-map conflict Task A removed and
78
+ // re-arm the fold-then-delete migration on every capture). `meta.fleet` carries
79
+ // only the roster/defaults; any lingering central-legacy discovery/ignored is
80
+ // carried forward verbatim and drained by the migration.
74
81
  const next = captureFleet(meta.fleet, inputs);
75
82
  if (opts.dryRun) {
76
83
  console.log(chalk.gray('# agents.yaml fleet: block (dry run — not written)'));
@@ -31,6 +31,7 @@ import { multiItemPicker } from '../lib/picker.js';
31
31
  import { sessionRecoveryRunArgs } from '../lib/session/recovery.js';
32
32
  import { shellQuote, assertValidSshTarget } from '../lib/ssh-exec.js';
33
33
  import { openSurfaces, currentContext, availableBackends, detectCurrentBackend, } from '../lib/terminal/index.js';
34
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
34
35
  import { isInteractiveTerminal, isPromptCancelled } from './utils.js';
35
36
  import { setHelpSections } from '../lib/help.js';
36
37
  const INHERITED_FOCUS_OPTIONS = [
@@ -534,6 +535,7 @@ export async function focusSelectedSession(meta, active, self) {
534
535
  return;
535
536
  }
536
537
  console.log(chalk.yellow('This live session has no session id or living attach rail to focus.'));
538
+ console.log(chalk.gray(addressabilityRecoveryHint(active, meta.id)));
537
539
  process.exitCode = 1;
538
540
  return;
539
541
  }
@@ -34,6 +34,7 @@ import { getDefaultSocketPath } from '../lib/tmux/paths.js';
34
34
  import { sshExec, sshStream, assertValidSshTarget, shellQuote, SSH_CONN_FAILURE_CODE } from '../lib/ssh-exec.js';
35
35
  import { connectionEndedNotice } from '../lib/hosts/reconnect.js';
36
36
  import { enumerateGhosttyTabs, assignGhosttyTabs } from '../lib/session/ghostty-tabs.js';
37
+ import { addressabilityRecoveryHint } from '../lib/terminal/resolve.js';
37
38
  const execFileAsync = promisify(execFile);
38
39
  export function registerGoCommand(program) {
39
40
  program
@@ -318,7 +319,7 @@ export async function refuseFallback(s, remote) {
318
319
  return;
319
320
  }
320
321
  console.log(chalk.yellow(`Can't jump to ${shortId(s)} — it's in ${s.host ?? 'an unknown terminal'} with no attach rail (not tmux/Ghostty).`) +
321
- chalk.gray(`\nTry: agents sessions resume ${shortId(s)}`));
322
+ chalk.gray(`\n${addressabilityRecoveryHint(s)}`));
322
323
  process.exitCode = 1;
323
324
  }
324
325
  export async function jumpTo(s, self, fallback = refuseFallback) {
@@ -26,7 +26,11 @@ async function resolveTarget(sessionId) {
26
26
  const resolution = resolveInjectTargetForSession(match);
27
27
  if (!resolution.addressable) {
28
28
  // Surface the resolver's precise reason (host/rail), not a generic "not tmux".
29
- return { target: null, reason: `Session "${sessionId}": ${resolution.reason}` };
29
+ return {
30
+ target: null,
31
+ reason: `Session "${sessionId}": ${resolution.reason}`,
32
+ hint: resolution.hint,
33
+ };
30
34
  }
31
35
  return { target: resolution.target };
32
36
  }
@@ -43,10 +47,11 @@ async function runInject(sessionId, text, options) {
43
47
  else {
44
48
  const resolved = await resolveTarget(sessionId);
45
49
  if (!resolved.target) {
50
+ const message = resolved.hint ? `${resolved.reason}\n${resolved.hint}` : resolved.reason;
46
51
  if (options.json)
47
- console.log(JSON.stringify({ ok: false, error: resolved.reason }));
52
+ console.log(JSON.stringify({ ok: false, error: message }));
48
53
  else
49
- console.error(chalk.red(resolved.reason));
54
+ console.error(chalk.red(message));
50
55
  process.exit(1);
51
56
  }
52
57
  target = resolved.target;
@@ -9,6 +9,7 @@ import fs from 'node:fs';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
11
  import { truncate, humanDuration } from '../lib/format.js';
12
+ import { sessionDisplayAgent } from '../lib/session/types.js';
12
13
  import { fetchPeerPreviewDigest } from '../lib/session/remote-list.js';
13
14
  import { parseSession, sanitizeForTerminal, SNAPSHOT_TODO_TOOLS } from '../lib/session/parse.js';
14
15
  import { safeTeamText } from '../lib/session/team-filter.js';
@@ -404,7 +405,7 @@ function formatHeader(session, events) {
404
405
  const { createdAgo, lastActiveAgo, duration } = extractTiming(session, events);
405
406
  // Line 1: Agent v version · shortId · model · account
406
407
  const line1 = [];
407
- line1.push(chalk.gray(`${displayAgent(session.agent)}${session.version ? ` v${session.version}` : ''}`));
408
+ line1.push(chalk.gray(`${displayAgent(sessionDisplayAgent(session))}${session.version ? ` v${session.version}` : ''}`));
408
409
  if (session.shortId)
409
410
  line1.push(chalk.dim(session.shortId));
410
411
  if (model)
@@ -18,7 +18,7 @@ import { sanitizeForTerminal, redactSecrets } from '../lib/redact.js';
18
18
  import { resolveProjectKey } from '../lib/project-key.js';
19
19
  import { listProjectDefs, resolveProjectNameForCwd } from '../lib/projects.js';
20
20
  import ora from 'ora';
21
- import { SESSION_AGENTS, isAgentTmuxAlias } from '../lib/session/types.js';
21
+ import { SESSION_AGENTS, isAgentTmuxAlias, sessionDisplayAgent } from '../lib/session/types.js';
22
22
  import { discoverArtifacts, readArtifact, resolveArtifact } from '../lib/session/artifacts.js';
23
23
  import { looksLikePath, toComparablePath, homeDir, needsWindowsShell, composeWin32CommandLine } from '../lib/platform/index.js';
24
24
  import { getActiveSessions, describeActiveDiscoveryHealth, sessionProcessIsLocal, backfillActiveRowsFromIndex, isRunningLiveSession, serializeActiveSessionsForJson, serializeSessionsJson, shortIdFromName } from '../lib/session/active.js';
@@ -233,9 +233,10 @@ async function renderArtifactsForSession(session, listAll, name) {
233
233
  console.log(chalk.gray('No file-write artifacts found in this session.'));
234
234
  return;
235
235
  }
236
- const agentColor = colorAgent(session.agent);
236
+ const shown = sessionDisplayAgent(session);
237
+ const agentColor = colorAgent(shown);
237
238
  console.log('');
238
- console.log(agentColor(session.agent) +
239
+ console.log(agentColor(shown) +
239
240
  chalk.gray(` · ${session.shortId} · ${formatRelativeTime(session.timestamp)}`));
240
241
  console.log(chalk.gray('─'.repeat(72)));
241
242
  for (const a of artifacts) {
@@ -726,7 +727,8 @@ function fitCell(content, room) {
726
727
  */
727
728
  export function renderActiveRowLines(s, indent, termW) {
728
729
  const idCol = chalk.dim(padToWidth((s.sessionId?.slice(0, 8)) ?? '-', ROW_ID_W));
729
- const kindCol = colorAgent(s.kind)(padToWidth(truncateToWidth(s.kind, ROW_AGENT_W), ROW_AGENT_W + 1));
730
+ const shownKind = sessionDisplayAgent({ agent: s.kind, harness: s.harness });
731
+ const kindCol = colorAgent(shownKind)(padToWidth(truncateToWidth(shownKind, ROW_AGENT_W), ROW_AGENT_W + 1));
730
732
  const versionCol = chalk.gray(padToWidth(truncateToWidth(s.version ?? '', ROW_VERSION_W), ROW_VERSION_W + 1));
731
733
  const statusCol = statusColor(s.status)(padToWidth(truncateToWidth(activityLabel(s), ROW_STATUS_W - 1), ROW_STATUS_W));
732
734
  const ownerCol = chalk.cyan(padToWidth(truncateToWidth(ownerLabel(s), ROW_OWNER_W - 1), ROW_OWNER_W));
@@ -1010,6 +1012,7 @@ export function serializeResolvedSessionsJson(sessions) {
1010
1012
  id: session.id,
1011
1013
  shortId: session.shortId,
1012
1014
  agent: session.agent,
1015
+ harness: session.harness,
1013
1016
  origin: session.origin,
1014
1017
  timestamp: session.timestamp,
1015
1018
  lastActivity: session.lastActivity,
@@ -1805,7 +1808,8 @@ function runOutcomeDetail(meta) {
1805
1808
  /** Compact per-session metadata line under a linked session row. */
1806
1809
  function linkedSessionMeta(session) {
1807
1810
  const parts = [];
1808
- parts.push(session.version ? `${session.agent} v${session.version}` : session.agent);
1811
+ const shown = sessionDisplayAgent(session);
1812
+ parts.push(session.version ? `${shown} v${session.version}` : shown);
1809
1813
  if (session.account)
1810
1814
  parts.push(session.account);
1811
1815
  if (session.model)
@@ -2996,7 +3000,8 @@ function timeCell(age, topicSlack) {
2996
3000
  * when `showTicket` — otherwise a listing with no refs would waste a column of
2997
3001
  * dashes and needlessly truncate the topic. Worktree stays a trailing badge. */
2998
3002
  export function flatSessionRow(session, live, showTicket = false, cols = {}, bookmarked = false) {
2999
- const agentColor = colorAgent(session.agent);
3003
+ const shown = sessionDisplayAgent(session);
3004
+ const agentColor = colorAgent(shown);
3000
3005
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3001
3006
  const project = session.project || '-';
3002
3007
  const tag = originTag(session) || teamTag(session);
@@ -3045,7 +3050,7 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, boo
3045
3050
  const topicW = Math.max(MIN_TOPIC_W, width - fixedW - modelW - when.extraW);
3046
3051
  return (bookmarkCell +
3047
3052
  chalk.white(padToWidth(truncateToWidth(session.shortId, 9), 10)) +
3048
- agentColor(padToWidth(truncateToWidth(session.agent, 8), 9)) +
3053
+ agentColor(padToWidth(truncateToWidth(shown, 8), 9)) +
3049
3054
  chalk.yellow(padToWidth(truncateToWidth(session.version || '-', 7), 8)) +
3050
3055
  (modelW ? chalk.yellow(padToWidth(truncateToWidth(modelLabel(session.model), modelW - 1), modelW)) : '') +
3051
3056
  machineCell +
@@ -3060,7 +3065,8 @@ export function flatSessionRow(session, live, showTicket = false, cols = {}, boo
3060
3065
  }
3061
3066
  /** One tree-mode row (grouped under a dir header): id · agent · badges · topic · time. No version/project column. */
3062
3067
  function treeSessionRow(session, live) {
3063
- const agentColor = colorAgent(session.agent);
3068
+ const shown = sessionDisplayAgent(session);
3069
+ const agentColor = colorAgent(shown);
3064
3070
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3065
3071
  const tag = originTag(session) || teamTag(session);
3066
3072
  const label = session.label;
@@ -3082,7 +3088,7 @@ function treeSessionRow(session, live) {
3082
3088
  const topicW = Math.max(12, baseTopicW - when.extraW);
3083
3089
  return (' ' +
3084
3090
  chalk.dim(padToWidth(session.shortId, 9)) +
3085
- agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
3091
+ agentColor(padToWidth(truncateToWidth(shown, 7), 8)) +
3086
3092
  (badges ? badges + ' ' : '') +
3087
3093
  (glyph ? glyph + ' ' : '') +
3088
3094
  statusCell +
@@ -3246,7 +3252,8 @@ const TEAM_HANDLE_W = 16;
3246
3252
  * identity the `--teams` view exists to show (RUSH-1997).
3247
3253
  */
3248
3254
  function teamMemberRow(session, live) {
3249
- const agentColor = colorAgent(session.agent);
3255
+ const shown = sessionDisplayAgent(session);
3256
+ const agentColor = colorAgent(shown);
3250
3257
  const origin = session.teamOrigin;
3251
3258
  const age = sessionAgeParts(session.timestamp, session.lastActivity);
3252
3259
  const handle = safeTeamText(origin?.handle) ?? session.shortId;
@@ -3267,7 +3274,7 @@ function teamMemberRow(session, live) {
3267
3274
  const topicW = Math.max(12, baseTopicW - when.extraW);
3268
3275
  return (' ' +
3269
3276
  chalk.dim(padToWidth(session.shortId, 9)) +
3270
- agentColor(padToWidth(truncateToWidth(session.agent, 7), 8)) +
3277
+ agentColor(padToWidth(truncateToWidth(shown, 7), 8)) +
3271
3278
  chalk.yellow(padToWidth(truncateToWidth(mode || '-', TEAM_MODE_W), TEAM_MODE_W + 1)) +
3272
3279
  (badges ? badges + ' ' : '') +
3273
3280
  (glyph ? glyph + ' ' : '') +
@@ -3444,13 +3451,14 @@ function renderArchivedSession(session, mode, options = {}) {
3444
3451
  }, null, 2));
3445
3452
  return;
3446
3453
  }
3447
- const agentColor = colorAgent(session.agent);
3454
+ const shown = sessionDisplayAgent(session);
3455
+ const agentColor = colorAgent(shown);
3448
3456
  const absTime = formatAbsoluteTime(session.timestamp);
3449
3457
  const title = session.label || session.topic;
3450
3458
  console.log('');
3451
3459
  if (title)
3452
3460
  console.log(chalk.bold.white(title));
3453
- console.log(agentColor(session.agent) +
3461
+ console.log(agentColor(shown) +
3454
3462
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3455
3463
  (session.project ? chalk.cyan(` ${session.project}`) : '') +
3456
3464
  chalk.gray(` ${absTime} (${formatRelativeTime(session.timestamp)})`) +
@@ -3481,7 +3489,7 @@ async function renderSession(session, mode, filters, options = {}) {
3481
3489
  console.log(chalk.yellow('Session transcript not available (file no longer exists).'));
3482
3490
  console.log(chalk.gray(`Path: ${session.filePath}`));
3483
3491
  if (session.version)
3484
- console.log(chalk.gray(`Version: ${session.agent} ${session.version}`));
3492
+ console.log(chalk.gray(`Version: ${sessionDisplayAgent(session)} ${session.version}`));
3485
3493
  if (session.project)
3486
3494
  console.log(chalk.gray(`Project: ${session.project}`));
3487
3495
  if (session.account)
@@ -3489,11 +3497,12 @@ async function renderSession(session, mode, filters, options = {}) {
3489
3497
  console.log(chalk.gray(`Time: ${session.timestamp}`));
3490
3498
  return;
3491
3499
  }
3492
- const spinner = ora(`Parsing ${session.agent} session...`).start();
3500
+ const spinner = ora(`Parsing ${sessionDisplayAgent(session)} session...`).start();
3493
3501
  const parsedEvents = parseSession(session.filePath, session.agent);
3494
3502
  spinner.stop();
3495
3503
  let events = filterEvents(parsedEvents, filters);
3496
- const agentColor = colorAgent(session.agent);
3504
+ const shown = sessionDisplayAgent(session);
3505
+ const agentColor = colorAgent(shown);
3497
3506
  console.log('');
3498
3507
  if (mode === 'summary') {
3499
3508
  const stats = computeSummaryStats(events);
@@ -3507,7 +3516,7 @@ async function renderSession(session, mode, filters, options = {}) {
3507
3516
  const badges = signalBadges(metaSignals(session));
3508
3517
  console.log(chalk.bold.white(title) + (badges ? ' ' + badges : ''));
3509
3518
  }
3510
- console.log(agentColor(session.agent) +
3519
+ console.log(agentColor(shown) +
3511
3520
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3512
3521
  modelStr +
3513
3522
  (session.project ? chalk.cyan(` ${session.project}`) + branchStr : branchStr) +
@@ -3521,7 +3530,7 @@ async function renderSession(session, mode, filters, options = {}) {
3521
3530
  return;
3522
3531
  }
3523
3532
  if (mode === 'markdown') {
3524
- console.log(agentColor(session.agent) +
3533
+ console.log(agentColor(shown) +
3525
3534
  (session.version ? chalk.yellow(` ${session.version}`) : '') +
3526
3535
  (session.project ? chalk.cyan(` ${session.project}`) : '') +
3527
3536
  chalk.gray(` ${formatRelativeTime(session.timestamp)}`) +
@@ -3662,7 +3671,8 @@ export function liveHostLabel(a) {
3662
3671
  return viewer && viewer !== a.host ? `${a.host}→${viewer}` : a.host;
3663
3672
  }
3664
3673
  export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarked = false, live) {
3665
- const agentColor = colorAgent(s.agent);
3674
+ const shown = sessionDisplayAgent(s);
3675
+ const agentColor = colorAgent(shown);
3666
3676
  const age = sessionAgeParts(s.timestamp, s.lastActivity);
3667
3677
  const project = s.project || '-';
3668
3678
  // SSH-launch origin (live rows only): mirrors the flat listing's `ssh←<device>`
@@ -3727,7 +3737,7 @@ export function formatPickerLabel(s, query, cols = {}, ssh, host = '', bookmarke
3727
3737
  // live row with no session id is named by its pid or cloud task, which can
3728
3738
  // run past the column and shunt every later column out of alignment.
3729
3739
  chalk.white(padRight(truncate(s.shortId, 9), 10)) +
3730
- agentColor(padRight(truncate(s.agent, 8), 9)) +
3740
+ agentColor(padRight(truncate(shown, 8), 9)) +
3731
3741
  chalk.yellow(padRight(truncate(versionStr, 7), 8)) +
3732
3742
  machineCell +
3733
3743
  hostCell +
@@ -1136,7 +1136,7 @@ function registerDevicesCommands(program) {
1136
1136
  registerFleetApplyAlias(devicesCmd);
1137
1137
  devicesCmd
1138
1138
  .command('register <name>')
1139
- .description('Register a discovered node and sync the approval through agents.yaml fleet.discovery.')
1139
+ .description("Register a discovered node and record the approval in this box's device doc (fleet.discovery), unioned fleet-wide.")
1140
1140
  .action(async (name) => {
1141
1141
  try {
1142
1142
  const nodes = parseTailscaleStatus(tailscaleStatusJson());
@@ -1145,11 +1145,20 @@ function registerDevicesCommands(program) {
1145
1145
  console.error(chalk.red(`'${name}' is not a current tailscale node. See 'agents devices sync'.`));
1146
1146
  process.exit(1);
1147
1147
  }
1148
- await removeIgnored(name); // a re-registered node is no longer dismissed
1148
+ await removeIgnored(name); // clear THIS box's dismissal, if any
1149
1149
  const d = await upsertDevice(name, nodeToDeviceInput(node));
1150
1150
  setDeviceDiscoveryStatus(name, 'approved');
1151
1151
  clearPendingSentinel(name); // drop the notification immediately
1152
1152
  console.log(chalk.green(`Registered '${name}'`) + chalk.gray(` (${d.platform})`));
1153
+ // A dismissal on another box still wins the union (ignored beats
1154
+ // approved), so approving here does not un-dismiss it fleet-wide — say so
1155
+ // rather than imply the node is now discoverable everywhere (PHNX-3315).
1156
+ const otherDismissals = loadIgnoredEntries().filter((e) => e.name === name);
1157
+ if (otherDismissals.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1158
+ const boxes = [...new Set(otherDismissals.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1159
+ console.error(chalk.yellow(`Note: '${name}' is still dismissed on: ${boxes}, so it stays ignored fleet-wide`) +
1160
+ chalk.gray(` until you run \`agents devices unignore ${name}\` there.`));
1161
+ }
1153
1162
  }
1154
1163
  catch (err) {
1155
1164
  console.error(chalk.red(err.message));
@@ -1158,7 +1167,7 @@ function registerDevicesCommands(program) {
1158
1167
  });
1159
1168
  devicesCmd
1160
1169
  .command('ignore <name>')
1161
- .description('Dismiss a node and sync the decision through agents.yaml fleet.discovery (also removes it locally).')
1170
+ .description("Dismiss a node and record the decision in this box's device doc (fleet.ignored), unioned fleet-wide (also removes it locally).")
1162
1171
  .action(async (name) => {
1163
1172
  try {
1164
1173
  await removeDevice(name);
@@ -1176,13 +1185,25 @@ function registerDevicesCommands(program) {
1176
1185
  .command('unignore <name>')
1177
1186
  .description('Undo `ignore`: allow a node to be discovered and registered again.')
1178
1187
  .action(async (name) => {
1179
- const portableWasIgnored = getDeviceDiscoveryStatus(name) === 'ignored';
1180
- const ok = await removeIgnored(name);
1181
- if (!ok && !portableWasIgnored) {
1188
+ const wasIgnored = getDeviceDiscoveryStatus(name) === 'ignored' || loadIgnoredEntries().some((e) => e.name === name);
1189
+ // A box can edit only its OWN device doc (PHNX-3315), so this clears just
1190
+ // this box's dismissal/approval decision.
1191
+ await removeIgnored(name);
1192
+ setDeviceDiscoveryStatus(name, undefined);
1193
+ // Re-evaluate the cross-box union AFTER clearing our slice: another box's
1194
+ // dismissal keeps the node ignored fleet-wide (ignored beats approved), so
1195
+ // never report a false "no longer ignoring" — name the box(es) to clear.
1196
+ const remaining = loadIgnoredEntries().filter((e) => e.name === name);
1197
+ if (remaining.length > 0 || getDeviceDiscoveryStatus(name) === 'ignored') {
1198
+ const boxes = [...new Set(remaining.map((e) => e.ignoredOn))].sort().join(', ') || 'another box';
1199
+ console.error(chalk.yellow(`Cleared this box's decision, but '${name}' is still dismissed on: ${boxes}.`) +
1200
+ chalk.gray(`\nRun \`agents devices unignore ${name}\` there too to clear it fleet-wide.`));
1201
+ return;
1202
+ }
1203
+ if (!wasIgnored) {
1182
1204
  console.error(chalk.gray(`'${name}' was not ignored.`));
1183
1205
  return;
1184
1206
  }
1185
- setDeviceDiscoveryStatus(name, undefined);
1186
1207
  console.log(chalk.green(`No longer ignoring '${name}'`) + chalk.gray(' — run `agents devices sync` to register it.'));
1187
1208
  });
1188
1209
  const ignoredCmd = devicesCmd
@@ -1219,11 +1240,13 @@ function registerDevicesCommands(program) {
1219
1240
  agents devices unignore old-laptop # undo a dismissal
1220
1241
  `,
1221
1242
  notes: `
1222
- Dismissals live in the tracked central agents.yaml (fleet.ignored) and
1223
- sync with 'agents repo push/pull', so a node dismissed on one box stays
1224
- dismissed everywhere. An ignored node is not a device — it never enters
1225
- the registry, so 'agents devices list' never shows it; this command is
1226
- where dismissals are visible.
1243
+ Dismissals live in each box's tracked device doc
1244
+ (devices/<host>/agents.yaml fleet.ignored) and sync with 'agents repo
1245
+ push/pull'; the effective list is their cross-box union, so a node
1246
+ dismissed on one box stays dismissed everywhere without the boxes ever
1247
+ rewriting one shared file. An ignored node is not a device — it never
1248
+ enters the registry, so 'agents devices list' never shows it; this command
1249
+ is where dismissals are visible.
1227
1250
  `,
1228
1251
  });
1229
1252
  // ─── devices config (unified settings surface) ────────────────────────────