@phnx-labs/agents-cli 1.22.52 → 1.22.54

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 (180) hide show
  1. package/CHANGELOG.md +336 -0
  2. package/README.md +42 -9
  3. package/dist/bootstrap.js +55 -154
  4. package/dist/cli/command-registry.d.ts +5 -0
  5. package/dist/cli/command-registry.js +8 -1
  6. package/dist/commands/accounts.js +220 -174
  7. package/dist/commands/apply.js +6 -3
  8. package/dist/commands/auth-mint.d.ts +8 -0
  9. package/dist/commands/auth-mint.js +96 -0
  10. package/dist/commands/auth.js +5 -1
  11. package/dist/commands/browser.js +1 -1
  12. package/dist/commands/cost.js +8 -2
  13. package/dist/commands/daemon.js +2 -2
  14. package/dist/commands/doctor.js +6 -1
  15. package/dist/commands/exec.js +26 -17
  16. package/dist/commands/fleet-capture.js +7 -0
  17. package/dist/commands/focus.d.ts +1 -0
  18. package/dist/commands/focus.js +4 -2
  19. package/dist/commands/go.d.ts +5 -4
  20. package/dist/commands/go.js +8 -7
  21. package/dist/commands/insights.js +9 -0
  22. package/dist/commands/monitors.js +85 -30
  23. package/dist/commands/output.js +8 -2
  24. package/dist/commands/repo.js +18 -0
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions-inject.js +8 -3
  27. package/dist/commands/sessions-picker.js +2 -1
  28. package/dist/commands/sessions.d.ts +20 -12
  29. package/dist/commands/sessions.js +94 -40
  30. package/dist/commands/setup-accounts.d.ts +8 -0
  31. package/dist/commands/setup-accounts.js +47 -0
  32. package/dist/commands/setup.d.ts +1 -1
  33. package/dist/commands/setup.js +11 -2
  34. package/dist/commands/share.d.ts +52 -3
  35. package/dist/commands/share.js +262 -18
  36. package/dist/commands/ssh.d.ts +7 -0
  37. package/dist/commands/ssh.js +53 -14
  38. package/dist/commands/status.js +14 -0
  39. package/dist/commands/sync.js +44 -0
  40. package/dist/commands/view.d.ts +3 -1
  41. package/dist/commands/view.js +5 -4
  42. package/dist/lib/account-registry.d.ts +15 -5
  43. package/dist/lib/account-registry.js +165 -53
  44. package/dist/lib/accounting/rotate.d.ts +20 -6
  45. package/dist/lib/accounting/rotate.js +38 -7
  46. package/dist/lib/accounting/usage.d.ts +37 -1
  47. package/dist/lib/accounting/usage.js +71 -6
  48. package/dist/lib/agent-spec/agents.d.ts +5 -2
  49. package/dist/lib/agent-spec/agents.js +25 -7
  50. package/dist/lib/analytics/mix-commands.js +12 -6
  51. package/dist/lib/answer-router.js +2 -1
  52. package/dist/lib/auth-mint.d.ts +150 -0
  53. package/dist/lib/auth-mint.js +434 -0
  54. package/dist/lib/browser/profiles.d.ts +18 -0
  55. package/dist/lib/browser/profiles.js +26 -1
  56. package/dist/lib/browser/registry.d.ts +44 -14
  57. package/dist/lib/browser/registry.js +141 -45
  58. package/dist/lib/browser/remote-control.d.ts +9 -7
  59. package/dist/lib/browser/remote-control.js +9 -7
  60. package/dist/lib/claude-account-token.d.ts +10 -0
  61. package/dist/lib/claude-account-token.js +14 -4
  62. package/dist/lib/config-drift.d.ts +37 -0
  63. package/dist/lib/config-drift.js +72 -0
  64. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  65. package/dist/lib/daemon/auth-sync-service.js +34 -0
  66. package/dist/lib/daemon/daemon.js +30 -4
  67. package/dist/lib/daemon/runner.js +10 -2
  68. package/dist/lib/daemon-services.d.ts +1 -1
  69. package/dist/lib/daemon-services.js +5 -0
  70. package/dist/lib/device-config.d.ts +3 -3
  71. package/dist/lib/device-config.js +8 -7
  72. package/dist/lib/devices/config-migration.js +147 -1
  73. package/dist/lib/devices/connect.d.ts +26 -0
  74. package/dist/lib/devices/connect.js +48 -1
  75. package/dist/lib/devices/device-docs.d.ts +35 -0
  76. package/dist/lib/devices/device-docs.js +163 -0
  77. package/dist/lib/devices/discovery-policy.d.ts +14 -2
  78. package/dist/lib/devices/discovery-policy.js +31 -21
  79. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  80. package/dist/lib/devices/doctor-findings.js +19 -1
  81. package/dist/lib/devices/registry.d.ts +11 -5
  82. package/dist/lib/devices/registry.js +46 -18
  83. package/dist/lib/exec.d.ts +88 -30
  84. package/dist/lib/exec.js +138 -34
  85. package/dist/lib/feed/feed.d.ts +10 -2
  86. package/dist/lib/feed/feed.js +35 -2
  87. package/dist/lib/feed-broadcast.js +1 -1
  88. package/dist/lib/fleet/apply.d.ts +11 -0
  89. package/dist/lib/fleet/apply.js +23 -3
  90. package/dist/lib/fleet/auth-sync.js +5 -3
  91. package/dist/lib/help.d.ts +9 -0
  92. package/dist/lib/help.js +29 -1
  93. package/dist/lib/hosts/dispatch.d.ts +4 -3
  94. package/dist/lib/hosts/dispatch.js +12 -8
  95. package/dist/lib/hosts/passthrough.d.ts +1 -10
  96. package/dist/lib/hosts/passthrough.js +1 -13
  97. package/dist/lib/hosts/providers/local.d.ts +9 -3
  98. package/dist/lib/hosts/providers/local.js +23 -12
  99. package/dist/lib/hosts/reconnect.d.ts +7 -4
  100. package/dist/lib/hosts/reconnect.js +29 -25
  101. package/dist/lib/hosts/registry.js +4 -1
  102. package/dist/lib/hosts/remote-os.js +3 -1
  103. package/dist/lib/installations/versions.js +9 -1
  104. package/dist/lib/linux-userns.d.ts +58 -0
  105. package/dist/lib/linux-userns.js +116 -0
  106. package/dist/lib/memory.d.ts +26 -0
  107. package/dist/lib/memory.js +80 -1
  108. package/dist/lib/monitors/config.d.ts +11 -0
  109. package/dist/lib/monitors/config.js +8 -0
  110. package/dist/lib/monitors/engine.js +8 -1
  111. package/dist/lib/monitors/state.d.ts +37 -1
  112. package/dist/lib/monitors/state.js +79 -4
  113. package/dist/lib/permissions-registry.d.ts +2 -0
  114. package/dist/lib/permissions-registry.js +116 -14
  115. package/dist/lib/permissions.d.ts +5 -3
  116. package/dist/lib/permissions.js +25 -27
  117. package/dist/lib/profiles.d.ts +8 -7
  118. package/dist/lib/profiles.js +12 -0
  119. package/dist/lib/project-key.d.ts +9 -0
  120. package/dist/lib/project-key.js +11 -0
  121. package/dist/lib/secrets/bundles.d.ts +35 -0
  122. package/dist/lib/secrets/bundles.js +78 -1
  123. package/dist/lib/secrets/push.d.ts +3 -8
  124. package/dist/lib/secrets/push.js +18 -14
  125. package/dist/lib/secrets/remote.d.ts +9 -18
  126. package/dist/lib/secrets/remote.js +11 -26
  127. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  128. package/dist/lib/secrets/reserved-sync.js +129 -0
  129. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  130. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  131. package/dist/lib/self-heal/registry.js +4 -0
  132. package/dist/lib/self-heal/types.d.ts +1 -1
  133. package/dist/lib/session/active.d.ts +10 -1
  134. package/dist/lib/session/active.js +8 -5
  135. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  136. package/dist/lib/session/actor-sidecar.js +2 -0
  137. package/dist/lib/session/db.d.ts +39 -4
  138. package/dist/lib/session/db.js +168 -31
  139. package/dist/lib/session/discover.d.ts +32 -4
  140. package/dist/lib/session/discover.js +126 -37
  141. package/dist/lib/session/insights.d.ts +14 -0
  142. package/dist/lib/session/insights.js +25 -2
  143. package/dist/lib/session/linear.js +1 -1
  144. package/dist/lib/session/live-metadata.js +1 -0
  145. package/dist/lib/session/pid-registry.d.ts +7 -0
  146. package/dist/lib/session/prompt.d.ts +15 -0
  147. package/dist/lib/session/prompt.js +21 -0
  148. package/dist/lib/session/shell-programs.d.ts +17 -0
  149. package/dist/lib/session/shell-programs.js +21 -0
  150. package/dist/lib/session/state.js +2 -1
  151. package/dist/lib/session/stream-render.js +2 -1
  152. package/dist/lib/session/tool-calls.js +2 -5
  153. package/dist/lib/session/trajectory-html.js +2 -1
  154. package/dist/lib/session/trajectory.js +3 -12
  155. package/dist/lib/session/types.d.ts +25 -0
  156. package/dist/lib/session/types.js +10 -0
  157. package/dist/lib/share/publish.d.ts +53 -5
  158. package/dist/lib/share/publish.js +99 -17
  159. package/dist/lib/share/worker-template.js +594 -64
  160. package/dist/lib/startup/root-command.js +2 -1
  161. package/dist/lib/state.d.ts +24 -0
  162. package/dist/lib/state.js +318 -54
  163. package/dist/lib/sync-status.d.ts +4 -0
  164. package/dist/lib/sync-status.js +3 -0
  165. package/dist/lib/terminal/resolve.d.ts +7 -0
  166. package/dist/lib/terminal/resolve.js +41 -2
  167. package/dist/lib/traces/classify.js +24 -19
  168. package/dist/lib/traces/insights.d.ts +67 -0
  169. package/dist/lib/traces/insights.js +178 -0
  170. package/dist/lib/traces/phenotype.d.ts +67 -0
  171. package/dist/lib/traces/phenotype.js +437 -0
  172. package/dist/lib/traces/segments.d.ts +133 -0
  173. package/dist/lib/traces/segments.js +301 -0
  174. package/dist/lib/traces/sync.d.ts +33 -0
  175. package/dist/lib/traces/sync.js +11 -2
  176. package/dist/lib/types.d.ts +47 -1
  177. package/dist/lib/usage-refresh.js +2 -1
  178. package/dist/lib/view-types.d.ts +2 -0
  179. package/dist/lib/watchdog/runner.js +18 -4
  180. package/package.json +2 -1
