@phnx-labs/agents-cli 1.22.71 → 1.22.73

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 (51) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +2 -0
  3. package/dist/cli/command-registry.d.ts +1 -1
  4. package/dist/cli/command-registry.js +2 -1
  5. package/dist/commands/packages-materialize.d.ts +17 -0
  6. package/dist/commands/packages-materialize.js +93 -0
  7. package/dist/commands/packages.d.ts +6 -4
  8. package/dist/commands/packages.js +8 -4
  9. package/dist/commands/repo.js +8 -8
  10. package/dist/commands/sessions-picker.js +38 -7
  11. package/dist/commands/sessions.js +6 -5
  12. package/dist/lib/actor.d.ts +36 -4
  13. package/dist/lib/actor.js +73 -9
  14. package/dist/lib/agent-spec/index.d.ts +4 -0
  15. package/dist/lib/agent-spec/index.js +5 -0
  16. package/dist/lib/agent-spec/materialize.d.ts +13 -0
  17. package/dist/lib/agent-spec/materialize.js +414 -0
  18. package/dist/lib/agent-spec/package-resolve.d.ts +12 -0
  19. package/dist/lib/agent-spec/package-resolve.js +274 -0
  20. package/dist/lib/agent-spec/package-schema.d.ts +5 -0
  21. package/dist/lib/agent-spec/package-schema.js +147 -0
  22. package/dist/lib/agent-spec/package-types.d.ts +121 -0
  23. package/dist/lib/agent-spec/package-types.js +11 -0
  24. package/dist/lib/daemon/usage-sync-service.d.ts +12 -5
  25. package/dist/lib/daemon/usage-sync-service.js +27 -5
  26. package/dist/lib/exec.js +3 -0
  27. package/dist/lib/fleet-shared-state.d.ts +30 -0
  28. package/dist/lib/fleet-shared-state.js +5 -0
  29. package/dist/lib/hooks/install.d.ts +31 -1
  30. package/dist/lib/hooks/install.js +44 -2
  31. package/dist/lib/mcp.d.ts +14 -2
  32. package/dist/lib/mcp.js +12 -2
  33. package/dist/lib/packages/output-home.d.ts +39 -0
  34. package/dist/lib/packages/output-home.js +203 -0
  35. package/dist/lib/paths.d.ts +9 -0
  36. package/dist/lib/paths.js +26 -0
  37. package/dist/lib/project-resources.js +14 -5
  38. package/dist/lib/session/active.d.ts +12 -0
  39. package/dist/lib/session/active.js +5 -1
  40. package/dist/lib/session/actor-sidecar.d.ts +7 -0
  41. package/dist/lib/session/actor-sidecar.js +2 -0
  42. package/dist/lib/session/db.d.ts +60 -1
  43. package/dist/lib/session/db.js +191 -6
  44. package/dist/lib/session/mirror.d.ts +67 -0
  45. package/dist/lib/session/mirror.js +158 -0
  46. package/dist/lib/session/types.d.ts +18 -0
  47. package/dist/lib/spinner.d.ts +39 -0
  48. package/dist/lib/spinner.js +41 -0
  49. package/dist/lib/startup/command-registry.js +1 -1
  50. package/dist/lib/types.d.ts +6 -0
  51. package/package.json +1 -1
@@ -2,6 +2,32 @@ import type { CachedUsageSnapshot } from './accounting/usage.js';
2
2
  export declare const FLEET_SHARED_STATE_VERSION = 1;
3
3
  export declare const FLEET_SHARED_STATE_FILE = "daemon-state.json";
4
4
  export type SharedAuthStatus = 'ready' | 'missing' | 'invalid';
