@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
@@ -1,5 +1,6 @@
1
1
  /** Synced device approval/ignore policy and local registry reconciliation. */
2
2
  import { readMeta, updateMeta } from '../state.js';
3
+ import { unionDeviceDiscovery } from './device-docs.js';
3
4
  import { addIgnored, assertValidDeviceName, loadDevices, removeDevice, removeIgnored, upsertDevice, } from './registry.js';
4
5
  import { localLoginUser, withDefaultUser } from './sync.js';
5
6
  import { nodeToDeviceInput, parseTailscaleStatus, tailscaleStatusJson } from './tailscale.js';
@@ -8,36 +9,46 @@ export function getDeviceDiscoveryStatus(name) {
8
9
  assertValidDeviceName(name);
9
10
  return loadDeviceDiscoveryPolicies().get(name);
10
11
  }
11
- /** Persist one portable decision in the central fleet manifest. */
12
+ /**
13
+ * Persist ONE discovery decision in THIS box's device doc (PHNX-3315). Each box
14
+ * records only its own choices in `devices/<machine>/agents.yaml` `fleet.discovery`,
15
+ * so N boxes no longer rewrite one shared central map (the guaranteed pull
16
+ * conflict). The effective policy is the union across every box
17
+ * ({@link loadDeviceDiscoveryPolicies}).
18
+ */
12
19
  export function setDeviceDiscoveryStatus(name, status) {
13
20
  assertValidDeviceName(name);
14
21
  updateMeta((meta) => {
15
- const discovery = { ...meta.fleet?.discovery };
22
+ const discovery = { ...meta.deviceFleet?.discovery };
16
23
  if (status)
17
24
  discovery[name] = status;
18
25
  else
19
26
  delete discovery[name];
20
- const fleet = {
21
- ...meta.fleet,
22
- devices: meta.fleet?.devices ?? {},
23
- // Keep an empty map as the synced tombstone: it distinguishes "policy is
24
- // authoritative and every device is pending" from an older config that
25
- // has never opted into portable discovery decisions.
26
- discovery,
27
- };
28
- return { ...meta, fleet };
27
+ return { ...meta, deviceFleet: { ...meta.deviceFleet, discovery } };
29
28
  });
30
29
  }