package/dist/lib/help.js CHANGED
@@ -7,6 +7,27 @@ const commandGroupRegistry = new WeakMap();
7
7
  export function registerCommandGroups(parent, groups) {
8
8
  commandGroupRegistry.set(parent, groups);
9
9
  }
10
+ /**
11
+ * Front-door command groups shown on `agents --help`. Derived from measured
12
+ * reach: tier 1 = setup/run/sessions/view, tier 2 = teams/browser/secrets/
13
+ * devices/accounts/add. The remaining groups stay discoverable through the
14
+ * pointer rendered below these groups.
15
+ */
16
+ export const FRONT_DOOR_COMMAND_GROUPS = [
17
+ {
18
+ title: 'Quick start',
19
+ names: ['setup', 'view', 'run', 'sessions'],
20
+ },
21
+ {
22
+ title: 'Most-used',
23
+ names: ['teams', 'browser', 'secrets', 'devices', 'accounts', 'add'],
24
+ },
25
+ ];
26
+ const compactRootHelp = new WeakMap();
27
+ /** Mark the root program so its help only renders front-door groups + a pointer. */
28
+ export function setCompactRootHelp(program) {
29
+ compactRootHelp.set(program, true);
30
+ }
10
31
  const helpSectionRegistry = new WeakMap();