5
+ /**
6
+ * One session's lightweight preview/metadata, mirrored to the fleet so the
7
+ * interactive device renders a remote-host row's topic/preview INLINE instead of
8
+ * fetching the peer's digest live over SSH per row (PHNX-3792). Deliberately
9
+ * NOT a full transcript: only the fields a list row and a compact preview card
10
+ * need. `machine` is the EXECUTION host the publisher recorded (so an offloaded
11
+ * session's mirror row matches the same `machine:id` key the live fan-out uses,
12
+ * never double-counting), `firstUser` is a bounded first-user-message snippet,
13
+ * and `capturedAt` stamps publish time for the staleness marker.
14
+ */
15
+ export interface SessionMirrorRow {
16
+ id: string;
17
+ shortId: string;
18
+ agent: string;
19
+ version?: string;
20
+ machine: string;
21
+ cwd?: string;
22
+ topic?: string;
23
+ label?: string;
24
+ firstUser?: string;
25
+ lastActivity?: string;
26
+ timestamp: string;
27
+ ticketId?: string;
28
+ prUrl?: string;
29
+ capturedAt: number;
30
+ }
5
31
  export interface FleetSharedDeviceState {
6
32
  version: typeof FLEET_SHARED_STATE_VERSION;
7
33
  device: string;
@@ -11,10 +37,14 @@ export interface FleetSharedDeviceState {
11
37
  auth?: {
12
38
  status: SharedAuthStatus;
13
39
  };
40
+ sessions?: {
41
+ rows: SessionMirrorRow[];
42
+ };
14
43
  }
15
44
  export interface FleetSharedStatePatch {
16
45
  usage?: FleetSharedDeviceState['usage'];
17
46
  auth?: FleetSharedDeviceState['auth'];
47
+ sessions?: FleetSharedDeviceState['sessions'];
18
48
  }
19
49
  export interface FleetSharedStateReadResult {
20
50
  states: FleetSharedDeviceState[];
@@ -37,6 +37,10 @@ function parseFleetSharedDeviceState(raw, owner) {
37
37
  (!isRecord(auth) || !['ready', 'missing', 'invalid'].includes(String(auth.status)))) {
38
38
  throw new Error('unrecognized auth verdict');
39
39
  }
40
+ const sessions = parsed.sessions;
41
+ if (sessions !== undefined && (!isRecord(sessions) || !Array.isArray(sessions.rows))) {
42
+ throw new Error('unrecognized session mirror');
43
+ }
40
44
  return parsed;
41
45
  }
42
46
  /**
@@ -58,6 +62,7 @@ function mergeFleetState(currentRaw, device, patch) {
58
62
  ...current,
59
63
  ...(patch.usage !== undefined ? { usage: patch.usage } : {}),
60
64
  ...(patch.auth !== undefined ? { auth: patch.auth } : {}),
65
+ ...(patch.sessions !== undefined ? { sessions: patch.sessions } : {}),
61
66
  version: FLEET_SHARED_STATE_VERSION,
62
67
  device,
63
68
  };
@@ -384,10 +384,40 @@ export declare function listUnmanagedHooksInVersionHome(agent: AgentId, version:
384
384
  * is normalized to >= 1 (Codex: unwrap_or(600).max(1)).
385
385
  */
386
386
  export declare function computeCodexHookTrustHash(eventKeyLabel: string, command: string, timeout: number, matcher: string | undefined): string;
387
- export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string): {
387
+ /**
388
+ * Options that narrow `registerHooksToSettings`'s process-global side effects.
389
+ *
390
+ * `skipGlobalShimSweep` — do NOT run {@link sweepOrphanShims}. The sweep deletes
391
+ * every `.sh` in the ONE process-global shim dir (`getHookShimsDir()`,
392
+ * `~/.agents/.cache/shims/hooks/`) whose name is absent from the manifest it is
393
+ * handed. That is correct for a normal install/sync (the manifest is the
394
+ * operator's complete hook set), but catastrophic when the materializer registers
395
+ * a portable PACKAGE's hooks into an isolated output home: that manifest carries
396
+ * only the package's hooks, so the sweep would wipe the operator's unrelated
397
+ * global shims. The materializer sets this so materialization stays isolated
398
+ * (PHNX-3838); every normal caller leaves it unset and keeps the sweep.
399
+ */
400
+ export interface RegisterHooksOptions {
401
+ skipGlobalShimSweep?: boolean;
402
+ }
403
+ export declare function registerHooksToSettings(agentId: AgentId, versionHome: string, hookManifest?: Record<string, ManifestHook>, agentsDirOverride?: string, options?: RegisterHooksOptions): {
388
404
  registered: string[];
389
405
  errors: string[];
390
406
  };
407
+ /**
408
+ * The concrete config file(s) `registerHooksToSettings` writes for `agentId` in
409
+ * `home` — the settings/registrar leaves, NOT the per-hook script copies. A
410
+ * caller materializing into an UNTRUSTED home (the portable-agent materializer)
411
+ * uses this to verify none of those leaves is a preplanted symlink before the
412
+ * registrar follows it and overwrites a file outside the home.
413
+ *
414
+ * This MUST mirror the `registerHooksToSettings` dispatch above — each arm's
415
+ * write target is reproduced here, so a new registrar branch adds its leaf here
416
+ * too. It intentionally returns only the primary settings/registrar file(s); the
417
+ * per-event/script files a registrar also writes live under the hooks dir the
418
+ * caller already guards. An agent with no hooks registrar returns `[]`.
419
+ */
420
+ export declare function hookRegistrationTargets(agentId: AgentId, home: string): string[];
391
421
  /**
392
422
  * Prune every Claude-family (`settings.json`) hook entry whose command lives
393
423
  * under a removed version's home
@@ -1748,7 +1748,7 @@ function sweepOrphanShims(manifest) {
1748
1748
  catch { /* best effort */ }
1749
1749
  }
1750
1750
  }
1751
- export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride) {
1751
+ export function registerHooksToSettings(agentId, versionHome, hookManifest, agentsDirOverride, options) {
1752
1752
  if (isAgentHardDeprecated(agentId)) {
1753
1753
  return { registered: [], errors: [] };
1754
1754
  }
@@ -1765,7 +1765,8 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1765
1765
  }
1766
1766
  return { registered: [], errors: [] };
1767
1767
  }