31
- /** Load every explicit decision from the synced central fleet manifest. */
30
+ /**
31
+ * The effective discovery policy: the UNION across every box's device doc, plus
32
+ * any lingering central-legacy map (drained by the fold-then-delete migration).
33
+ * Precedence for a name declared by more than one box is deterministic and
34
+ * order-independent — `ignored` beats `approved` — so every box computes the
35
+ * identical policy. Absence means pending.
36
+ */
32
37
  export function loadDeviceDiscoveryPolicies() {
33
38
  const policies = new Map();
34
- for (const [name, status] of Object.entries(readMeta().fleet?.discovery ?? {})) {
35
- assertValidDeviceName(name);
36
- if (status !== 'approved' && status !== 'ignored') {
37
- throw new Error(`Device discovery policy for '${name}' must be approved or ignored.`);
39
+ const apply = (rec) => {
40
+ for (const [name, status] of Object.entries(rec ?? {})) {
41
+ assertValidDeviceName(name);
42
+ if (status !== 'approved' && status !== 'ignored') {
43
+ throw new Error(`Device discovery policy for '${name}' must be approved or ignored.`);
44
+ }
45
+ if (policies.get(name) === 'ignored')
46
+ continue; // ignored is never downgraded
47
+ policies.set(name, status);
38
48
  }
39
- policies.set(name, status);
40
- }
49
+ };
50
+ apply(readMeta().fleet?.discovery); // central legacy, until the migration drains it
51
+ apply(unionDeviceDiscovery()); // per-box device docs (ignored still wins)
41
52
  return policies;
42
53
  }
43
54
  /**
@@ -55,11 +66,10 @@ export function loadDeviceDiscoveryPolicies() {
55
66
  * that never meant to touch them.
56
67
  */
57
68
  export async function reconcileDeviceDiscoveryPolicies() {
58
- const configured = readMeta().fleet?.discovery;
59
- if (configured === undefined) {
69
+ const policies = loadDeviceDiscoveryPolicies();
70
+ if (policies.size === 0) {
60
71
  return { approved: [], ignored: [], registered: [], unresolved: [] };
61
72
  }
62
- const policies = loadDeviceDiscoveryPolicies();
63
73
  const approved = [...policies].filter(([, s]) => s === 'approved').map(([n]) => n).sort();
64
74
  const ignored = [...policies].filter(([, s]) => s === 'ignored').map(([n]) => n).sort();
65
75
  for (const name of ignored) {
@@ -176,10 +176,12 @@ export declare function removeDevice(name: string): Promise<boolean>;
176
176
  */
177
177
  export type { IgnoredDeviceEntry } from '../fleet/types.js';
178
178
  /**
179
- * The full ignore-list entries — who dismissed a node, when, and on which box —
180
- * the typed read side for `agents devices ignored`. Absent `fleet.ignored` =>
181
- * []. A malformed block is a hard error for the same reason the registry is:
182
- * silently returning [] would let the next write wipe the user's dismissals.
179
+ * The EFFECTIVE ignore-list: the union of every box's device doc
180
+ * `fleet.ignored` (deduped by node name, newest `ignoredAt` winning) plus any
181
+ * lingering central-legacy `fleet.ignored` block (drained by the migration).
182
+ * Deterministic and order-independent. A malformed block is a hard error for the
183
+ * same reason the registry is: silently returning [] would let the next write
184
+ * wipe the user's dismissals.
183
185
  */
184
186
  export declare function loadIgnoredEntries(meta?: Meta): IgnoredDeviceEntry[];
185
187
  /** Load the set of ignored node names. Same corruption contract as
@@ -195,7 +197,11 @@ export declare function isIgnored(name: string): Promise<boolean>;
195
197
  * lib/devices/config-migration.ts.
196
198
  */
197
199
  export declare function withIgnoredAdded(meta: Meta, names: string[], ignoredAt: string): Meta;
198
- /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
200
+ /** Add a node name to THIS box's ignore-list (device doc). Idempotent. Returns
201
+ * the resulting cross-box union of dismissed names. Reads only the device docs
202
+ * for the return value — a corrupt central-legacy block surfaces loudly on the
203
+ * effective read path ({@link loadIgnoredEntries}), never blocks a per-box
204
+ * write that does not touch central at all. */
199
205
  export declare function addIgnored(name: string): Promise<Set<string>>;
200
206
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
201
207
  * not ignored. */
@@ -20,6 +20,7 @@ import lockfile from 'proper-lockfile';
20
20
  import { getDevicesRegistryPath, readMeta, updateMeta } from '../state.js';
21
21
  import { atomicWriteJsonSync } from '../fs-atomic.js';
22
22
  import { machineId } from '../machine-id.js';
23
+ import { addIgnoredEntry, unionDeviceIgnored } from './device-docs.js';
23
24
  /**
24
25
  * Whether a fan-out should dial this device, honouring the preference stated on
25
26
  * {@link DeviceProfile.reachability}: the live SSH probe wins over the cached
@@ -287,19 +288,48 @@ export async function removeDevice(name) {
287
288
  * []. A malformed block is a hard error for the same reason the registry is:
288
289
  * silently returning [] would let the next write wipe the user's dismissals.
289
290
  */
290
- export function loadIgnoredEntries(meta = readMeta()) {
291
- const raw = meta.fleet?.ignored;
292
- if (raw === undefined)
293
- return [];
291
+ /** Validate a raw ignore-list block, or throw with `where` naming the file. */
292
+ function assertIgnoredShape(raw, where) {
294
293
  if (!Array.isArray(raw) ||
295
294
  raw.some((e) => !e ||
296
295
  typeof e.name !== 'string' ||
297
296
  typeof e.ignoredAt !== 'string' ||
298
297
  typeof e.ignoredOn !== 'string')) {
299
- throw new Error(`Device ignore-list corrupted in agents.yaml (fleet.ignored): expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair ~/.agents/agents.yaml.`);
298
+ throw new Error(`Device ignore-list corrupted in ${where}: expected a list of { name, ignoredAt, ignoredOn } entries. Inspect and repair it.`);
300
299
  }
300
+ }
301
+ /**
302
+ * THIS box's OWN dismissals — the writable slice in `meta.deviceFleet.ignored`
303
+ * (the device doc). `withIgnoredAdded`/`removeIgnored` operate on this so a box
304
+ * only ever edits its own folder (PHNX-3315). Absent => [].
305
+ */
306
+ function loadOwnIgnoredEntries(meta) {
307
+ const raw = meta.deviceFleet?.ignored;
308
+ if (raw === undefined)
309
+ return [];
310
+ assertIgnoredShape(raw, `devices/<machine>/agents.yaml (fleet.ignored)`);
301
311
  return raw;
302
312
  }
313
+ /**
314
+ * The EFFECTIVE ignore-list: the union of every box's device doc
315
+ * `fleet.ignored` (deduped by node name, newest `ignoredAt` winning) plus any
316
+ * lingering central-legacy `fleet.ignored` block (drained by the migration).
317
+ * Deterministic and order-independent. A malformed block is a hard error for the
318
+ * same reason the registry is: silently returning [] would let the next write
319
+ * wipe the user's dismissals.
320
+ */
321
+ export function loadIgnoredEntries(meta = readMeta()) {
322
+ const byName = new Map();
323
+ const central = meta.fleet?.ignored;
324
+ if (central !== undefined) {
325
+ assertIgnoredShape(central, `agents.yaml (fleet.ignored)`);
326
+ for (const e of central)
327
+ addIgnoredEntry(byName, e);
328
+ }
329
+ for (const e of unionDeviceIgnored())
330
+ addIgnoredEntry(byName, e);
331
+ return [...byName.values()].sort((a, b) => a.name.localeCompare(b.name));
332
+ }
303
333
  /** Load the set of ignored node names. Same corruption contract as
304
334
  * {@link loadIgnoredEntries}. */
305
335
  export async function loadIgnored() {
@@ -317,40 +347,38 @@ export async function isIgnored(name) {
317
347
  * lib/devices/config-migration.ts.
318
348
  */
319
349
  export function withIgnoredAdded(meta, names, ignoredAt) {
320
- const entries = loadIgnoredEntries(meta); // throws on a corrupted block — never wipe it
350
+ const entries = loadOwnIgnoredEntries(meta); // throws on a corrupted block — never wipe it
321
351
  const have = new Set(entries.map((e) => e.name));
322
352
  const fresh = names.filter((n) => !have.has(n));
323
353
  if (fresh.length === 0)
324
354
  return meta;
325
- const fleet = (meta.fleet ?? { devices: {} });
326
355
  const ignored = [
327
356
  ...entries,
328
357
  ...fresh.map((name) => ({ name, ignoredAt, ignoredOn: machineId() })),
329
358
  ].sort((a, b) => a.name.localeCompare(b.name));
330
- const nextFleet = { ...fleet, ignored };
331
- return { ...meta, fleet: nextFleet };
359
+ return { ...meta, deviceFleet: { ...meta.deviceFleet, ignored } };
332
360
  }
333
- /** Add a node name to the ignore-list. Idempotent. Returns the resulting set. */
361
+ /** Add a node name to THIS box's ignore-list (device doc). Idempotent. Returns
362
+ * the resulting cross-box union of dismissed names. Reads only the device docs
363
+ * for the return value — a corrupt central-legacy block surfaces loudly on the
364
+ * effective read path ({@link loadIgnoredEntries}), never blocks a per-box
365
+ * write that does not touch central at all. */
334
366
  export async function addIgnored(name) {
335
367
  assertValidDeviceName(name);
336
- const meta = updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
337
- return new Set(loadIgnoredEntries(meta).map((e) => e.name));
368
+ updateMeta((m) => withIgnoredAdded(m, [name], new Date().toISOString()));
369
+ return new Set(unionDeviceIgnored().map((e) => e.name));
338
370
  }
339
371
  /** Remove a node name from the ignore-list (un-ignore). Returns false if it was
340
372
  * not ignored. */
341
373
  export async function removeIgnored(name) {
342
374
  let removed = false;
343
375
  updateMeta((m) => {
344
- const fleet = m.fleet;
345
- if (!fleet?.ignored)
346
- return m;
347
- const entries = loadIgnoredEntries(m);
376
+ const entries = loadOwnIgnoredEntries(m); // only this box's own dismissals are ours to drop
348
377
  const next = entries.filter((e) => e.name !== name);
349
378
  if (next.length === entries.length)
350
379
  return m;
351
380
  removed = true;
352
- const nextFleet = { ...fleet, ignored: next };
353
- return { ...m, fleet: nextFleet };
381
+ return { ...m, deviceFleet: { ...m.deviceFleet, ignored: next } };
354
382
  });
355
383
  return removed;
356
384
  }
@@ -93,6 +93,14 @@ export type ExecEffort = 'low' | 'medium' | 'high' | 'xhigh' | 'max' | 'auto';
93
93
  /** Options for spawning an agent process. Omitting `prompt` launches the CLI interactively. */
94
94
  export interface ExecOptions {
95
95
  agent: AgentId;
96
+ /**
97
+ * Custom harness / profile name when this run was launched via
98
+ * `agents run <profile>` (e.g. `deepseek`). `agent` stays the HOST CLI
99
+ * that actually executes. Stamped onto `AGENTS_AGENT_NAME`, the pid
100
+ * registry, and the session-actor sidecar so listings can tell the
101
+ * profile apart from a native host run (PHNX-2935).
102
+ */
103
+ harnessName?: string;
96
104
  version?: string;
97
105
  /** Version home whose native auth/config is overlaid onto this run's binary. */
98
106
  configVersion?: string;
@@ -177,6 +185,18 @@ export interface ExecOptions {
177
185
  */
178
186
  raw?: boolean;
179
187
  }
188
+ /**
189
+ * Identity a custom-harness run stamps on env / pid-registry / sidecars.
190
+ * `agent` is the host CLI; `harnessName` is the profile the user launched.
191
+ * Empty/whitespace harness names fall back to the host so a blank stamp
192
+ * never hides a real agent.
193
+ */
194
+ export declare function stampedAgentName(options: Pick<ExecOptions, 'agent' | 'harnessName'>): string;
195
+ /**
196
+ * Profile name when it differs from the host agent. Undefined for a native
197
+ * run, so pid-registry / sidecar records stay sparse.
198
+ */
199
+ export declare function customHarnessName(options: Pick<ExecOptions, 'agent' | 'harnessName'>): string | undefined;
180
200
  /**
181
201
  * Resolve interactive vs headless. Explicit flags are definitive and win over
182
202
  * inference: `--interactive` forces interactive, `--headless` forces headless.
@@ -406,15 +426,18 @@ export interface TmuxWrapContext {
406
426
  * True when this run was dispatched onto this box over SSH by `--device`
407
427
  * (the launcher exports {@link REMOTE_INTERACTIVE_ENV}).
408
428
  *
409
- * A remote interactive agent is a child of the sshd session and holds its
410
- * controlling TTY, so without the wrap a dropped link SIGHUPs it and the work
411
- * in flight is gone. Durability is therefore NOT a preference the way
412
- * `configEnabled` is — `tmux.enabled` is about whether this box's operator
413
- * likes tmux's mouse/clipboard/scrollback at their own keyboard, which says
414
- * nothing about whether a run arriving over the network must outlive it.
415
- * Conflating the two is what left every `--device` agent unsurvivable
416
- * (RUSH-3125), while lib/hosts/reconnect.ts reconnected on the premise that
417
- * they were detached.
429
+ * Since PHNX-3316 this flag no longer forces the wrap: `tmux.enabled` gates
430
+ * local and remote runs alike, because the operator reading "tmux disabled"
431
+ * expects NO tmux anywhere. A followed remote run left bare is protected by
432
+ * reconnect-and-resume (lib/hosts/reconnect.ts): a dropped link costs the
433
+ * in-flight turn, and the harness session resumes from disk.
434
+ *
435
+ * The flag still matters for one case: a followed remote run whose LAUNCHER
436
+ * has no TTY (CI, a script, another agent driving the CLI — `sshStream`
437
+ * allocates the peer's TTY from `process.stdin.isTTY`, dispatch.ts) gets no
438
+ * TTY on the peer either, so there is nothing to attach to and the detached
439
+ * pane is the run's only interface — it wraps regardless of `configEnabled`
440
+ * (and is refused as `undurable` when tmux is missing).
418
441
  */
419
442
  remoteDispatch: boolean;
420
443
  /** Whether a tmux binary is on PATH. */
@@ -423,16 +446,19 @@ export interface TmuxWrapContext {
423
446
  * True when this process has a real TTY to attach (`stdout.isTTY`).
424
447
  * A piped `agents run --interactive` (session-tracker tests, CI) has none:
425
448
  * wrapping then treating the failed attach as Ctrl-b d leaked live panes
426
- * for a week on yosemite-s0 (PHNX-3293). Remote dispatch still wraps
427
- * without a TTY — `--device --no-follow` *wants* a detached pane.
449
+ * for a week on yosemite-s0 (PHNX-3293). A remote dispatch still wraps
450
+ * without a TTY — a launcher that has none (CI, scripts, another agent)
451
+ * gives the peer nothing to attach to, so the detached pane is the run's
452
+ * only interface.
428
453
  */
429
454
  hasTty: boolean;
430
455
  }
431
456
  /**
432
- * What to do with an interactive spawn. Three outcomes, not two: a run that
433
- * MUST be durable and cannot be is neither "wrap" nor "spawn bare" — it is a
434
- * launch that should not happen, because a bare remote spawn looks fine right
435
- * up until the link blinks and the agent dies with it.
457
+ * What to do with an interactive spawn. Three outcomes, not two: a remote run
458
+ * that WOULD wrap (the operator opted in, or a `--no-follow` run whose only
459
+ * interface is the detached pane) on a box with no tmux is neither "wrap" nor
460
+ * "spawn bare" — it is a launch that should not happen, because a bare remote
461
+ * spawn looks fine right up until the link blinks and the agent dies with it.
436
462
  */
437
463
  export type TmuxWrapDecision =
438
464
  /** Run the agent in a detached tmux session and attach this TTY. */
@@ -443,7 +469,7 @@ export type TmuxWrapDecision =
443
469
  | {
444
470
  kind: 'bare';
445
471
  }
446
- /** Remote-dispatched and tmux is missing on this box: refuse, don't pretend. */
472
+ /** Remote-dispatched, wants the wrap, and tmux is missing: refuse, don't pretend. */
447
473
  | {
448
474
  kind: 'undurable';
449
475
  };
@@ -451,20 +477,24 @@ export type TmuxWrapDecision =
451
477
  * Decide whether to run an interactive agent INSIDE a detached tmux session on
452
478
  * the shared socket (then attach the current TTY) instead of a bare spawn.
453
479
  *
454
- * Wrapping serves two independent purposes, and they are gated differently:
455
- *
456
- * - **Addressability** (`configEnabled`): a unique `%pane` handle so
457
- * `agents sessions --active` can tell co-located agents apart and `agents
458
- * focus` re-attaches without forking. That is a local preference — the
459
- * operator turns it on once tmux's mouse/clipboard/scrollback suits them.
460
- * - **Durability** (`remoteDispatch`): a run that arrived over SSH must outlive
461
- * the SSH client, because a bare remote spawn dies of SIGHUP the moment the
462
- * link blinks. That is not a preference, so it does not consult
463
- * `configEnabled` (RUSH-3125 — see {@link TmuxWrapContext.remoteDispatch}).
464
- *
465
- * The per-run opt-outs bind BOTH: `--raw` / `--no-tmux` / `AGENTS_NO_TMUX=1` are
466
- * explicit "I want the bare process" requests, and honouring them over the
467
- * durability rule keeps one escape hatch that always works.
480
+ * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
481
+ * unique `%pane` handle so `agents sessions --active` can tell co-located
482
+ * agents apart and `agents focus` re-attaches without forking, plus scrollback
483
+ * and mouse at the operator's keyboard. Off means OFF, for local and remote
484
+ * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
485
+ * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
486
+ * when one exists and resumes the harness session from disk when it does not.
487
+ * The RUSH-3125 forced remote wrap conflated durability with that preference
488
+ * and surprised every operator who had explicitly left tmux off.
489
+ *
490
+ * One case still wraps regardless: a followed remote run whose launcher has
491
+ * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
492
+ * the detached pane is the run's only interface, not an ergonomics choice.
493
+ *
494
+ * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
495
+ * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
496
+ * escape hatch that silently stopped applying over `--device` would be worse
497
+ * than the bare run the user asked for.
468
498
  *
469
499
  * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
470
500
  */
package/dist/lib/exec.js CHANGED
@@ -181,6 +181,26 @@ export function defaultModeFor(agent) {
181
181
  export function implicitModeFor(agent) {
182
182
  return agent === 'codex' ? 'edit' : 'plan';
183
183
  }
184
+ /**
185
+ * Identity a custom-harness run stamps on env / pid-registry / sidecars.
186
+ * `agent` is the host CLI; `harnessName` is the profile the user launched.
187
+ * Empty/whitespace harness names fall back to the host so a blank stamp
188
+ * never hides a real agent.
189
+ */
190
+ export function stampedAgentName(options) {
191
+ const harness = options.harnessName?.trim();
192
+ return harness || options.agent;
193
+ }
194
+ /**
195
+ * Profile name when it differs from the host agent. Undefined for a native
196
+ * run, so pid-registry / sidecar records stay sparse.
197
+ */
198
+ export function customHarnessName(options) {
199
+ const harness = options.harnessName?.trim();
200
+ if (!harness || harness === options.agent)
201
+ return undefined;
202
+ return harness;
203
+ }
184
204
  /**
185
205
  * Resolve interactive vs headless. Explicit flags are definitive and win over
186
206
  * inference: `--interactive` forces interactive, `--headless` forces headless.
@@ -346,8 +366,11 @@ export function buildExecEnv(options) {
346
366
  result.AGENTS_RUN_MODE = resolveHeadlessMode(options.agent, normalizeMode(options.mode), resolveInteractive(options), options.modeWarningContext, options.modeWarningState);
347
367
  result.AGENTS_HISTORY_DIR = getHistoryDir();
348
368
  // So activity / feed posts stamp the right harness without re-detecting.
369
+ // A custom-harness run (`agents run deepseek`) must stamp the PROFILE name,
370
+ // not the host CLI (`claude`) — otherwise sessions and feed posts cannot
371
+ // tell the two apart (PHNX-2935).
349
372
  if (options.agent) {
350
- result.AGENTS_AGENT_NAME = options.agent;
373
+ result.AGENTS_AGENT_NAME = stampedAgentName(options);
351
374
  }
352
375
  if (options.cwd) {
353
376
  result.AGENTS_CWD = options.cwd;
@@ -1176,36 +1199,38 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1176
1199
  * Decide whether to run an interactive agent INSIDE a detached tmux session on
1177
1200
  * the shared socket (then attach the current TTY) instead of a bare spawn.
1178
1201
  *
1179
- * Wrapping serves two independent purposes, and they are gated differently:
1202
+ * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
1203
+ * unique `%pane` handle so `agents sessions --active` can tell co-located
1204
+ * agents apart and `agents focus` re-attaches without forking, plus scrollback
1205
+ * and mouse at the operator's keyboard. Off means OFF, for local and remote
1206
+ * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
1207
+ * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
1208
+ * when one exists and resumes the harness session from disk when it does not.
1209
+ * The RUSH-3125 forced remote wrap conflated durability with that preference
1210
+ * and surprised every operator who had explicitly left tmux off.
1180
1211
  *
1181
- * - **Addressability** (`configEnabled`): a unique `%pane` handle so
1182
- * `agents sessions --active` can tell co-located agents apart and `agents
1183
- * focus` re-attaches without forking. That is a local preference — the
1184
- * operator turns it on once tmux's mouse/clipboard/scrollback suits them.
1185
- * - **Durability** (`remoteDispatch`): a run that arrived over SSH must outlive
1186
- * the SSH client, because a bare remote spawn dies of SIGHUP the moment the
1187
- * link blinks. That is not a preference, so it does not consult
1188
- * `configEnabled` (RUSH-3125 — see {@link TmuxWrapContext.remoteDispatch}).
1212
+ * One case still wraps regardless: a followed remote run whose launcher has
1213
+ * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
1214
+ * the detached pane is the run's only interface, not an ergonomics choice.
1189
1215
  *
1190
- * The per-run opt-outs bind BOTH: `--raw` / `--no-tmux` / `AGENTS_NO_TMUX=1` are
1191
- * explicit "I want the bare process" requests, and honouring them over the
1192
- * durability rule keeps one escape hatch that always works.
1216
+ * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
1217
+ * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
1218
+ * escape hatch that silently stopped applying over `--device` would be worse
1219
+ * than the bare run the user asked for.
1193
1220
  *
1194
1221
  * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
1195
1222
  */
1196
1223
  export function resolveTmuxWrap(ctx) {
1197
1224
  // A headless `-p` run has no TTY to attach, Windows has no tmux path, and
1198
- // nesting tmux-in-tmux is pointless — none of these can wrap, and none of them
1199
- // is a remote interactive agent whose life depends on it.
1225
+ // nesting tmux-in-tmux is pointless.
1200
1226
  if (!ctx.interactive)
1201
1227
  return { kind: 'bare' };
1202
1228
  if (ctx.platform === 'win32')
1203
1229
  return { kind: 'bare' };
1204
1230
  if (ctx.inTmux)
1205
1231
  return { kind: 'bare' };
1206
- // Explicit opt-outs win over the durability rule: `--raw` exists precisely to
1207
- // get the unwrapped process, and a flag that silently stopped working on a
1208
- // remote box would be worse than an undurable run the user asked for.
1232
+ // Explicit opt-outs win over every other rule: `--raw` exists precisely to
1233
+ // get the unwrapped process.
1209
1234
  if (ctx.raw)
1210
1235
  return { kind: 'bare' };
1211
1236
  if (ctx.noTmuxEnv)
@@ -1213,10 +1238,13 @@ export function resolveTmuxWrap(ctx) {
1213
1238
  // Local interactive with no TTY cannot attach. Wrapping anyway creates a
1214
1239
  // detached pane, attach returns immediately, and resolveAfterAttach treats
1215
1240
  // the still-alive pane as Ctrl-b d — the session-tracker test leak.
1216
- // Remote dispatch is the exception: --no-follow *intends* a detached pane.
1217
1241
  if (!ctx.hasTty && !ctx.remoteDispatch)
1218
1242
  return { kind: 'bare' };
1219
- if (!ctx.configEnabled && !ctx.remoteDispatch)
1243
+ // tmux.enabled gates the wrap for local AND followed remote runs. The one
1244
+ // exception: a followed remote run whose launcher has no TTY (CI, scripts)
1245
+ // gives the peer nothing to attach to — the detached pane is its only
1246
+ // interface, infrastructure rather than ergonomics.
1247
+ if (!ctx.configEnabled && !(ctx.remoteDispatch && !ctx.hasTty))
1220
1248
  return { kind: 'bare' };
1221
1249
  // Fail loud rather than launch a remote agent that a blink would kill: the
1222
1250
  // caller refuses the run instead of starting work that cannot be recovered.
@@ -1520,6 +1548,7 @@ async function runInTmux(options, executable, args) {
1520
1548
  writePidSessionEntry({
1521
1549
  pid: panePid,
1522
1550
  agent: options.agent,
1551
+ harness: customHarnessName(options),
1523
1552
  sessionId: options.sessionId,
1524
1553
  cwd,
1525
1554
  actor: resolveActor().id,
@@ -1538,6 +1567,7 @@ async function runInTmux(options, executable, args) {
1538
1567
  sessionId: options.sessionId,
1539
1568
  actor: resolveActor().id,
1540
1569
  initiatedBy: resolveActor().kind,
1570
+ harness: customHarnessName(options),
1541
1571
  startedAtMs: Date.now(),
1542
1572
  });
1543
1573
  }
@@ -1662,9 +1692,10 @@ async function spawnAgent(options) {
1662
1692
  args: redactArgs(args.slice(0, 10)),
1663
1693
  });
1664
1694
  // Interactive spawn-wrap: run the agent INSIDE a shared-socket tmux session
1665
- // (then attach this TTY) so it gets a unique, addressable %pane — and, when
1666
- // the run arrived over `--device`, so it survives the SSH link that carries
1667
- // it. Every failed guard keeps the bare spawn below.
1695
+ // (then attach this TTY) so it gets a unique, addressable %pane. Opt-in via
1696
+ // this device's tmux.enabled, local and remote alike (PHNX-3316); a followed
1697
+ // remote run left bare relies on reconnect-and-resume, not a pane. Every
1698
+ // failed guard keeps the bare spawn below.
1668
1699
  const tmuxWrap = resolveTmuxWrap({
1669
1700
  interactive,
1670
1701
  platform: process.platform,
@@ -1683,10 +1714,15 @@ async function spawnAgent(options) {
1683
1714
  // there is no launcher-side probe ahead of this one. Failing here still
1684
1715
  // fails before the agent starts, which is what matters; a pre-dispatch
1685
1716
  // probe would only move the message earlier, at the cost of a round trip on
1686
- // every launch.
1687
- const msg = `agents: ${machineId()} has no tmux, so a --device run here could not survive a dropped connection.\n`
1688
- + ` install it: (apt|dnf|brew) install tmux\n`
1689
- + ` or accept the risk: agents run … --device ${machineId()} --raw\n`;
1717
+ // every launch. Reached only when the wrap was wanted: the device opted in
1718
+ // via tmux.enabled, or a followed run whose launcher had no TTY (CI,
1719
+ // scripts) left the peer nothing to attach to, so the pane is its stdout.
1720
+ // The config tip uses the ssh form because tmux.enabled is machine-local:
1721
+ // setting it from the launcher throws (assertLocalTarget).
1722
+ const msg = `agents: ${machineId()} has no tmux, so this --device run cannot get the pane it needs.\n`
1723
+ + ` install it: (apt|dnf|brew) install tmux\n`
1724
+ + ` or turn the wrap off: agents ssh ${machineId()} 'agents devices config ${machineId()} tmux.enabled off'\n`
1725
+ + ` or accept a bare run: agents run … --device ${machineId()} --raw\n`;
1690
1726
  process.stderr.write(`\x1b[31m${msg}\x1b[0m`);
1691
1727
  timer.end({ exitCode: 1, status: 'failed', error: 'remote interactive run has no tmux for durability' });
1692
1728
  return { exitCode: 1, stdout: '', stderr: msg };
@@ -1737,6 +1773,7 @@ async function spawnAgent(options) {
1737
1773
  writePidSessionEntry({
1738
1774
  pid: child.pid ?? 0,
1739
1775
  agent: options.agent,
1776
+ harness: customHarnessName(options),
1740
1777
  sessionId: options.sessionId,
1741
1778
  cwd: options.cwd || process.cwd(),
1742
1779
  actor: resolveActor().id,
@@ -1751,6 +1788,7 @@ async function spawnAgent(options) {
1751
1788
  sessionId: options.sessionId,
1752
1789
  actor: resolveActor().id,
1753
1790
  initiatedBy: resolveActor().kind,
1791
+ harness: customHarnessName(options),
1754
1792
  startedAtMs: Date.now(),
1755
1793
  });
1756
1794
  }