11
32
  /**
12
33
  * Attach an Examples block (rendered between the description and Arguments)
@@ -136,7 +157,14 @@ function formatHelpCommandsFirst(cmd, helper) {
136
157
  output = output.concat([`${title}:`, formatList(subs.map(renderCommand)), '']);
137
158
  }
138
159
  const remaining = visibleCommands.filter((s) => !placed.has(s.name()));
139
- if (remaining.length > 0) {
160
+ if (compactRootHelp.get(cmd)) {
161
+ output = output.concat([
162
+ 'Commands:',
163
+ ` See "${cmd.name()} --help-all" for every command.`,
164
+ '',
165
+ ]);
166
+ }
167
+ else if (remaining.length > 0) {
140
168
  output = output.concat(['Commands:', formatList(remaining.map(renderCommand)), '']);
141
169
  }
142
170
  }
@@ -40,9 +40,10 @@ export declare function withActorEnv(env?: Record<string, string>): Record<strin
40
40
  *
41
41
  * `extra` carries the markers that are true of ONE path rather than both — the
42
42
  * interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
43
- * to decide it must detach. It goes through this builder rather than being
44
- * concatenated on at the call site so every remote env marker is exported the
45
- * same way, in one place.
43
+ * to know its stdio is an ssh link (reconnect target, `--no-follow` pane
44
+ * requirement). It goes through this builder rather than being concatenated on
45
+ * at the call site so every remote env marker is exported the same way, in one
46
+ * place.
46
47
  */
47
48
  export declare function remoteRunShellPrelude(agent: string, extra?: Record<string, string>): string;
48
49
  /**
@@ -78,9 +78,10 @@ export function withActorEnv(env) {
78
78
  *
79
79
  * `extra` carries the markers that are true of ONE path rather than both — the
80
80
  * interactive dispatch adds REMOTE_INTERACTIVE_ENV, which the remote CLI reads
81
- * to decide it must detach. It goes through this builder rather than being
82
- * concatenated on at the call site so every remote env marker is exported the
83
- * same way, in one place.
81
+ * to know its stdio is an ssh link (reconnect target, `--no-follow` pane
82
+ * requirement). It goes through this builder rather than being concatenated on
83
+ * at the call site so every remote env marker is exported the same way, in one
84
+ * place.
84
85
  */
