@phnx-labs/agents-cli 1.22.53 → 1.22.55

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 (166) hide show
  1. package/CHANGELOG.md +208 -0
  2. package/README.md +59 -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 +219 -173
  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 +10 -8
  16. package/dist/commands/focus.d.ts +1 -0
  17. package/dist/commands/focus.js +2 -2
  18. package/dist/commands/go.d.ts +5 -4
  19. package/dist/commands/go.js +7 -7
  20. package/dist/commands/insights.js +9 -0
  21. package/dist/commands/monitors.js +85 -30
  22. package/dist/commands/output.js +8 -2
  23. package/dist/commands/repo.js +18 -0
  24. package/dist/commands/routines.js +31 -2
  25. package/dist/commands/secrets.js +33 -14
  26. package/dist/commands/sessions.d.ts +20 -12
  27. package/dist/commands/sessions.js +64 -20
  28. package/dist/commands/setup-accounts.d.ts +8 -0
  29. package/dist/commands/setup-accounts.js +47 -0
  30. package/dist/commands/setup.d.ts +1 -1
  31. package/dist/commands/setup.js +11 -2
  32. package/dist/commands/share.d.ts +79 -3
  33. package/dist/commands/share.js +347 -18
  34. package/dist/commands/ssh.d.ts +7 -0
  35. package/dist/commands/ssh.js +18 -2
  36. package/dist/commands/status.js +14 -0
  37. package/dist/commands/view.d.ts +11 -1
  38. package/dist/commands/view.js +35 -7
  39. package/dist/lib/account-registry.js +15 -3
  40. package/dist/lib/accounting/rotate.d.ts +20 -6
  41. package/dist/lib/accounting/rotate.js +38 -7
  42. package/dist/lib/accounting/usage.d.ts +68 -1
  43. package/dist/lib/accounting/usage.js +116 -10
  44. package/dist/lib/agent-spec/agents.d.ts +5 -2
  45. package/dist/lib/agent-spec/agents.js +25 -7
  46. package/dist/lib/analytics/mix-commands.js +12 -6
  47. package/dist/lib/auth-mint.d.ts +150 -0
  48. package/dist/lib/auth-mint.js +434 -0
  49. package/dist/lib/browser/cdp.d.ts +1 -1
  50. package/dist/lib/browser/cdp.js +1 -1
  51. package/dist/lib/browser/ffmpeg.d.ts +12 -0
  52. package/dist/lib/browser/ffmpeg.js +184 -0
  53. package/dist/lib/browser/remote-control.d.ts +9 -7
  54. package/dist/lib/browser/remote-control.js +9 -7
  55. package/dist/lib/browser/service.js +119 -25
  56. package/dist/lib/claude-account-token.d.ts +10 -0
  57. package/dist/lib/claude-account-token.js +14 -4
  58. package/dist/lib/config-drift.d.ts +37 -0
  59. package/dist/lib/config-drift.js +72 -0
  60. package/dist/lib/daemon/auth-sync-service.d.ts +19 -0
  61. package/dist/lib/daemon/auth-sync-service.js +34 -0
  62. package/dist/lib/daemon/browser-task-reap-service.d.ts +14 -0
  63. package/dist/lib/daemon/browser-task-reap-service.js +26 -0
  64. package/dist/lib/daemon/daemon.js +87 -176
  65. package/dist/lib/daemon/heartbeat-service.d.ts +13 -0
  66. package/dist/lib/daemon/heartbeat-service.js +26 -0
  67. package/dist/lib/daemon/monitor-engine-service.d.ts +9 -5
  68. package/dist/lib/daemon/monitor-engine-service.js +15 -7
  69. package/dist/lib/daemon/runner.d.ts +15 -0
  70. package/dist/lib/daemon/runner.js +23 -0
  71. package/dist/lib/daemon/secrets-broker-service.d.ts +5 -4
  72. package/dist/lib/daemon/secrets-broker-service.js +17 -32
  73. package/dist/lib/daemon/service.d.ts +2 -2
  74. package/dist/lib/daemon/service.js +1 -1
  75. package/dist/lib/daemon/session-state-service.d.ts +21 -0
  76. package/dist/lib/daemon/session-state-service.js +34 -0
  77. package/dist/lib/daemon/supervisor.d.ts +17 -7
  78. package/dist/lib/daemon/supervisor.js +87 -14
  79. package/dist/lib/daemon/tmux-reap-service.d.ts +11 -0
  80. package/dist/lib/daemon/tmux-reap-service.js +28 -0
  81. package/dist/lib/daemon/webhook-receiver-service.d.ts +9 -0
  82. package/dist/lib/daemon/webhook-receiver-service.js +17 -0
  83. package/dist/lib/daemon-services.d.ts +1 -1
  84. package/dist/lib/daemon-services.js +25 -0
  85. package/dist/lib/device-config.d.ts +3 -3
  86. package/dist/lib/device-config.js +5 -5
  87. package/dist/lib/devices/connect.d.ts +26 -0
  88. package/dist/lib/devices/connect.js +48 -1
  89. package/dist/lib/devices/doctor-findings.d.ts +5 -1
  90. package/dist/lib/devices/doctor-findings.js +19 -1
  91. package/dist/lib/devices/harness-inventory.js +5 -2
  92. package/dist/lib/exec.d.ts +28 -0
  93. package/dist/lib/exec.js +73 -7
  94. package/dist/lib/feed/feed.d.ts +1 -1
  95. package/dist/lib/feed/feed.js +23 -1
  96. package/dist/lib/feed-broadcast.js +1 -1
  97. package/dist/lib/fleet/apply.d.ts +11 -0
  98. package/dist/lib/fleet/apply.js +23 -3
  99. package/dist/lib/fleet/auth-sync.js +5 -3
  100. package/dist/lib/help.d.ts +9 -0
  101. package/dist/lib/help.js +29 -1
  102. package/dist/lib/hosts/passthrough.d.ts +1 -10
  103. package/dist/lib/hosts/passthrough.js +1 -13
  104. package/dist/lib/installations/versions.js +9 -1
  105. package/dist/lib/linux-userns.d.ts +58 -0
  106. package/dist/lib/linux-userns.js +116 -0
  107. package/dist/lib/memory.d.ts +26 -0
  108. package/dist/lib/memory.js +80 -1
  109. package/dist/lib/monitors/config.d.ts +11 -0
  110. package/dist/lib/monitors/config.js +8 -0
  111. package/dist/lib/monitors/engine.d.ts +5 -1
  112. package/dist/lib/monitors/engine.js +13 -4
  113. package/dist/lib/monitors/state.d.ts +37 -1
  114. package/dist/lib/monitors/state.js +79 -4
  115. package/dist/lib/permissions-registry.d.ts +2 -0
  116. package/dist/lib/permissions-registry.js +116 -14
  117. package/dist/lib/permissions.d.ts +5 -3
  118. package/dist/lib/permissions.js +25 -27
  119. package/dist/lib/profiles.d.ts +8 -7
  120. package/dist/lib/profiles.js +12 -0
  121. package/dist/lib/project-key.d.ts +9 -0
  122. package/dist/lib/project-key.js +11 -0
  123. package/dist/lib/scheduling/routines.d.ts +47 -0
  124. package/dist/lib/scheduling/routines.js +70 -1
  125. package/dist/lib/secrets/bundles.d.ts +35 -0
  126. package/dist/lib/secrets/bundles.js +78 -1
  127. package/dist/lib/secrets/push.d.ts +3 -8
  128. package/dist/lib/secrets/push.js +18 -14
  129. package/dist/lib/secrets/remote.d.ts +9 -18
  130. package/dist/lib/secrets/remote.js +11 -26
  131. package/dist/lib/secrets/reserved-sync.d.ts +65 -0
  132. package/dist/lib/secrets/reserved-sync.js +129 -0
  133. package/dist/lib/self-heal/checks/hook-manifest.d.ts +2 -0
  134. package/dist/lib/self-heal/checks/hook-manifest.js +56 -0
  135. package/dist/lib/self-heal/registry.js +4 -0
  136. package/dist/lib/self-heal/types.d.ts +1 -1
  137. package/dist/lib/session/active.js +1 -4
  138. package/dist/lib/session/db.d.ts +41 -5
  139. package/dist/lib/session/db.js +132 -30
  140. package/dist/lib/session/discover.d.ts +32 -4
  141. package/dist/lib/session/discover.js +119 -25
  142. package/dist/lib/session/insights.d.ts +14 -0
  143. package/dist/lib/session/insights.js +25 -2
  144. package/dist/lib/session/linear.js +1 -1
  145. package/dist/lib/session/shell-programs.d.ts +17 -0
  146. package/dist/lib/session/shell-programs.js +21 -0
  147. package/dist/lib/session/state.js +2 -1
  148. package/dist/lib/session/stream-render.js +2 -1
  149. package/dist/lib/session/tool-calls.js +2 -5
  150. package/dist/lib/session/trajectory-html.js +2 -1
  151. package/dist/lib/session/trajectory.js +3 -12
  152. package/dist/lib/session/types.d.ts +8 -0
  153. package/dist/lib/share/capture.js +11 -2
  154. package/dist/lib/share/publish.d.ts +56 -5
  155. package/dist/lib/share/publish.js +126 -18
  156. package/dist/lib/share/worker-template.d.ts +3 -12
  157. package/dist/lib/share/worker-template.js +860 -59
  158. package/dist/lib/startup/root-command.js +2 -1
  159. package/dist/lib/state.d.ts +16 -0
  160. package/dist/lib/state.js +178 -46
  161. package/dist/lib/sync-status.d.ts +4 -0
  162. package/dist/lib/sync-status.js +3 -0
  163. package/dist/lib/traces/classify.js +24 -19
  164. package/dist/lib/usage-refresh.js +2 -1
  165. package/dist/lib/view-types.d.ts +7 -0
  166. package/package.json +9 -2
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
  }