1768
- sweepOrphanShims(manifest);
1768
+ if (!options?.skipGlobalShimSweep)
1769
+ sweepOrphanShims(manifest);
1769
1770
  const overrideRoots = agentsDirOverride ? [agentsDirOverride] : null;
1770
1771
  // Scripts are copied into the version home during sync — prefer that stable
1771
1772
  // local path so registered commands don't break when source dirs change.
@@ -1856,6 +1857,47 @@ export function registerHooksToSettings(agentId, versionHome, hookManifest, agen
1856
1857
  }
1857
1858
  return { registered: [], errors: [] };
1858
1859
  }
1860
+ /**
1861
+ * The concrete config file(s) `registerHooksToSettings` writes for `agentId` in
1862
+ * `home` — the settings/registrar leaves, NOT the per-hook script copies. A
1863
+ * caller materializing into an UNTRUSTED home (the portable-agent materializer)
1864
+ * uses this to verify none of those leaves is a preplanted symlink before the
1865
+ * registrar follows it and overwrites a file outside the home.
1866
+ *
1867
+ * This MUST mirror the `registerHooksToSettings` dispatch above — each arm's
1868
+ * write target is reproduced here, so a new registrar branch adds its leaf here
1869
+ * too. It intentionally returns only the primary settings/registrar file(s); the
1870
+ * per-event/script files a registrar also writes live under the hooks dir the
1871
+ * caller already guards. An agent with no hooks registrar returns `[]`.
1872
+ */
1873
+ export function hookRegistrationTargets(agentId, home) {
1874
+ switch (agentId) {
1875
+ case 'claude':
1876
+ case 'droid':
1877
+ case 'muse':
1878
+ return [path.join(home, agentConfigDirName(agentId), 'settings.json')];
1879
+ case 'codex':
1880
+ return [path.join(home, '.codex', 'hooks.json'), path.join(home, '.codex', 'config.toml')];
1881
+ case 'antigravity':
1882
+ return [path.join(home, '.gemini', 'antigravity-cli', 'settings.json')];
1883
+ case 'grok':
1884
+ return [path.join(home, '.grok', 'hooks', 'hooks.json')];
1885
+ case 'kimi':
1886
+ return [path.join(home, '.kimi-code', 'config.toml')];
1887
+ case 'copilot':
1888
+ return [path.join(home, '.copilot', 'hooks', COPILOT_MANAGED_HOOKS_FILE)];
1889
+ case 'goose':
1890
+ return [path.join(home, '.agents', 'plugins', GOOSE_MANAGED_PLUGIN_NAME, 'hooks', 'hooks.json')];
1891
+ case 'cursor':
1892
+ return [path.join(home, '.cursor', 'hooks.json')];
1893
+ case 'hermes':
1894
+ return [path.join(home, '.hermes', 'config.yaml')];
1895
+ case 'opencode':
1896
+ return [path.join(home, '.config', 'opencode', 'plugins', 'agents-cli-hooks.ts')];
1897
+ default:
1898
+ return [];
1899
+ }
1900
+ }
1859
1901
  const OPENCODE_DIRECT_EVENT_MAP = {
1860
1902
  PreToolUse: 'tool.execute.before',
1861
1903
  PostToolUse: 'tool.execute.after',
package/dist/lib/mcp.d.ts CHANGED
@@ -142,8 +142,20 @@ export interface WritableMcpServer {
142
142
  * version-home configs). `mode: 'merge'` updates/adds the provided server
143
143
  * entries while preserving existing entries (used for project-level configs
144
144
  * that users may hand-edit or populate via agent CLI commands).
145
- */
146
- export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge'): void;
145
+ *
146
+ * An empty `servers` list is a no-op by default — most callers pass a
147
+ * resolved-but-possibly-empty selection and an empty one means "nothing to
148
+ * apply," not "clear whatever is there," so this must never wipe an existing
149
+ * config out from under an unrelated caller. `options.allowEmpty` is the
150
+ * explicit opt-in for a caller that owns the ENTIRE mcp section by
151
+ * construction (it always knows the complete current desired server set,
152
+ * including the empty set) and needs `overwrite` to actually clear it —
153
+ * still only the mcp section: `readExistingConfig` above preserves every
154
+ * other top-level key already, in both modes.
155
+ */
156
+ export declare function writeMcpConfig(agentId: AgentId, configPath: string, servers: WritableMcpServer[], mode?: 'overwrite' | 'merge', options?: {
157
+ allowEmpty?: boolean;
158
+ }): void;
147
159
  /**
148
160
  * Install MCP servers to an agent.
149
161
  * For Claude/Codex: uses CLI commands (claude mcp add, codex mcp add)
package/dist/lib/mcp.js CHANGED
@@ -516,9 +516,19 @@ function parseJsonc(raw) {
516
516
  * version-home configs). `mode: 'merge'` updates/adds the provided server
517
517
  * entries while preserving existing entries (used for project-level configs
518
518
  * that users may hand-edit or populate via agent CLI commands).
519
+ *
520
+ * An empty `servers` list is a no-op by default — most callers pass a
521
+ * resolved-but-possibly-empty selection and an empty one means "nothing to
522
+ * apply," not "clear whatever is there," so this must never wipe an existing
523
+ * config out from under an unrelated caller. `options.allowEmpty` is the
524
+ * explicit opt-in for a caller that owns the ENTIRE mcp section by
525
+ * construction (it always knows the complete current desired server set,
526
+ * including the empty set) and needs `overwrite` to actually clear it —
527
+ * still only the mcp section: `readExistingConfig` above preserves every
528
+ * other top-level key already, in both modes.
519
529
  */
520
- export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite') {
521
- if (servers.length === 0) {
530
+ export function writeMcpConfig(agentId, configPath, servers, mode = 'overwrite', options = {}) {
531
+ if (servers.length === 0 && !options.allowEmpty) {
522
532
  return;
523
533
  }
524
534
  // Narrowed, not asserted: this is what lets the `never` guard on the default
@@ -0,0 +1,39 @@
1
+ /** The three harness homes a portable schema-v3 package can be materialized into. */
2
+ export declare const PORTABLE_HARNESSES: readonly ["claude", "codex", "opencode"];
3
+ export type PortableHarness = (typeof PORTABLE_HARNESSES)[number];
4
+ export declare function isPortableHarness(value: string): value is PortableHarness;
5
+ /** Thrown for a bad front-door argument (harness / version / output home). Never `process.exit`. */
6
+ export declare class MaterializeGuardError extends Error {
7
+ constructor(message: string);
8
+ }
9
+ /** Reject a harness that is not one of the three portable homes, with a message naming it. */
10
+ export declare function assertPortableHarness(harness: string): PortableHarness;
11
+ /** Reject a non-exact harness version (empty, malformed, or `@latest`). */
12
+ export declare function assertExactHarnessVersion(version: string): string;
13
+ /** True when `raw` still contains a `..` segment after splitting on both separators. */
14
+ export declare function outputHomeHasDotDot(raw: string): boolean;
15
+ /**
16
+ * Resolve `--output-home` to an absolute path, refusing a target that climbs out
17
+ * of cwd (relative), uses a `..` segment, IS the live home root, or targets (or
18
+ * sits inside) an existing live Claude/Codex/OpenCode home — after canonicalizing
19
+ * symlinks in the existing ancestors so a symlinked target/ancestor can't alias
20
+ * `$HOME`. Every rejection carries a `Path escape:` prefix so callers surface one
21
+ * consistent reason.
22
+ *
23
+ * If any protected home is instead a DANGLING symlink or chain, the function
24
+ * fails closed and refuses EVERY output home (via {@link assertNoDanglingLiveHome}),
25
+ * because the absent target it points at has no canonical spelling to compare a
26
+ * candidate against and the materializer's first `mkdir -p` would follow the link
27
+ * and re-create the operator's live home there (PHNX-3838). The existing-home
28
+ * comparison uses a byte-exact `===` / `startsWith` on two `realpath`-canonical
29
+ * paths, which is exact filesystem identity — no approximation of the volume's
30
+ * case/Unicode collation, which an independent macOS review showed cannot be
31
+ * reproduced in JS from the path text.
32
+ *
33
+ * This is a front-door convenience guard. The load-bearing containment invariant
34
+ * (that no per-resource write/delete escapes the output home — including through
35
+ * a symlink planted at the harness-config-dir join point, which this function
36
+ * cannot see because the materializer forms that child itself) is enforced in
37
+ * `materializeAgentPackage`, so a direct (non-CLI) caller is protected too.
38
+ */
39
+ export declare function resolveOutputHome(raw: string, cwd?: string, home?: string): string;
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Front-door safety guard for `agents packages materialize` (PHNX-3838).
3
+ *
4
+ * The canonical materializer ({@link materializeAgentPackage} in
5
+ * `agent-spec/materialize.ts`) writes into whatever `outputHome` it is handed —
6
+ * that is its job, and it must stay destination-agnostic so Factory / Prix Cloud
7
+ * can point it at any ephemeral home. Refusing a *dangerous* destination is a
8
+ * CLI-front-door concern, not a materializer concern, so it lives here:
9
+ *
10
+ * - the target may not climb out of cwd (relative) or carry a `..` segment;
11
+ * - if any protected live harness home (`~/.claude`, `~/.codex`, `~/.opencode`)
12
+ * is a DANGLING symlink or dangling chain, EVERY output home is refused —
13
+ * fail closed (see {@link assertNoDanglingLiveHome});
14
+ * - the target may not be the operator's live home ROOT, nor — or sit inside —
15
+ * an EXISTING live `~/.claude`, `~/.codex`, or `~/.opencode` home;
16
+ * - the harness must be one of the three portable homes;
17
+ * - the harness version must be an exact version, never `@latest`.
18
+ *
19
+ * The live-home ROOT refusal is load-bearing: the materializer appends the
20
+ * harness config dir (`.claude`/`.codex`/`.opencode`) to `outputHome`, so an
21
+ * `outputHome` of `$HOME` itself would land straight in the operator's live
22
+ * `~/.claude`. And because a symlink (`outputHome` itself, or any ancestor)
23
+ * pointing at `$HOME` re-opens the same hole — `path.resolve` normalizes `..`
24
+ * but never follows links — the check canonicalizes the existing ancestors with
25
+ * `realpath` before comparing.
26
+ *
27
+ * Kept beside the command (not inside it) so the live-home refusal is
28
+ * unit-testable without spawning the whole CLI.
29
+ */
30
+ import * as fs from 'fs';
31
+ import * as os from 'os';
32
+ import * as path from 'path';
33
+ import { assertWithin, realpathExistingPrefix } from '../paths.js';
34
+ import { VERSION_RE } from '../agent-spec/primitives.js';
35
+ /** The three harness homes a portable schema-v3 package can be materialized into. */
36
+ export const PORTABLE_HARNESSES = ['claude', 'codex', 'opencode'];
37
+ export function isPortableHarness(value) {
38
+ return PORTABLE_HARNESSES.includes(value);
39
+ }
40
+ /** Thrown for a bad front-door argument (harness / version / output home). Never `process.exit`. */
41
+ export class MaterializeGuardError extends Error {
42
+ constructor(message) {
43
+ super(message);
44
+ this.name = 'MaterializeGuardError';
45
+ }
46
+ }
47
+ /** Reject a harness that is not one of the three portable homes, with a message naming it. */
48
+ export function assertPortableHarness(harness) {
49
+ if (!isPortableHarness(harness)) {
50
+ throw new MaterializeGuardError(`Unsupported capability: '${harness}' is not a portable-agent harness (${PORTABLE_HARNESSES.join(', ')}).`);
51
+ }
52
+ return harness;
53
+ }
54
+ /** Reject a non-exact harness version (empty, malformed, or `@latest`). */
55
+ export function assertExactHarnessVersion(version) {
56
+ if (!version || version === 'latest' || !VERSION_RE.test(version)) {
57
+ throw new MaterializeGuardError(`Invalid harness version '${version}'. Pass an exact harness version (not @latest).`);
58
+ }
59
+ return version;
60
+ }
61
+ /** True when `raw` still contains a `..` segment after splitting on both separators. */
62
+ export function outputHomeHasDotDot(raw) {
63
+ return raw.split(/[\\/]/).includes('..');
64
+ }
65
+ /**
66
+ * The absolute path a DANGLING symlink chain ultimately points at, or `null`
67
+ * when `p` is not a symlink or its chain resolves to an existing file.
68
+ *
69
+ * `realpathExistingPrefix` deliberately does NOT follow a dangling LEAF link — it
70
+ * resolves the link's parent and re-appends the basename — so `~/.claude` → an
71
+ * absent target canonicalizes to the literal `~/.claude`, and the target itself
72
+ * sails past every containment compare. Yet the materializer's very first act is
73
+ * `fs.mkdirSync(outputHome, { recursive: true })`, which FOLLOWS that link and
74
+ * creates the target, re-pointing the operator's live `~/.claude` at the
75
+ * materialized tree. So the guard treats a dangling protected home as a fail-
76
+ * closed condition (see {@link assertNoDanglingLiveHome}). A live symlink (its
77
+ * target exists) resolves cleanly through `realpathExistingPrefix`, hence the
78
+ * `!fs.existsSync` gate returns `null` for it. Relative links resolve against
79
+ * each hop's own directory; a bounded hop budget defuses a symlink cycle
80
+ * (returning the best-effort endpoint, which still reads as dangling → refused).
81
+ */
82
+ function danglingLinkChainTarget(p) {
83
+ let current = path.resolve(p);
84
+ let followed = false;
85
+ for (let hops = 0; hops < 40; hops++) {
86
+ let dest;
87
+ try {
88
+ dest = fs.readlinkSync(current);
89
+ }
90
+ catch {
91
+ return followed && !fs.existsSync(current) ? current : null;
92
+ }
93
+ followed = true;
94
+ current = path.resolve(path.dirname(current), dest);
95
+ }
96
+ return current;
97
+ }
98
+ /**
99
+ * Fail CLOSED when any protected live harness home (`~/.claude`, `~/.codex`,
100
+ * `~/.opencode`) is a DANGLING symlink or dangling symlink chain — refusing
101
+ * EVERY output home, not merely one that aliases the dangling target.
102
+ *
103
+ * Why refuse the whole operation instead of the aliasing path alone: the
104
+ * materializer's first act is `fs.mkdirSync(outputHome, { recursive: true })`,
105
+ * which FOLLOWS the link and creates its absent target, re-pointing the
106
+ * operator's live `~/.<harness>` at the materialized tree. To refuse only the
107
+ * aliasing output home we would have to decide whether the requested path and
108
+ * the dangling target name the SAME file — but the target does not exist, so it
109
+ * cannot be `realpath`'d, and its identity then depends on the destination
110
+ * volume's own case/Unicode collation, which is per-volume and NOT knowable from
111
+ * the path text. An independent macOS review confirmed this directly: NFC +
112
+ * `toLowerCase` is not APFS case folding (APFS treats e.g. U+017F ſ and ASCII `s`
113
+ * as identical, which `toLowerCase` does not), and an OS-derived case-sensitivity
114
+ * flag is wrong for per-volume semantics. Rather than approximate an unknowable
115
+ * collation, the guard refuses outright until the operator repairs the dangling
116
+ * home. A dangling protected home is an abnormal state, so this over-rejects
117
+ * nothing a healthy environment relies on; the EXISTING-home comparison stays
118
+ * exact because `realpath` gives it a trustworthy canonical spelling.
119
+ */
120
+ function assertNoDanglingLiveHome(realHome) {
121
+ for (const name of PORTABLE_HARNESSES) {
122
+ if (danglingLinkChainTarget(path.join(realHome, `.${name}`)) !== null) {
123
+ throw new MaterializeGuardError(`Path escape: the live .${name} home is a dangling symlink; refusing every output home until it is repaired ` +
124
+ `(the materializer's mkdir -p would follow the link and re-create your live .${name} at its absent target)`);
125
+ }
126
+ }
127
+ }
128
+ /**
129
+ * The canonical EXISTING live harness home to forbid, per harness: the
130
+ * `realpathExistingPrefix` of each `~/.<harness>`. For a live symlink this is the
131
+ * real on-disk target (so `--output-home ~/.claude` and its resolved dir both
132
+ * match); for a plain dir, or a home that does not exist at all, it is the
133
+ * literal `~/.<harness>` path. Because both this and the candidate output home
134
+ * are canonicalized with `realpath`, a byte-exact compare is exact filesystem
135
+ * identity — `realpath` returns the volume's own on-disk spelling, so two paths
136
+ * naming the same file collapse to the same string with no need to approximate
137
+ * the volume's case/Unicode collation. A DANGLING `~/.<harness>` never reaches
138
+ * here: {@link assertNoDanglingLiveHome} fails closed first, precisely because
139
+ * its absent target has no realpath-canonical spelling to compare against.
140
+ */
141
+ function liveHarnessHomes(realHome) {
142
+ return PORTABLE_HARNESSES.map((name) => ({
143
+ harness: name,
144
+ live: realpathExistingPrefix(path.join(realHome, `.${name}`)),
145
+ }));
146
+ }
147
+ /**
148
+ * Resolve `--output-home` to an absolute path, refusing a target that climbs out
149
+ * of cwd (relative), uses a `..` segment, IS the live home root, or targets (or
150
+ * sits inside) an existing live Claude/Codex/OpenCode home — after canonicalizing
151
+ * symlinks in the existing ancestors so a symlinked target/ancestor can't alias
152
+ * `$HOME`. Every rejection carries a `Path escape:` prefix so callers surface one
153
+ * consistent reason.
154
+ *
155
+ * If any protected home is instead a DANGLING symlink or chain, the function
156
+ * fails closed and refuses EVERY output home (via {@link assertNoDanglingLiveHome}),
157
+ * because the absent target it points at has no canonical spelling to compare a
158
+ * candidate against and the materializer's first `mkdir -p` would follow the link
159
+ * and re-create the operator's live home there (PHNX-3838). The existing-home
160
+ * comparison uses a byte-exact `===` / `startsWith` on two `realpath`-canonical
161
+ * paths, which is exact filesystem identity — no approximation of the volume's
162
+ * case/Unicode collation, which an independent macOS review showed cannot be
163
+ * reproduced in JS from the path text.
164
+ *
165
+ * This is a front-door convenience guard. The load-bearing containment invariant
166
+ * (that no per-resource write/delete escapes the output home — including through
167
+ * a symlink planted at the harness-config-dir join point, which this function
168
+ * cannot see because the materializer forms that child itself) is enforced in
169
+ * `materializeAgentPackage`, so a direct (non-CLI) caller is protected too.
170
+ */
171
+ export function resolveOutputHome(raw, cwd = process.cwd(), home = os.homedir()) {
172
+ if (!raw || raw.includes('\0')) {
173
+ throw new MaterializeGuardError('Path escape: output home is empty or contains a null byte');
174
+ }
175
+ if (outputHomeHasDotDot(raw)) {
176
+ throw new MaterializeGuardError(`Path escape: ${raw}`);
177
+ }
178
+ const resolved = path.resolve(cwd, raw);
179
+ if (!path.isAbsolute(raw)) {
180
+ try {
181
+ assertWithin(cwd, resolved);
182
+ }
183
+ catch {
184
+ throw new MaterializeGuardError(`Path escape: ${raw}`);
185
+ }
186
+ }
187
+ const canonical = realpathExistingPrefix(resolved);
188
+ const realHome = realpathExistingPrefix(home);
189
+ // Fail closed first: a dangling protected home makes EVERY output home unsafe,
190
+ // and its absent target has no realpath-canonical spelling to compare against.
191
+ assertNoDanglingLiveHome(realHome);
192
+ // The materializer appends the harness config dir to outputHome, so the live
193
+ // home ROOT would write straight into ~/.claude etc.
194
+ if (canonical === realHome) {
195
+ throw new MaterializeGuardError('Path escape: output home must not be the live home directory');
196
+ }
197
+ for (const { harness, live } of liveHarnessHomes(realHome)) {
198
+ if (canonical === live || canonical.startsWith(live + path.sep)) {
199
+ throw new MaterializeGuardError(`Path escape: output home must not target the live .${harness} directory`);
200
+ }
201
+ }
202
+ return resolved;
203
+ }
@@ -1,3 +1,12 @@
1
+ /**
2
+ * Canonicalize `target` by `realpath`-resolving its longest EXISTING ancestor and
3
+ * re-appending the not-yet-created tail. A plain `realpathSync(target)` throws when
4
+ * `target` (a fresh output dir, a not-yet-written file) does not exist — but any
5
+ * symlink in the part that DOES exist (the target itself, or an ancestor) is
6
+ * exactly the escape hatch a containment check must resolve before comparing.
7
+ * Unlike `path.resolve` (which only normalizes `..`), this follows links.
8
+ */
9
+ export declare function realpathExistingPrefix(target: string): string;
1
10
  /**
2
11
  * True when `name` is a safe single path segment: non-empty, not '.'/'..',
3
12
  * free of path separators and null bytes, and within the filename length limit.
package/dist/lib/paths.js CHANGED
@@ -1,4 +1,30 @@
1
+ import * as fs from 'fs';
1
2
  import * as path from 'path';
3
+ /**
4
+ * Canonicalize `target` by `realpath`-resolving its longest EXISTING ancestor and
5
+ * re-appending the not-yet-created tail. A plain `realpathSync(target)` throws when
6
+ * `target` (a fresh output dir, a not-yet-written file) does not exist — but any
7
+ * symlink in the part that DOES exist (the target itself, or an ancestor) is
8
+ * exactly the escape hatch a containment check must resolve before comparing.
9
+ * Unlike `path.resolve` (which only normalizes `..`), this follows links.
10
+ */
11
+ export function realpathExistingPrefix(target) {
12
+ let current = path.resolve(target);
13
+ const tail = [];
14
+ for (;;) {
15
+ try {
16
+ const real = fs.realpathSync(current);
17
+ return tail.length ? path.join(real, ...tail.reverse()) : real;
18
+ }
19
+ catch {
20
+ const parent = path.dirname(current);
21
+ if (parent === current)
22
+ return path.resolve(target); // nothing on this path exists
23
+ tail.push(path.basename(current));
24
+ current = parent;
25
+ }
26
+ }
27
+ }
2
28
  /**
3
29
  * True when `name` is a safe single path segment: non-empty, not '.'/'..',
4
30
  * free of path separators and null bytes, and within the filename length limit.
@@ -141,7 +141,9 @@ function applyManagedBlock(content, begin, end, entries) {
141
141
  if (entries.length > 0) {
142
142
  return [...lines.slice(0, bi), begin, ...entries, end, ...lines.slice(ei + 1)].join('\n');
143
143
  }
144
- // Prune the block, tidying the blank lines that hugged it.
144
+ // Prune the block, tidying the blank lines that hugged it. Vestigial on the
145
+ // reconcileProjectGitignore path (its entries always include the manifest,
146
+ // so entries.length is never 0 there) — kept for a direct caller/unit test.
145
147
  const before = lines.slice(0, bi);
146
148
  const after = lines.slice(ei + 1);
147
149
  while (before.length && before[before.length - 1].trim() === '')
@@ -162,16 +164,23 @@ function applyManagedBlock(content, begin, end, entries) {
162
164
  * generated per-harness resource dir never shows as untracked dirt. Idempotent
163
165
  * and convergent: replaces the block in place and writes only when the content
164
166
  * actually changes, so the launch hot path does not churn the file (or its
165
- * watchers) every run — even in a project synced by several harnesses. Called
166
- * with an empty `managed` set when a sync clears a harness, which prunes the
167
- * block. Never creates a `.gitignore` outside a git working tree.
167
+ * watchers) every run — even in a project synced by several harnesses. When a
168
+ * sync clears a harness's resources the block does not vanish: it shrinks to the
169
+ * lone `.agents-managed.json` entry (that file still sits in the harness dir and
170
+ * must stay ignored), so the block is only ever fully pruned by hand, never via
171
+ * this call path. Never creates a `.gitignore` outside a git working tree.
168
172
  */
169
173
  function reconcileProjectGitignore(projectRoot, agent, agentRoot, managed) {
170
174
  const gitignorePath = path.join(projectRoot, '.gitignore');
171
175
  if (!isInsideGitRepo(projectRoot) && !pathExists(gitignorePath))
172
176
  return;
173
177
  const { begin, end } = gitignoreMarkers(agent);
174
- const entries = managedGitignoreEntries(agentRoot, projectRoot, managed);
178
+ // Ignore the manifest marker file too, not just the synced resources: the
179
+ // sync always writes `<agentRoot>/.agents-managed.json`, so without this the
180
+ // harness dir still shows as untracked in `git status` on the strength of that
181
+ // one file (defeating the whole point). It lives at agentRoot, so it resolves
182
+ // through the same anchoring + escape guard as any managed path.
183
+ const entries = managedGitignoreEntries(agentRoot, projectRoot, [MANIFEST_FILE, ...managed]);
175
184
  let original = '';
176
185
  try {
177
186
  original = fs.readFileSync(gitignorePath, 'utf-8');
@@ -402,6 +402,18 @@ export interface ActiveSession {
402
402
  * did not inherit a terminal id.
403
403
  */
404
404
  terminalId?: string;
405
+ /**
406
+ * The launch id (`AGENT_LAUNCH_ID`) the CLI stamps on every agent at spawn — a
407
+ * stable UUID that is identical locally and across an SSH hop and survives a
408
+ * session-id rotation (`/clear`, exit-and-rerun). Unlike `sessionId` (which the
409
+ * non-Claude harnesses only mint after boot) it exists from the first tick, so
410
+ * it is the join key a client uses to re-identify a session on the watch stream.
411
+ * Populated wherever the by-pid launch registry resolves — reliably for
412
+ * `agents run`-launched processes; still absent for editor-launched terminals
413
+ * whose shell pid does not line up with the agent-pid registry today (the same
414
+ * limitation the `readPidSessionEntry` call in `listTerminalsActive` documents).
415
+ */
416
+ launchId?: string;
405
417
  /**
406
418
  * tmux pane id (`%N`) when this row was discovered via the tmux source AND its
407
419
  * session id could not be resolved (a born-unidentifiable non-Claude pane). It
@@ -1059,6 +1059,8 @@ export async function listTerminalsActive() {
1059
1059
  tty: procByPid.get(t.pid)?.tty,
1060
1060
  pid: t.pid,
1061
1061
  sessionId: t.sessionId ?? sessionIdFromFile(sessionFile),
1062
+ launchId: pidEntry?.launchId,
1063
+ terminalId: pidEntry?.terminalId,
1062
1064
  cwd: t.cwd ?? undefined,
1063
1065
  label,
1064
1066
  name,
@@ -1558,7 +1560,8 @@ async function listUnattributedActiveLive(attributed) {
1558
1560
  lastActivityMs: mtimeMs,
1559
1561
  pidCount: 1 + (foldedByRoot.get(pid) ?? 0),
1560
1562
  owner: resolveOwner(entry?.actor, resolvedId),
1561
- terminalId: entry?.terminalId,
1563
+ launchId: entry?.launchId ?? hookRec?.launch_id,
1564
+ terminalId: entry?.terminalId ?? hookRec?.terminal_id,
1562
1565
  }, state, sessionFile, true));
1563
1566
  }
1564
1567
  // Housekeeping: drop registry files for pids that have since died.
@@ -1773,6 +1776,7 @@ export async function listTmuxAgentSessions() {
1773
1776
  // Factory / --active join key: AGENT_TERMINAL_ID stamped on the launch
1774
1777
  // registry and preserved by SessionStart. Without this, Grok/Codex tmux
1775
1778
  // panes never surface terminalId even when by-pid has it (RUSH-2192).
1779
+ launchId: liveEntry?.launchId,
1776
1780
  terminalId: liveEntry?.terminalId,
1777
1781
  // An id-less pane keys its dedupe on the unique pane, so two anonymous
1778
1782
  // co-located panes stay two rows instead of folding into one.
@@ -5,6 +5,13 @@ export interface SessionActorRecord {
5
5
  actor?: string;
6
6
  /** Actor kind (`resolveActor().kind`). */
7
7
  initiatedBy?: 'human' | 'agent';
8
+ /**
9
+ * Phoenix id of the responsible actor (`resolveActor().phoenixId`) — the
10
+ * tailnet human's stable Phoenix identity, resolved from the `actors:` map at
11
+ * spawn (PHNX-3798). Pairs with {@link actor}: joined onto the session index at
12
+ * scan time so a durable listing can surface the Phoenix id, not just the email.
13
+ */
14
+ phoenixId?: string;
8
15
  /** Effective permissions mode used by the launcher. */
9
16
  mode?: SessionRunMode;
10
17
  /**
@@ -39,6 +39,7 @@ function isSafeAlias(alias) {
39
39
  }
40
40
  function hasRecordData(record) {
41
41
  return typeof record.actor === 'string'
42
+ || typeof record.phoenixId === 'string'
42
43
  || typeof record.mode === 'string'
43
44
  || typeof record.version === 'string'
44
45
  || typeof record.harness === 'string'
@@ -84,6 +85,7 @@ export function writeSessionAliasRecord(sessionId, alias) {
84
85
  sessionId,
85
86
  actor: previous?.actor,
86
87
  initiatedBy: previous?.initiatedBy,
88
+ phoenixId: previous?.phoenixId,
87
89
  mode: previous?.mode,
88
90
  version: previous?.version,
89
91
  harness: previous?.harness,