85
86
  export function remoteRunShellPrelude(agent, extra = {}) {
86
87
  const guard = agent === RUN_AUTO_KEYWORD ? { [RUN_AUTO_HOST_RESOLVED_ENV]: '1' } : {};
@@ -497,11 +498,14 @@ export async function runInteractiveOnHost(host, opts) {
497
498
  // than re-resolving from this box's SSH_CONNECTION (RUSH-2028); a `run auto`
498
499
  // dispatch also gets the chain-hop guard (remoteRunShellPrelude).
499
500
  //
500
- // REMOTE_INTERACTIVE_ENV rides the same prelude and is what makes the remote
501
- // agent DETACHED: its stdio is this ssh link, so without the tmux wrap a
502
- // blink SIGHUPs it and the in-flight turn is gone — while reconnect.ts is
503
- // built to re-attach a pane it assumes survived (RUSH-3125). Set here, on the
504
- // interactive path only: `launchDetached` already setsids the headless one.
501
+ // REMOTE_INTERACTIVE_ENV rides the same prelude and tells the remote CLI its
502
+ // stdio is this ssh link: it marks the run as reconnect-managed (a drop is
503
+ // answered by reconnect.ts, which rejoins the live pane or resumes the
504
+ // session in place) and, when the launcher has no TTY (CI, scripts), requires
505
+ // the detached pane that is the run's only interface. Since PHNX-3316 it no
506
+ // longer forces the tmux wrap on a TTY-followed run — the peer's
507
+ // tmux.enabled decides that. Set here, on the interactive path only:
508
+ // `launchDetached` already setsids the headless one.
505
509
  const prelude = remoteRunShellPrelude(opts.agent, { [REMOTE_INTERACTIVE_ENV]: '1' });
506
510
  let remoteCmd = `${prelude}${cwd}${invocation}`;
507
511
  if (opts.copyCreds) {
@@ -17,7 +17,7 @@
17
17
  * "not supported" message — never commander's raw `unknown option`.
18
18
  */
19
19
  import { type Host } from './types.js';
20
- import { type DeviceProfile, type DeviceRegistry } from '../devices/registry.js';
20
+ import { type DeviceRegistry } from '../devices/registry.js';
21
21
  import { runLocalCommand, runOnDevice } from '../devices/fleet.js';
22
22
  /** Re-export for callers that historically imported flagValue from this module. */
23
23
  export { flagValue, hasHostRoutingFlag } from './routing-flag.js';
@@ -69,15 +69,6 @@ export interface FleetPassthroughOptions {
69
69
  /** Override this machine's id (tests). Defaults to `machineId()`. */
70
70
  self?: string;
71
71
  }
72
- /**
73
- * Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
74
- * the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
75
- * joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
76
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
77
- * `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
78
- * self target runs locally and must stay ungated.
79
- */
80
- export declare function markFleetRemote(cmd: string[], device: DeviceProfile): string[];
81
72
  /** Run `agents <command> …` across every registered device and render the roster. */
82
73
  export declare function runFleetPassthrough(command: string, allArgs: string[], spec: RemoteSpec, opts?: FleetPassthroughOptions): Promise<boolean>;
83
74
  /**
@@ -32,6 +32,7 @@ import { isDeviceInteractive, resolveInteractiveDevice, interactiveUnsetError, }
32
32
  import { flagValue, hasHostRoutingFlag } from './routing-flag.js';
33
33
  import { loadDevices } from '../devices/registry.js';
34
34
  import { isSelfHost } from '../devices/self-host.js';
35
+ import { markFleetRemote } from '../devices/connect.js';
35
36
  import { fanOutDevices, planFleetTargets, runLocalCommand, runOnDevice, } from '../devices/fleet.js';
36
37
  import { platformGroupLabel } from '../devices/health-report.js';
37
38
  import { isKnownTopLevelCommand } from '../startup/command-registry.js';
@@ -368,19 +369,6 @@ function renderFleetRoster(command, forwarded, results, self) {
368
369
  console.log(chalk.gray(summaryParts.join(' · ')));
369
370
  }
370
371
  }
371
- /**
372
- * Prefix a fan-out remote command so the far side sees AGENTS_FLEET_REMOTE=1 —
373
- * the same marker the single-target dispatch sets via env. `wrapRemoteCommand`
374
- * joins the argv with spaces (POSIX) or base64-encodes it for PowerShell, so a
375
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
376
- * `$env:VAR='1'; …` on PowerShell. Only remote (non-self) targets get it; the
377
- * self target runs locally and must stay ungated.
378
- */
379
- export function markFleetRemote(cmd, device) {
380
- return device.shell === 'powershell'
381
- ? [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd]
382
- : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
383
- }
384
372
  /** Run `agents <command> …` across every registered device and render the roster. */
385
373
  export async function runFleetPassthrough(command, allArgs, spec, opts = {}) {
386
374
  const self = opts.self ?? machineId();
@@ -2,9 +2,15 @@
2
2
  * Local host provider: the v1 directory.
3
3
  *
4
4
  * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
- * details owned by ssh) and inline entries the user registered in agents.yaml.
6
- * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
- * never copy or rewrite ssh config.
5
+ * details owned by ssh) and inline entries the user registered. The host
6
+ * overlay (caps/os, keyed by name) is merged onto both. We never copy or
7
+ * rewrite ssh config.
8
+ *
9
+ * PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
10
+ * `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
11
+ * so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
12
+ * Reads are the cross-box UNION of every device doc, plus any lingering central
13
+ * legacy entries drained by the migration.
8
14
  */
9
15
  import type { Host, HostProvider, HostProviderCapabilities } from '../types.js';
10
16
  export declare class LocalHostProvider implements HostProvider {
@@ -2,14 +2,28 @@
2
2
  * Local host provider: the v1 directory.
3
3
  *
4
4
  * `list()` is the union of ssh-config `Host` stanzas (read-only, connection
5
- * details owned by ssh) and inline entries the user registered in agents.yaml.
6
- * The `Meta.hosts` overlay (caps/os, keyed by name) is merged onto both. We
7
- * never copy or rewrite ssh config.
5
+ * details owned by ssh) and inline entries the user registered. The host
6
+ * overlay (caps/os, keyed by name) is merged onto both. We never copy or
7
+ * rewrite ssh config.
8
+ *
9
+ * PHNX-3315: registrations are DEVICE-SCOPED — each box writes only its own
10
+ * `hosts:` block in `devices/<machine>/agents.yaml` (via `Meta.deviceHosts`),
11
+ * so N boxes no longer rewrite one shared `hosts:` map (the pull conflict).
12
+ * Reads are the cross-box UNION of every device doc, plus any lingering central
13
+ * legacy entries drained by the migration.
8
14
  */
9
15
  import { readMeta, updateMeta } from '../../state.js';
16
+ import { unionDeviceHosts } from '../../devices/device-docs.js';
10
17
  import { listSshConfigHosts, isSshConfigHost } from '../ssh-config.js';
18
+ /** The EFFECTIVE host overlay: the cross-box union of every device doc's
19
+ * `hosts:` block, with any lingering central-legacy `hosts:` entries as a base
20
+ * (a device doc wins on a name collision). Newest `addedAt` wins across boxes. */
11
21
  function entries() {
12
- return readMeta().hosts ?? {};
22
+ return { ...readMeta().hosts, ...unionDeviceHosts() };
23
+ }
24
+ /** THIS box's OWN host registrations (the writable slice in the device doc). */
25
+ function ownEntries(meta = readMeta()) {
26
+ return meta.deviceHosts ?? {};
13
27
  }
14
28
  function toHost(name, entry, enrolled) {
15
29
  return {
@@ -63,19 +77,16 @@ export class LocalHostProvider {
63
77
  ...(spec.caps && spec.caps.length ? { caps: spec.caps } : {}),
64
78
  addedAt: spec.addedAt ?? new Date().toISOString(),
65
79
  };
66
- updateMeta((meta) => ({ ...meta, hosts: { ...(meta.hosts ?? {}), [spec.name]: entry } }));
80
+ // Device-scoped: land in THIS box's device doc, never the shared central map.
81
+ updateMeta((meta) => ({ ...meta, deviceHosts: { ...ownEntries(meta), [spec.name]: entry } }));
67
82
  return toHost(spec.name, entry, true);
68
83
  }
69
84
  async remove(name) {
85
+ // A box only owns the registrations in its own device doc; drop from there.
70
86
  updateMeta((meta) => {
71
- const hosts = { ...(meta.hosts ?? {}) };
87
+ const hosts = { ...ownEntries(meta) };
72
88
  delete hosts[name];
73
- // Drop the key entirely when empty so we don't leave `hosts: {}` behind.
74
- if (Object.keys(hosts).length === 0) {
75
- const { hosts: _omit, ...rest } = meta;
76
- return rest;
77
- }
78
- return { ...meta, hosts };
89
+ return { ...meta, deviceHosts: hosts };
79
90
  });
80
91
  }
81
92
  }
@@ -211,10 +211,13 @@ export declare function startConnectionTarget(opts: {
211
211
  /**
212
212
  * Decide what happens after an interactive `--device` stream returns.
213
213
  *
214
- * Auto-reconnect only when the link dropped (255) AND the run is tmux-hosted
215
- * (`willReconnect`). `--raw` is not wrapped, so it never reconnects — but it
216
- * still prints the session id: the user is at a shell, and EXEC-55 does not
217
- * exempt raw (RUSH-3227). Bundling the notice behind `!isRaw` was the miss.
214
+ * Auto-reconnect fires whenever the link dropped (255) and the run did not opt
215
+ * out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
216
+ * survived, so the reattach rejoins it; a bare run's agent died with the link,
217
+ * so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
218
+ * never reconnects — but it still prints the session id: the user is at a
219
+ * shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
220
+ * behind `!isRaw` was the miss.
218
221
  */
219
222
  export declare function afterInteractiveRemoteExit(opts: {
220
223
  target?: ReconnectTarget;
@@ -2,34 +2,35 @@
2
2
  * Auto-reconnect for an interactive `agents run --device` session whose
3
3
  * SSH link dropped.
4
4
  *
5
- * A remote interactive agent runs in a DETACHED tmux session on the peer (see
6
- * lib/exec.ts `runInTmux`), so a network blink kills only the local ssh client —
7
- * the agent keeps running.
8
- *
9
- * **That premise is a guarantee, not a hope, only since RUSH-3125.** It used to
10
- * rest on the peer's `tmux.enabled`, an ergonomics preference that defaults OFF
11
- * — so on a default box the agent was a child of the sshd session, a blink
12
- * SIGHUPed it, and this file reconnected to a corpse while telling the user it
13
- * was "still running there." The interactive dispatch now exports
14
- * REMOTE_INTERACTIVE_ENV and exec.ts `resolveTmuxWrap` wraps on it regardless of
15
- * that toggle, so what is written below actually holds. Anything that would let
16
- * a remote interactive run reach the peer unwrapped breaks this whole file.
5
+ * What the reconnect lands on depends on the peer's `tmux.enabled`
6
+ * (PHNX-3316). With the wrap opted in, the agent runs in a DETACHED tmux
7
+ * session on the peer (see lib/exec.ts `runInTmux`), so a network blink kills
8
+ * only the local ssh client and the reattach below rejoins the live pane.
9
+ * With the wrap off — the fleet default — the remote agent is a child of the
10
+ * sshd session and a blink SIGHUPs it: the in-flight turn is lost, and the
11
+ * reattach RESUMES the harness session from disk instead. Both outcomes go
12
+ * through the same verb below; what changed in PHNX-3316 is that "resumed"
13
+ * is once again an honest, expected result rather than a corpse this file
14
+ * pretended was alive (the pre-RUSH-3125 bug), and RUSH-3125's forced wrap —
15
+ * which made the pane a guarantee by overriding the operator's tmux.enabled —
16
+ * is gone with it.
17
17
  *
18
18
  * `sshStream` reports that drop as exit code 255 (ssh's
19
19
  * own connection-layer failure; see ssh-exec.ts). Without this, exec.ts would
20
20
  * `process.exit(255)` and the user would have to notice, find the session id, and
21
- * `agents sessions focus` by hand. Instead we re-attach the live remote pane over
22
- * SSH automatically, with bounded backoff, until the user detaches cleanly (the
23
- * remote returns 0), the agent exits (the tmux session is gone; a non-255 code),
24
- * or the user interrupts the wait with Ctrl-C ({@link waitOrInterrupt} → 130).
21
+ * `agents sessions focus` by hand. Instead we re-attach over SSH automatically,
22
+ * with bounded backoff, until the user detaches cleanly (the remote returns 0),
23
+ * the agent exits (a non-255 code), or the user interrupts the wait with
24
+ * Ctrl-C ({@link waitOrInterrupt} → 130).
25
25
  *
26
26
  * The re-attach reuses the peer's OWN recovery verb — `agents sessions focus <id>
27
27
  * --local` — which JOINS the live local tmux pane there (a second client, no fork)
28
- * when it still exists, and RESUMES the session in place when the pane is already
29
- * gone. Dropping `--attach-only` is deliberate: a reattach that lands after the
30
- * remote pane died must not dead-end at a bare shell (the RUSH-2085 bug), it must
31
- * fall through to resume so the user is put back into the agent. There is one
32
- * re-attach implementation (the peer's focus) to keep in sync.
28
+ * when it exists, and RESUMES the session in place when there is no pane — which
29
+ * is every drop on a default (wrap-off) box. Dropping `--attach-only` is
30
+ * deliberate: a reattach that finds no pane must not dead-end at a bare shell
31
+ * (the RUSH-2085 bug), it must fall through to resume so the user is put back
32
+ * into the agent. There is one re-attach implementation (the peer's focus) to
33
+ * keep in sync.
33
34
  *
34
35
  * **What it takes to refill the budget: reached the host AND held the pane.** ssh
35
36
  * returns 255 for BOTH "couldn't connect at all" and "connected, then the link
@@ -314,10 +315,13 @@ export function startConnectionTarget(opts) {
314
315
  /**
315
316
  * Decide what happens after an interactive `--device` stream returns.
316
317
  *
317
- * Auto-reconnect only when the link dropped (255) AND the run is tmux-hosted
318
- * (`willReconnect`). `--raw` is not wrapped, so it never reconnects — but it
319
- * still prints the session id: the user is at a shell, and EXEC-55 does not
320
- * exempt raw (RUSH-3227). Bundling the notice behind `!isRaw` was the miss.
318
+ * Auto-reconnect fires whenever the link dropped (255) and the run did not opt
319
+ * out with `--raw` (`willReconnect`) — wrap or no wrap. A wrapped run's pane
320
+ * survived, so the reattach rejoins it; a bare run's agent died with the link,
321
+ * so the same verb resumes the session in place from disk (PHNX-3316). `--raw`
322
+ * never reconnects — but it still prints the session id: the user is at a
323
+ * shell, and EXEC-55 does not exempt raw (RUSH-3227). Bundling the notice
324
+ * behind `!isRaw` was the miss.
321
325
  */
322
326
  export function afterInteractiveRemoteExit(opts) {
323
327
  if (!opts.target)
@@ -22,6 +22,7 @@ import { DevicesHostProvider } from './providers/devices.js';
22
22
  import { assertValidSshTarget } from '../ssh-exec.js';
23
23
  import { normalizeHost } from '../machine-id.js';
24
24
  import { readMeta } from '../state.js';
25
+ import { unionDeviceHosts } from '../devices/device-docs.js';
25
26
  import { isSshConfigHost } from './ssh-config.js';
26
27
  import { resolveRemoteOsSync } from './remote-os.js';
27
28
  import { loadDevices } from '../devices/registry.js';
@@ -187,7 +188,9 @@ export async function matchHost(name, opts = {}) {
187
188
  catch {
188
189
  reg = {};
189
190
  }
190
- const overlay = readMeta().hosts?.[host];
191
+ // The effective host overlay is the cross-box union of every device doc's
192
+ // `hosts:` block (PHNX-3315), plus any lingering central-legacy entry.
193
+ const overlay = { ...readMeta().hosts, ...unionDeviceHosts() }[host];
191
194
  // 1. A registered device (normalized match) — its live address/OS/presence win.
192
195
  const device = matchDevice(host, reg);
193
196
  if (device)
@@ -19,6 +19,7 @@
19
19
  import { loadDevicesSync } from '../devices/registry.js';
20
20
  import { readDeviceConfigValues } from '../device-config.js';
21
21
  import { readMeta } from '../state.js';
22
+ import { unionDeviceHosts } from '../devices/device-docs.js';
22
23
  /** Resolve the OS/platform string for a host name, or undefined if unknown. */
23
24
  export function resolveRemoteOsSync(name) {
24
25
  try {
@@ -33,5 +34,6 @@ export function resolveRemoteOsSync(name) {
33
34
  // A corrupt/unreadable device registry must never break command building —
34
35
  // fall through to the host overlay and ultimately the POSIX default.
35
36
  }
36
- return readMeta().hosts?.[name]?.os;
37
+ // Cross-box union of the device-scoped host overlays, central legacy as base.
38
+ return ({ ...readMeta().hosts, ...unionDeviceHosts() }[name])?.os;
37
39
  }
@@ -49,7 +49,7 @@ import { emit } from '../feed/events.js';
49
49
  import { safeJoin } from '../paths.js';
50
50
  import { readSkillSourceCommandMarker, shouldAlsoInstallCommandAsSkill, shouldInstallCommandAsSkill, } from '../command-skills.js';
51
51
  import { getWriter, getDetector } from '../staleness/registry.js';
52
- import { syncMemoryToVersionHome } from '../memory.js';
52
+ import { syncMemoryToVersionHome, syncClaudeProjectMemoryDir } from '../memory.js';
53
53
  import { listPluginSkillNames, resolveCommandSource, resolveSkillSource } from '../staleness/writers/sources.js';
54
54
  import { syncProjectResourcesToAgent } from '../project-resources.js';
55
55
  import { installClaudeStatusLine } from '../claude-statusline.js';
@@ -2860,6 +2860,14 @@ export function syncResourcesToVersion(agent, version, selection, options = {})
2860
2860
  if (supports(agent, 'memory', version).ok) {
2861
2861
  syncMemoryToVersionHome(agent, versionHome, cwd);
2862
2862
  }
2863
+ // Claude Code's own NATIVE per-project auto-memory (.claude/projects/<key>/memory/,
2864
+ // PHNX-2817) is a separate, unmanaged directory Claude writes into itself — make it
2865
+ // version-independent the same way project-level rules already are, via a shared
2866
+ // symlink, so a note survives an agent version upgrade instead of vanishing into a
2867
+ // fresh, empty version home.
2868
+ if (agent === 'claude') {
2869
+ syncClaudeProjectMemoryDir(versionHome, cwd);
2870
+ }
2863
2871
  // Prune resources deleted from source (RUSH-2438). Runs only on a repo-scope
2864
2872
  // reconcile (`options.prune`) with a caller selection — a full sync
2865
2873
  // (`!userPassedSelection`) already sweeps orphans above, and an additive
@@ -0,0 +1,58 @@
1
+ /** Path of the AppArmor knob that gates unprivileged userns on Ubuntu 23.10+. */
2
+ export declare const APPARMOR_USERNS_SYSCTL_PATH = "/proc/sys/kernel/apparmor_restrict_unprivileged_userns";
3
+ export type UsernsState =
4
+ /** A new user namespace with a uid map can be created — Codex's sandbox works. */
5
+ 'ok'
6
+ /** Unprivileged userns is restricted — Codex's bwrap sandbox cannot start. */
7
+ | 'blocked'
8
+ /** Could not determine (non-Linux, or the probe could not run). */
9
+ | 'unknown';
10
+ export interface UsernsStatus {
11
+ state: UsernsState;
12
+ /** One-line human reason, present when `blocked` or `unknown`. */
13
+ reason?: string;
14
+ }
15
+ /** The raw signals the pure interpreter reasons over. */
16
+ export interface UsernsInputs {
17
+ platform: NodeJS.Platform;
18
+ /**
19
+ * Contents of {@link APPARMOR_USERNS_SYSCTL_PATH} trimmed, or null when the
20
+ * file is absent (older kernels / no AppArmor userns mediation).
21
+ */
22
+ apparmorRestrict: string | null;
23
+ /**
24
+ * Result of actually attempting to create a user namespace with a uid map:
25
+ * - 'ok' → the probe created the namespace and mapped root.
26
+ * - 'denied' → the kernel refused the uid_map write (the restricted case).
27
+ * - 'no-tool' → the probe binary (`unshare`) was missing or failed to spawn.
28
+ */
29
+ unshareProbe: 'ok' | 'denied' | 'no-tool';
30
+ }
31
+ /**
32
+ * Decide userns availability from raw signals. Pure — no I/O.
33
+ *
34
+ * The definitive signal is the actual probe: if we successfully created a userns
35
+ * and wrote a uid map, the sandbox works regardless of the sysctl (an AppArmor
36
+ * profile may grant a specific binary `userns` even while the global knob is 1).
37
+ * A denied probe is a hard `blocked`. When the probe tool is missing we fall back
38
+ * to the sysctl: `1` → `blocked`, `0`/absent → `unknown` (we could not prove it,
39
+ * and refuse to claim `ok` we did not observe).
40
+ */
41
+ export declare function interpretUsernsInputs(inputs: UsernsInputs): UsernsStatus;
42
+ /** Read the AppArmor userns sysctl, or null when the file is absent. */
43
+ export declare function readApparmorRestrict(sysctlPath?: string): string | null;
44
+ /**
45
+ * Actually try to create a user namespace and map root inside it — the same
46
+ * operation bwrap performs (`unshare --user --map-root-user`). This is the ground
47
+ * truth: it observes exactly what the kernel/AppArmor policy permits for *this*
48
+ * process, rather than inferring from the sysctl alone.
49
+ */
50
+ export declare function probeUnshare(): 'ok' | 'denied' | 'no-tool';
51
+ /**
52
+ * Resolve whether an unprivileged user namespace can be created on this host,
53
+ * cached for the process (the answer is a stable property of the box). Non-Linux
54
+ * short-circuits to `ok` without spawning anything.
55
+ */
56
+ export declare function probeUnprivilegedUserns(platform?: NodeJS.Platform): UsernsStatus;
57
+ /** Test-only: drop the process cache so a test can re-probe. */
58
+ export declare function resetUsernsCacheForTests(): void;
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Unprivileged user-namespace availability on Linux — the capability Codex's
3
+ * Linux sandbox needs, and the one Ubuntu 23.10+ restricts by default.
4
+ *
5
+ * Codex ≥0.146 implements its `read-only` and `workspace-write` sandbox modes on
6
+ * Linux with a bundled **bubblewrap** (`bwrap`), extracted per-run to
7
+ * `$CODEX_HOME/tmp/arg0/codex-XXXX/` and exec'd from a memfd. bwrap sets up its
8
+ * mounts inside a fresh **unprivileged user namespace** (`--unshare-user`, then a
9
+ * write to `/proc/self/uid_map`). Ubuntu 24.04 ships
10
+ * `kernel.apparmor_restrict_unprivileged_userns=1`, which denies that to an
11
+ * unconfined binary — so bwrap dies with `bwrap: setting up uid map: Permission
12
+ * denied` and a headless Codex run lands zero tools (no file writes, no shell).
13
+ * `danger-full-access` (our `skip` mode) drops the sandbox and is the only mode
14
+ * that avoids bwrap; the legacy Landlock backend is gone (`use_linux_sandbox_bwrap`
15
+ * is `removed`, `use_legacy_landlock` panics under the permission-profile model).
16
+ *
17
+ * This module is the single detector. It is pure at its core
18
+ * ({@link interpretUsernsInputs}) so the decision is unit-testable without a
19
+ * shell, and {@link probeUnprivilegedUserns} gathers the real inputs once per
20
+ * process. See PHNX-3285.
21
+ */
22
+ import { execFileSync } from 'child_process';
23
+ import * as fs from 'fs';
24
+ /** Path of the AppArmor knob that gates unprivileged userns on Ubuntu 23.10+. */
25
+ export const APPARMOR_USERNS_SYSCTL_PATH = '/proc/sys/kernel/apparmor_restrict_unprivileged_userns';
26
+ /**
27
+ * Decide userns availability from raw signals. Pure — no I/O.
28
+ *
29
+ * The definitive signal is the actual probe: if we successfully created a userns
30
+ * and wrote a uid map, the sandbox works regardless of the sysctl (an AppArmor
31
+ * profile may grant a specific binary `userns` even while the global knob is 1).
32
+ * A denied probe is a hard `blocked`. When the probe tool is missing we fall back
33
+ * to the sysctl: `1` → `blocked`, `0`/absent → `unknown` (we could not prove it,
34
+ * and refuse to claim `ok` we did not observe).
35
+ */
36
+ export function interpretUsernsInputs(inputs) {
37
+ if (inputs.platform !== 'linux')
38
+ return { state: 'ok' };
39
+ if (inputs.unshareProbe === 'ok')
40
+ return { state: 'ok' };
41
+ if (inputs.unshareProbe === 'denied') {
42
+ const via = inputs.apparmorRestrict === '1'
43
+ ? ' (kernel.apparmor_restrict_unprivileged_userns=1)'
44
+ : '';
45
+ return {
46
+ state: 'blocked',
47
+ reason: `the kernel denied creating an unprivileged user namespace${via}`,
48
+ };
49
+ }
50
+ // Probe tool unavailable — lean on the AppArmor knob.
51
+ if (inputs.apparmorRestrict === '1') {
52
+ return {
53
+ state: 'blocked',
54
+ reason: 'unprivileged user namespaces are AppArmor-restricted ' +
55
+ '(kernel.apparmor_restrict_unprivileged_userns=1) and `unshare` was not available to confirm',
56
+ };
57
+ }
58
+ return {
59
+ state: 'unknown',
60
+ reason: '`unshare` was not available to probe user-namespace support',
61
+ };
62
+ }
63
+ /** Read the AppArmor userns sysctl, or null when the file is absent. */
64
+ export function readApparmorRestrict(sysctlPath = APPARMOR_USERNS_SYSCTL_PATH) {
65
+ try {
66
+ return fs.readFileSync(sysctlPath, 'utf8').trim();
67
+ }
68
+ catch {
69
+ return null;
70
+ }
71
+ }
72
+ /**
73
+ * Actually try to create a user namespace and map root inside it — the same
74
+ * operation bwrap performs (`unshare --user --map-root-user`). This is the ground
75
+ * truth: it observes exactly what the kernel/AppArmor policy permits for *this*
76
+ * process, rather than inferring from the sysctl alone.
77
+ */
78
+ export function probeUnshare() {
79
+ try {
80
+ execFileSync('unshare', ['--user', '--map-root-user', 'true'], {
81
+ stdio: 'ignore',
82
+ timeout: 5000,
83
+ });
84
+ return 'ok';
85
+ }
86
+ catch (err) {
87
+ // ENOENT / spawn failure → the tool isn't here; anything else (nonzero exit
88
+ // from the denied uid_map write) is the restricted case.
89
+ const code = err?.code;
90
+ if (code === 'ENOENT')
91
+ return 'no-tool';
92
+ return 'denied';
93
+ }
94
+ }
95
+ let cached = null;
96
+ /**
97
+ * Resolve whether an unprivileged user namespace can be created on this host,
98
+ * cached for the process (the answer is a stable property of the box). Non-Linux
99
+ * short-circuits to `ok` without spawning anything.
100
+ */
101
+ export function probeUnprivilegedUserns(platform = process.platform) {
102
+ if (platform !== 'linux')
103
+ return { state: 'ok' };
104
+ if (cached)
105
+ return cached;
106
+ cached = interpretUsernsInputs({
107
+ platform,
108
+ apparmorRestrict: readApparmorRestrict(),
109
+ unshareProbe: probeUnshare(),
110
+ });
111
+ return cached;
112
+ }
113
+ /** Test-only: drop the process cache so a test can re-probe. */
114
+ export function resetUsernsCacheForTests() {
115
+ cached = null;
116
+ }
@@ -53,3 +53,29 @@ export declare function rebuildMemoryIndex(dir: string): void;
53
53
  export declare function memoryTargetDir(agent: AgentId): string;
54
54
  /** Copy canonical layered memory into one version home. Returns fact names written. */
55
55
  export declare function syncMemoryToVersionHome(agent: AgentId, versionHome: string, cwd?: string): string[];
56
+ /**
57
+ * Canonical shared dir for Claude Code's NATIVE per-project auto-memory —
58
+ * `<versionHome>/.claude/projects/<project-key>/memory/*.md`, the freeform
59
+ * notes Claude writes for itself during a session. Distinct from the layered
60
+ * `memory` resource above (~/.agents/memory/ facts synced into
61
+ * `.claude/memory/`): this dir is keyed by project (via
62
+ * {@link claudeProjectDirName}), not by agent version, and Claude Code itself
63
+ * decides what goes in it — agents-cli only makes the directory
64
+ * version-independent, never writes into it.
65
+ */
66
+ export declare function getClaudeProjectMemoryDir(cwd: string): string;
67
+ /**
68
+ * Make Claude Code's native per-project memory dir version-independent by
69
+ * symlinking `<versionHome>/.claude/projects/<project-key>/memory/` into the
70
+ * one canonical dir every installed Claude version's home shares for this
71
+ * project (PHNX-2817). Without this, `getVersionHomePath` gives every
72
+ * installed version its own isolated HOME, so a note written under one
73
+ * version is invisible under another — the directory is just empty there.
74
+ *
75
+ * Idempotent and safe to call on every sync: a dir already linked to the
76
+ * canonical target is left alone; a PRE-EXISTING real directory with content
77
+ * (the common case today, since this bug has always left one behind) has its
78
+ * files migrated into the canonical dir first — never discarded — before
79
+ * being replaced by the symlink.
80
+ */
81
+ export declare function syncClaudeProjectMemoryDir(versionHome: string, cwd?: string): void;