@@ -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();
@@ -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;
@@ -13,9 +13,10 @@
13
13
  */
14
14
  import * as fs from 'fs';
15
15
  import * as path from 'path';
16
- import { getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ensureAgentsDir, } from './state.js';
16
+ import { getUserAgentsDir, getSystemAgentsDir, getProjectAgentsDir, ensureAgentsDir, getRuntimeStateDir, } from './state.js';
17
17
  import { agentConfigDirName } from './agents.js';
18
18
  import { supports } from './capabilities.js';
19
+ import { claudeProjectDirName } from './project-key.js';
19
20
  /** User-layer memory root (~/.agents/memory/). */
20
21
  export function getUserMemoryDir() {
21
22
  return path.join(getUserAgentsDir(), 'memory');
@@ -273,3 +274,81 @@ export function syncMemoryToVersionHome(agent, versionHome, cwd = process.cwd())
273
274
  catch { /* best-effort */ }
274
275
  return written;
275
276
  }
277
+ /**
278
+ * Canonical shared dir for Claude Code's NATIVE per-project auto-memory —
279
+ * `<versionHome>/.claude/projects/<project-key>/memory/*.md`, the freeform
280
+ * notes Claude writes for itself during a session. Distinct from the layered
281
+ * `memory` resource above (~/.agents/memory/ facts synced into
282
+ * `.claude/memory/`): this dir is keyed by project (via
283
+ * {@link claudeProjectDirName}), not by agent version, and Claude Code itself
284
+ * decides what goes in it — agents-cli only makes the directory
285
+ * version-independent, never writes into it.
286
+ */
287
+ export function getClaudeProjectMemoryDir(cwd) {
288
+ const projectKey = claudeProjectDirName(path.resolve(cwd));
289
+ return path.join(getRuntimeStateDir(), 'claude-project-memory', projectKey);
290
+ }
291
+ /**
292
+ * Make Claude Code's native per-project memory dir version-independent by
293
+ * symlinking `<versionHome>/.claude/projects/<project-key>/memory/` into the
294
+ * one canonical dir every installed Claude version's home shares for this
295
+ * project (PHNX-2817). Without this, `getVersionHomePath` gives every
296
+ * installed version its own isolated HOME, so a note written under one
297
+ * version is invisible under another — the directory is just empty there.
298
+ *
299
+ * Idempotent and safe to call on every sync: a dir already linked to the
300
+ * canonical target is left alone; a PRE-EXISTING real directory with content
301
+ * (the common case today, since this bug has always left one behind) has its
302
+ * files migrated into the canonical dir first — never discarded — before
303
+ * being replaced by the symlink.
304
+ */
305
+ export function syncClaudeProjectMemoryDir(versionHome, cwd = process.cwd()) {
306
+ const projectKey = claudeProjectDirName(path.resolve(cwd));
307
+ const canonicalDir = path.join(getRuntimeStateDir(), 'claude-project-memory', projectKey);
308
+ const projectDir = path.join(versionHome, agentConfigDirName('claude'), 'projects', projectKey);
309
+ const nativeMemoryDir = path.join(projectDir, 'memory');
310
+ fs.mkdirSync(canonicalDir, { recursive: true, mode: 0o700 });
311
+ let existing;
312
+ try {
313
+ existing = fs.lstatSync(nativeMemoryDir);
314
+ }
315
+ catch { /* nothing there yet */ }
316
+ if (existing?.isSymbolicLink()) {
317
+ let currentTarget;
318
+ try {
319
+ currentTarget = fs.readlinkSync(nativeMemoryDir);
320
+ }
321
+ catch { /* dangling link */ }
322
+ if (currentTarget === canonicalDir)
323
+ return; // already wired correctly
324
+ fs.unlinkSync(nativeMemoryDir); // stale/foreign link — replace below
325
+ }
326
+ else if (existing?.isDirectory()) {
327
+ // Migrate first (never clobber content already promoted to canonical by
328
+ // an earlier-synced version home), then remove the now-redundant copy.
329
+ fs.cpSync(nativeMemoryDir, canonicalDir, { recursive: true, force: false, errorOnExist: false });
330
+ fs.rmSync(nativeMemoryDir, { recursive: true, force: true });
331
+ }
332
+ else if (existing) {
333
+ return; // an unexpected file at this path — leave it alone rather than destroy it
334
+ }
335
+ fs.mkdirSync(projectDir, { recursive: true });
336
+ try {
337
+ fs.symlinkSync(canonicalDir, nativeMemoryDir, process.platform === 'win32' ? 'junction' : undefined);
338
+ }
339
+ catch (err) {
340
+ // A concurrent sync (e.g. two `agents run claude` launches racing on a
341
+ // first-ever project) can win this exact link between our lstat above
342
+ // and this call. If it landed the same canonical target, that's the
343
+ // outcome we wanted — treat it as success rather than throwing.
344
+ if (err?.code !== 'EEXIST')
345
+ throw err;
346
+ let racedTarget;
347
+ try {
348
+ racedTarget = fs.readlinkSync(nativeMemoryDir);
349
+ }
350
+ catch { /* not even a symlink — fall through to rethrow */ }
351
+ if (racedTarget !== canonicalDir)
352
+ throw err;
353
+ }
354
+ }
@@ -84,6 +84,17 @@ export interface ActionConfig {
84
84
  notifyChannel?: string;
85
85
  /** webhook-out: URL to POST the event to. */
86
86
  url?: string;
87
+ /**
88
+ * Shell command that must exit 0 after a `run`/`routine` action settles.
89
+ * Asserts the stated effect actually happened (PHNX-2842) — e.g.
90
+ * `gh pr view 1682 --json state --jq .state | grep -qx MERGED`. `{event}` is
91
+ * replaced with the fired event summary, same as the prompt. Evaluated once
92
+ * the dispatched run is no longer `running`; a failed check makes the fire
93
+ * `ok: false` with effect `none` (`postcondition not met`), not a healthy
94
+ * `completed`. Notify/webhook-out already have a synchronous ok and refuse
95
+ * this field.
96
+ */
97
+ postcondition?: string;
87
98
  }
88
99
  /** Full monitor configuration (persisted as YAML in ~/.agents/monitors/). */
89
100
  export interface MonitorConfig {
@@ -238,6 +238,14 @@ export function validateMonitor(config) {
238
238
  errors.push(`action.url must be an absolute URL (got ${JSON.stringify(action.url)})`);
239
239
  }
240
240
  }
241
+ if (action.postcondition !== undefined) {
242
+ if (action.type !== 'run' && action.type !== 'routine') {
243
+ errors.push("action.postcondition only applies to run or routine actions");
244
+ }
245
+ else if (typeof action.postcondition !== 'string' || action.postcondition.trim() === '') {
246
+ errors.push('action.postcondition must be a non-empty shell command');
247
+ }
248
+ }
241
249
  }
242
250
  // ─── PLACEMENT ───────────────────────────────────────────────────────────────
243
251
  if (config.device !== undefined && config.devices !== undefined) {
@@ -13,6 +13,8 @@
13
13
  */
14
14
  import { type MonitorConfig, type MonitorEvent } from './config.js';
15
15
  import { type Observation } from './sources/index.js';
16
+ /** How often the engine wakes to check which monitors are due. */
17
+ export declare const MONITOR_ENGINE_TICK_MS = 5000;
16
18
  /** Poll-model source types the engine actually evaluates on a cadence; ws/webhook are push-only and inert here. */
17
19
  export declare const POLL_SOURCE_TYPES: Set<string>;
18
20
  /**
@@ -58,7 +60,9 @@ export declare class MonitorEngine {
58
60
  private ticking;
59
61
  constructor(logFn?: LogFn);
60
62
  /** Load owned+enabled monitors and start the tick loop. */
61
- start(): void;
63
+ start(options?: {
64
+ externalScheduler?: boolean;
65
+ }): void;
62
66
  /** Reload monitor configs (SIGHUP). */
63
67
  reload(): void;
64
68
  /** Stop the tick loop. */
@@ -14,11 +14,11 @@
14
14
  import { listMonitors, monitorRunsOnThisDevice, parseInterval, setMonitorEnabled, } from './config.js';
15
15
  import { evaluateSource } from './sources/index.js';
16
16
  import { hasChanged, readState, writeState, recordFireTime, writeFireRecord, recordCheck, markDroughtNotified, } from './state.js';
17
- import { dispatchAction } from './dispatch.js';
17
+ import { dispatchAction, injectEvent } from './dispatch.js';
18
18
  import { sendToOwner } from '../notify.js';
19
19
  import { readRunMeta } from '../scheduling/routines.js';
20
20
  /** How often the engine wakes to check which monitors are due. */
21
- const TICK_MS = 5_000;
21
+ export const MONITOR_ENGINE_TICK_MS = 5_000;
22
22
  /** Default evaluation cadence for sources that carry no explicit interval. */
23
23
  const DEFAULT_INTERVAL_MS = 60_000;
24
24
  /**
@@ -126,10 +126,12 @@ export class MonitorEngine {
126
126
  this.logFn = logFn;
127
127
  }
128
128
  /** Load owned+enabled monitors and start the tick loop. */
129
- start() {
129
+ start(options = {}) {
130
130
  this.loadAll();
131
131
  this.logFn('INFO', `Monitor engine started (${this.monitors.length} monitor(s) on this device)`);
132
- this.timer = setInterval(() => void this.tick(), TICK_MS);
132
+ if (!options.externalScheduler) {
133
+ this.timer = setInterval(() => void this.tick(), MONITOR_ENGINE_TICK_MS);
134
+ }
133
135
  }
134
136
  /** Reload monitor configs (SIGHUP). */
135
137
  reload() {
@@ -282,12 +284,19 @@ export class MonitorEngine {
282
284
  // exactly the fires whose `ok` needs revisiting; `resolveFireOutcome`
283
285
  // (state.ts) never trusts this field — it re-reads the run fresh instead.
284
286
  const runStatusAtFire = result.runId ? readRunMeta(monitor.name, result.runId)?.status : undefined;
287
+ // Snapshot the postcondition with `{event}` already interpolated so a later
288
+ // `resolveFireOutcome` can assert the stated effect without re-reading YAML
289
+ // (PHNX-2842). Notify/webhook-out have no run to settle, so they skip this.
290
+ const postcondition = (result.kind === 'run' || result.kind === 'routine') && monitor.action.postcondition
291
+ ? injectEvent(monitor.action.postcondition, event)
292
+ : undefined;
285
293
  writeFireRecord(event, {
286
294
  ...(result.runId ? { runId: result.runId } : {}),
287
295
  action: result.kind,
288
296
  ok: result.ok,
289
297
  ...(result.error ? { error: result.error } : {}),
290
298
  ...(runStatusAtFire ? { runStatusAtFire } : {}),
299
+ ...(postcondition ? { postcondition } : {}),
291
300
  });
292
301
  writeState(monitor.name, decision.value, decision.dedupeKey, { lastFiredAt: event.firedAt, fireTimes });
293
302
  this.logFn(result.ok ? 'INFO' : 'ERROR', `monitor '${monitor.name}' fired → ${result.kind}` +
@@ -118,6 +118,16 @@ export interface FireRecord extends MonitorEvent {
118
118
  * run fresh on every call instead of trusting this snapshot.
119
119
  */
120
120
  runStatusAtFire?: RunMeta['status'];
121
+ /**
122
+ * The action's postcondition command, snapshotted at fire time with `{event}`
123
+ * already interpolated (PHNX-2842). `resolveFireOutcome` runs this once the
124
+ * dispatched run has settled `completed`.
125
+ */
126
+ postcondition?: string;
127
+ /** Result of the postcondition check, persisted after the first evaluation. */
128
+ postconditionOk?: boolean;
129
+ /** stderr/stdout snippet when `postconditionOk` is false. */
130
+ postconditionError?: string;
121
131
  }
122
132
  /** List a monitor's fire history, chronologically ascending. */
123
133
  export declare function listFires(name: string): FireRecord[];
@@ -127,10 +137,28 @@ export interface ReconciledFireOutcome {
127
137
  ok: boolean;
128
138
  /** The run's live terminal status, when a runId is present and resolvable. */
129
139
  runStatus?: RunMeta['status'];
140
+ /**
141
+ * Present when a `completed` run had a postcondition to assert (PHNX-2842).
142
+ * `met` = the command exited 0; `none` = ran but the intended effect did not
143
+ * happen (the fire must not read as `ok`).
144
+ */
145
+ effect?: 'met' | 'none';
146
+ /** Why the fire is not ok, when the postcondition failed. */
147
+ error?: string;
130
148
  }
149
+ /**
150
+ * Run a fire's postcondition command. Exit 0 means the intended effect happened;
151
+ * anything else (nonzero, timeout, spawn error, empty command) is "no effect".
152
+ * Real `/bin/sh -c` (or `cmd /c`) — the same seam command sources use.
153
+ */
154
+ export declare function evaluatePostcondition(command: string): {
155
+ ok: boolean;
156
+ error?: string;
157
+ };
131
158
  /**
132
159
  * Reconcile a fire's frozen `ok` against its dispatched run's REAL, current
133
- * status — the render-time fix for RUSH-2690.
160
+ * status — the render-time fix for RUSH-2690 — and, when the run has settled
161
+ * `completed`, against a declared postcondition (PHNX-2842).
134
162
  *
135
163
  * `writeFireRecord` (this module) persists `ok` once, at fire time, from
136
164
  * `dispatchAction`'s synchronous return. For a `run`/`routine` action that
@@ -146,5 +174,13 @@ export interface ReconciledFireOutcome {
146
174
  * A fire with no `runId` (a `notify`/`webhook-out` action, or a `run`/`routine`
147
175
  * dispatch that never got a runId at all) has nothing to reconcile against —
148
176
  * its frozen `ok` is the only signal and is returned as-is.
177
+ *
178
+ * `completed` is not success by itself. An agent that exits 0 without doing
179
+ * the job (merge-on-green that never merged) used to record `ok` because
180
+ * `OK_RUN_STATUSES` treated `completed` as healthy. When the fire carries a
181
+ * `postcondition` command, this function runs it once the run has settled and
182
+ * returns `ok: false, effect: 'none'` when it fails — distinguishing "ran but
183
+ * no effect" from a working fire. The result is persisted on the fire record
184
+ * so later listings do not re-exec the command.
149
185
  */
150
186
  export declare function resolveFireOutcome(jobName: string, fire: FireRecord): ReconciledFireOutcome;