@phnx-labs/agents-cli 1.22.43 → 1.22.44

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 (164) hide show
  1. package/CHANGELOG.md +77 -0
  2. package/README.md +14 -12
  3. package/dist/bootstrap.js +8 -6
  4. package/dist/cli/command-registry.d.ts +0 -5
  5. package/dist/cli/command-registry.js +1 -17
  6. package/dist/commands/apply.d.ts +7 -10
  7. package/dist/commands/apply.js +15 -35
  8. package/dist/commands/artifacts.js +4 -5
  9. package/dist/commands/audit.d.ts +6 -6
  10. package/dist/commands/audit.js +12 -12
  11. package/dist/commands/auth.js +7 -3
  12. package/dist/commands/beta.d.ts +7 -1
  13. package/dist/commands/beta.js +16 -9
  14. package/dist/commands/commands.js +1 -1
  15. package/dist/commands/doctor.js +1 -4
  16. package/dist/commands/events.d.ts +2 -2
  17. package/dist/commands/events.js +6 -5
  18. package/dist/commands/exec.d.ts +1 -1
  19. package/dist/commands/exec.js +8 -4
  20. package/dist/commands/factory.d.ts +1 -1
  21. package/dist/commands/fleet-capture.d.ts +1 -1
  22. package/dist/commands/fleet-capture.js +4 -5
  23. package/dist/commands/harness.js +1 -49
  24. package/dist/commands/hooks.js +1 -1
  25. package/dist/commands/insights.d.ts +1 -1
  26. package/dist/commands/insights.js +3 -2
  27. package/dist/commands/menubar.js +58 -1
  28. package/dist/commands/org.d.ts +5 -0
  29. package/dist/commands/org.js +82 -29
  30. package/dist/commands/output.js +2 -2
  31. package/dist/commands/prune.js +5 -4
  32. package/dist/commands/routines.test-fixture.d.ts +86 -0
  33. package/dist/commands/routines.test-fixture.js +345 -0
  34. package/dist/commands/sessions.test-fixture.d.ts +24 -0
  35. package/dist/commands/sessions.test-fixture.js +224 -0
  36. package/dist/commands/setup.js +5 -1
  37. package/dist/commands/share.d.ts +9 -10
  38. package/dist/commands/share.js +68 -50
  39. package/dist/commands/skills.js +1 -1
  40. package/dist/commands/ssh.js +15 -62
  41. package/dist/commands/subagents.js +1 -1
  42. package/dist/commands/view.js +5 -2
  43. package/dist/lib/analytics/mix-commands.d.ts +6 -28
  44. package/dist/lib/analytics/mix-commands.js +14 -40
  45. package/dist/lib/audit/log.d.ts +2 -2
  46. package/dist/lib/audit/log.js +2 -2
  47. package/dist/lib/beta.js +1 -1
  48. package/dist/lib/browser/domain-skills.d.ts +26 -6
  49. package/dist/lib/browser/domain-skills.js +81 -43
  50. package/dist/lib/daemon/daemon.js +11 -0
  51. package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
  52. package/dist/lib/daemon/daemon.test-fixture.js +71 -0
  53. package/dist/lib/daemon-ticks.d.ts +38 -1
  54. package/dist/lib/daemon-ticks.js +52 -5
  55. package/dist/lib/device-config.js +1 -2
  56. package/dist/lib/devices/discovery-policy.d.ts +3 -3
  57. package/dist/lib/devices/discovery-policy.js +3 -3
  58. package/dist/lib/devices/fleet.d.ts +4 -8
  59. package/dist/lib/devices/fleet.js +3 -15
  60. package/dist/lib/devices/health-report.js +3 -3
  61. package/dist/lib/devices/pool.d.ts +0 -6
  62. package/dist/lib/devices/pool.js +0 -6
  63. package/dist/lib/devices/registry.d.ts +0 -15
  64. package/dist/lib/devices/registry.js +0 -9
  65. package/dist/lib/devices/stats-cache.d.ts +12 -0
  66. package/dist/lib/devices/stats-cache.js +24 -5
  67. package/dist/lib/fleet/capture.d.ts +1 -1
  68. package/dist/lib/fleet/manifest.js +1 -1
  69. package/dist/lib/fleet/remote-login.d.ts +2 -2
  70. package/dist/lib/fleet/remote-login.js +3 -4
  71. package/dist/lib/hosts/dispatch.d.ts +2 -0
  72. package/dist/lib/hosts/dispatch.js +8 -1
  73. package/dist/lib/hosts/passthrough.js +0 -1
  74. package/dist/lib/hosts/providers/devices.js +1 -13
  75. package/dist/lib/hosts/ready.d.ts +12 -3
  76. package/dist/lib/hosts/ready.js +27 -12
  77. package/dist/lib/hosts/registry.d.ts +4 -5
  78. package/dist/lib/hosts/registry.js +3 -7
  79. package/dist/lib/installations/versions.js +21 -2
  80. package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
  81. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  82. package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
  83. package/dist/lib/menubar/install-menubar.d.ts +109 -0
  84. package/dist/lib/menubar/install-menubar.js +199 -4
  85. package/dist/lib/prix-account.d.ts +2 -1
  86. package/dist/lib/prix-account.js +2 -1
  87. package/dist/lib/remote-agents-json.js +1 -7
  88. package/dist/lib/routines-placement.d.ts +1 -1
  89. package/dist/lib/routines-placement.js +1 -1
  90. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  91. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
  92. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  93. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
  94. package/dist/lib/session/active.d.ts +4 -16
  95. package/dist/lib/session/active.js +2 -6
  96. package/dist/lib/session/db.d.ts +13 -98
  97. package/dist/lib/session/db.js +4 -11
  98. package/dist/lib/session/discover.d.ts +5 -81
  99. package/dist/lib/session/discover.js +5 -5
  100. package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
  101. package/dist/lib/session/remote/remote-bundle.js +61 -0
  102. package/dist/lib/session/remote/remote-list.d.ts +148 -0
  103. package/dist/lib/session/remote/remote-list.js +607 -0
  104. package/dist/lib/session/remote/remote.d.ts +132 -0
  105. package/dist/lib/session/remote/remote.js +314 -0
  106. package/dist/lib/session/remote/watch.d.ts +101 -0
  107. package/dist/lib/session/remote/watch.js +241 -0
  108. package/dist/lib/session/remote-bundle.d.ts +1 -12
  109. package/dist/lib/session/remote-bundle.js +3 -61
  110. package/dist/lib/session/remote-list.d.ts +1 -148
  111. package/dist/lib/session/remote-list.js +3 -607
  112. package/dist/lib/session/remote.d.ts +1 -132
  113. package/dist/lib/session/remote.js +3 -314
  114. package/dist/lib/session/watch.d.ts +1 -101
  115. package/dist/lib/session/watch.js +3 -242
  116. package/dist/lib/share/config.d.ts +12 -2
  117. package/dist/lib/share/config.js +47 -11
  118. package/dist/lib/share/delete.js +1 -1
  119. package/dist/lib/share/publish.js +1 -1
  120. package/dist/lib/share/worker-template.js +2 -2
  121. package/dist/lib/smart-launch.d.ts +4 -4
  122. package/dist/lib/smart-launch.js +8 -18
  123. package/dist/lib/staleness/index.d.ts +3 -1
  124. package/dist/lib/staleness/index.js +21 -1
  125. package/dist/lib/staleness/types.d.ts +11 -0
  126. package/dist/lib/staleness/writers/commands.js +13 -4
  127. package/dist/lib/staleness/writers/hooks.js +3 -1
  128. package/dist/lib/staleness/writers/mcp.d.ts +0 -7
  129. package/dist/lib/staleness/writers/mcp.js +16 -1
  130. package/dist/lib/staleness/writers/rules.js +1 -1
  131. package/dist/lib/staleness/writers/skills.js +3 -1
  132. package/dist/lib/staleness/writers/subagents.d.ts +0 -9
  133. package/dist/lib/staleness/writers/subagents.js +18 -1
  134. package/dist/lib/staleness/writers/types.d.ts +10 -0
  135. package/dist/lib/startup/command-registry.d.ts +7 -1
  136. package/dist/lib/startup/command-registry.js +17 -5
  137. package/dist/lib/startup/root-command.d.ts +18 -1
  138. package/dist/lib/startup/root-command.js +18 -1
  139. package/dist/lib/state.js +7 -0
  140. package/dist/lib/teams/placement-probe.js +27 -12
  141. package/dist/lib/teams/scheduler.d.ts +3 -1
  142. package/dist/lib/types.d.ts +1 -1
  143. package/dist/lib/view-types.d.ts +3 -0
  144. package/package.json +2 -3
  145. package/dist/bin/agents +0 -0
  146. package/dist/commands/serve.d.ts +0 -10
  147. package/dist/commands/serve.js +0 -68
  148. package/dist/commands/trends.d.ts +0 -10
  149. package/dist/commands/trends.js +0 -12
  150. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  151. package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
  152. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  153. package/dist/lib/serve/control.d.ts +0 -95
  154. package/dist/lib/serve/control.js +0 -260
  155. package/dist/lib/serve/data.d.ts +0 -81
  156. package/dist/lib/serve/data.js +0 -91
  157. package/dist/lib/serve/page.d.ts +0 -7
  158. package/dist/lib/serve/page.js +0 -140
  159. package/dist/lib/serve/server.d.ts +0 -80
  160. package/dist/lib/serve/server.js +0 -145
  161. package/dist/lib/serve/stream.d.ts +0 -43
  162. package/dist/lib/serve/stream.js +0 -116
  163. package/dist/lib/serve/token.d.ts +0 -35
  164. package/dist/lib/serve/token.js +0 -85
@@ -0,0 +1,132 @@
1
+ /**
2
+ * POSIX single-quote a string for safe interpolation into a remote shell command.
3
+ * Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
4
+ * forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
5
+ * every token is quoted to keep the command boundary unambiguous.
6
+ */
7
+ export declare function shellQuote(s: string): string;
8
+ /**
9
+ * Strip the `--device`/`-D` flag (and its value) from a raw `agents sessions` argv,
10
+ * leaving the args to forward to the remote unchanged. The remote runs the same
11
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
12
+ * over for free. Handles every form commander accepts: `--device h`, `--device=h`,
13
+ * `-D h`, `-D=h`, and the glued short form `-Dh`.
14
+ *
15
+ * @param argv full process argv; the sessions args begin at index 2
16
+ * (`[runtime, script, 'sessions', ...]`).
17
+ */
18
+ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>): string[];
19
+ /**
20
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
21
+ *
22
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
23
+ * default listing is silently cwd-scoped, so `sessions --device box` reads as
24
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
25
+ * Across SSH a peer's cwd is meaningless, so `--device` defaults to `--all`
26
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
27
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
28
+ * a query that looks like a path takes precedence over `--all` on the remote.
29
+ * Idempotent: never adds a second `--all`.
30
+ */
31
+ export declare function ensureWholeIndex(forwardedArgs: string[]): string[];
32
+ /**
33
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
34
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
35
+ * quoted again so it survives `bash -lc <...>`.
36
+ *
37
+ * `os` selects the remote shell: a Windows host gets a PowerShell invocation
38
+ * (ssh lands in cmd.exe/PowerShell there, where `bash -lc` does not exist);
39
+ * anything else — including unknown/absent — keeps the POSIX form unchanged.
40
+ * The forwarded terminal width rides across as an env var either way so the
41
+ * remote renders its table to the local screen.
42
+ */
43
+ export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number, os?: string): string;
44
+ /** The four outcomes of one `ssh <host> agents sessions …` invocation. */
45
+ export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
46
+ /**
47
+ * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
48
+ * connection-layer failures (host down, timeout, refused, auth, changed host
49
+ * key) — distinct from any other non-zero, which is the remote `agents sessions`
50
+ * exit code forwarded back (the query ran but failed). The two must be handled
51
+ * differently: 255 may fall back to cache, a forwarded failure must surface.
52
+ */
53
+ export declare function classifySshFailure(res: {
54
+ error?: Error | null;
55
+ status: number | null;
56
+ }): SshOutcome;
57
+ /**
58
+ * How long a successful remote fetch may be served without re-SSHing.
59
+ * Short on purpose: session listings must stay near-live (RUSH-2062). Match the
60
+ * active-session snapshot window so surfaces share one freshness model.
61
+ */
62
+ export declare const REMOTE_CACHE_MAX_AGE_MS = 15000;
63
+ /**
64
+ * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
65
+ * are hashed so distinct queries cache independently; the host stays readable in
66
+ * the filename (sanitised so `user@host` and aliases are filesystem-safe).
67
+ */
68
+ export declare function remoteCachePath(host: string, forwardedArgs: string[]): string;
69
+ /**
70
+ * Pure freshness check for a remote-sessions cache entry. A reachable host
71
+ * skips SSH only while this returns true; unreachable fallback ignores age.
72
+ */
73
+ export declare function isRemoteCacheFresh(mtimeMs: number, nowMs: number, maxAgeMs?: number): boolean;
74
+ export interface RemoteCacheHit {
75
+ output: string;
76
+ mtimeMs: number;
77
+ }
78
+ /**
79
+ * Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
80
+ * entry is older than the window (cache-first path for reachable hosts).
81
+ * Omit `maxAgeMs` to accept any age (unreachable fallback).
82
+ */
83
+ export declare function readRemoteCache(host: string, forwardedArgs: string[], opts?: {
84
+ maxAgeMs?: number;
85
+ nowMs?: number;
86
+ }): RemoteCacheHit | null;
87
+ /** Banner shown above replayed cache rows when the peer is offline. */
88
+ export declare function formatStaleBanner(host: string, mtimeMs: number): string;
89
+ /** Message shown when a host is unreachable and there is no cache to fall back to. */
90
+ export declare function formatUnreachable(host: string): string;
91
+ /** Persist a successful fetch for later cache-first / offline replay.
92
+ * Best-effort: a cache write must never break the live query. Exported for tests. */
93
+ export declare function writeRemoteCache(host: string, forwardedArgs: string[], output: string): void;
94
+ /**
95
+ * Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
96
+ * is still within the freshness window). Returns false when missing/stale so
97
+ * the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
98
+ * even when the cache was just written.
99
+ */
100
+ export declare function serveWarmRemoteCache(host: string, forwardedArgs: string[], opts?: {
101
+ maxAgeMs?: number;
102
+ nowMs?: number;
103
+ }): boolean;
104
+ /** Replay a cached fetch for an unreachable host (any age). Banner goes to
105
+ * stderr (so a piped stdout stays exactly the cached rows); returns false when
106
+ * nothing is cached for this exact (host, query). */
107
+ export declare function replayRemoteCache(host: string, forwardedArgs: string[]): boolean;
108
+ export interface RunRemoteSessionsOptions {
109
+ /** Skip warm cache and SSH every host (force-refresh). */
110
+ forceRefresh?: boolean;
111
+ /** Override freshness window for the warm path. */
112
+ maxAgeMs?: number;
113
+ /** Clock (tests). */
114
+ nowMs?: number;
115
+ }
116
+ /**
117
+ * Run the current `agents sessions` invocation on one or more remote machines over
118
+ * SSH, writing each remote's output to the terminal.
119
+ *
120
+ * Cache policy (RUSH-2062):
121
+ * - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
122
+ * - **`forceRefresh`:** always SSH, then rewrite the cache.
123
+ * - **Unreachable:** fall back to any cached output (with a stale banner).
124
+ *
125
+ * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
126
+ * Reads the invocation from `process.argv` (override via `argv` for testing).
127
+ *
128
+ * Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
129
+ * Session output is small and the remote returns quickly, so buffering is
130
+ * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
131
+ */
132
+ export declare function runRemoteSessions(hosts: string[], argv?: string[], opts?: RunRemoteSessionsOptions): void;
@@ -0,0 +1,314 @@
1
+ /**
2
+ * `agents sessions --device <target>` — run the session query on a remote machine
3
+ * over SSH and stream its output back. Session transcripts and the index DB live
4
+ * on the machine that produced them (see `discover.ts`, all `os.homedir()`-rooted),
5
+ * so instead of syncing the bytes here we invoke the *remote's own* `agents
6
+ * sessions` against its already-built index and forward stdout verbatim.
7
+ *
8
+ * This is the live counterpart to `agents sessions sync` (R2/CRDT, eventual): no
9
+ * upfront copy, always current, but the peer must be reachable. SSH access is the
10
+ * only auth — if you can `ssh <host>`, you own the box (no identity layer by design).
11
+ *
12
+ * Cache-first (RUSH-2062) + offline degradation: every *successful* fetch is
13
+ * cached to `~/.agents/.cache/remote-sessions/`, keyed by host + the exact query.
14
+ * A later call with a *fresh* cache serves it without SSH (same daemon-warmed
15
+ * shared-cache shape as `stats-cache.ts`) so a reachable host is not re-probed
16
+ * on every menubar/CLI/watchdog tick. When the host is unreachable, any cache
17
+ * (even stale) is replayed with a clearly labelled "showing cached results"
18
+ * banner. The cache is a byproduct of fetches you already made — freely
19
+ * deletable — so the fetch-don't-replicate model holds.
20
+ *
21
+ * Mirrors the transport already used by `agents secrets export --device`
22
+ * (`src/commands/secrets.ts`): `ssh -o BatchMode=yes <host> bash -lc '<cmd>'`,
23
+ * with `bash -lc` so the remote login PATH resolves `agents`.
24
+ */
25
+ import { spawnSync } from 'child_process';
26
+ import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync } from 'fs';
27
+ import { join } from 'path';
28
+ import { createHash } from 'crypto';
29
+ import chalk from 'chalk';
30
+ import { getCacheDir } from '../../state.js';
31
+ import { SSH_OPTS, controlOpts, assertValidSshTarget } from '../../ssh-exec.js';
32
+ import { remoteShellFor, buildWindowsAgentsCommand } from '../../hosts/remote-cmd.js';
33
+ import { resolveRemoteOsSync } from '../../hosts/remote-os.js';
34
+ import { NO_FANOUT_ENV } from '../remote-active.js';
35
+ import { formatRelativeTime } from '../../text/relative-time.js';
36
+ import { terminalWidth } from '../../text/width.js';
37
+ /**
38
+ * POSIX single-quote a string for safe interpolation into a remote shell command.
39
+ * Always wraps (unlike the bare-passthrough variant in `ssh-exec.ts`) — the
40
+ * forwarded `agents` argv is embedded verbatim inside `bash -lc '<cmd>'`, so
41
+ * every token is quoted to keep the command boundary unambiguous.
42
+ */
43
+ export function shellQuote(s) {
44
+ return `'${s.replace(/'/g, `'\\''`)}'`;
45
+ }
46
+ /**
47
+ * Strip the `--device`/`-D` flag (and its value) from a raw `agents sessions` argv,
48
+ * leaving the args to forward to the remote unchanged. The remote runs the same
49
+ * binary, so every other flag (`--since`, `--last`, `--json`, query, …) carries
50
+ * over for free. Handles every form commander accepts: `--device h`, `--device=h`,
51
+ * `-D h`, `-D=h`, and the glued short form `-Dh`.
52
+ *
53
+ * @param argv full process argv; the sessions args begin at index 2
54
+ * (`[runtime, script, 'sessions', ...]`).
55
+ */
56
+ export function buildForwardedArgs(argv, hosts = new Set()) {
57
+ const args = argv.slice(2);
58
+ const out = [];
59
+ for (let i = 0; i < args.length; i++) {
60
+ const a = args[i];
61
+ if (a === '--device' || a === '--devices') {
62
+ // Commander's `<target...>` variadic accepts both `--device a --device b`
63
+ // and `--device a b` — consume every consecutive token that is a known host
64
+ // so the variadic form doesn't leak the extra hosts into the remote argv.
65
+ // Fall back to consuming the single next token when we have no host set
66
+ // (e.g. malformed input) so the flag value never leaks either way.
67
+ if (hosts.size > 0) {
68
+ while (i + 1 < args.length && hosts.has(args[i + 1]))
69
+ i++;
70
+ }
71
+ else {
72
+ i++; // also consume the separate value token
73
+ }
74
+ continue;
75
+ }
76
+ if (a.startsWith('--device=') || a.startsWith('--devices='))
77
+ continue;
78
+ out.push(a);
79
+ }
80
+ return out;
81
+ }
82
+ /**
83
+ * Force a forwarded `agents sessions` listing to span the peer's WHOLE index.
84
+ *
85
+ * A remote listing runs in the peer's SSH-login cwd — its home dir — and the
86
+ * default listing is silently cwd-scoped, so `sessions --device box` reads as
87
+ * empty even when the box's index is full (`No sessions found for /home/<user>`).
88
+ * Across SSH a peer's cwd is meaningless, so `--device` defaults to `--all`
89
+ * (whole-index) scope. This only drops the *cwd* narrowing — an explicit path
90
+ * query, `--project`, `--since`, or `--agent` filter still narrows on top, and
91
+ * a query that looks like a path takes precedence over `--all` on the remote.
92
+ * Idempotent: never adds a second `--all`.
93
+ */
94
+ export function ensureWholeIndex(forwardedArgs) {
95
+ return forwardedArgs.includes('--all') ? forwardedArgs : [...forwardedArgs, '--all'];
96
+ }
97
+ /**
98
+ * Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
99
+ * are quoted for the inner login shell, then the whole `agents …` invocation is
100
+ * quoted again so it survives `bash -lc <...>`.
101
+ *
102
+ * `os` selects the remote shell: a Windows host gets a PowerShell invocation
103
+ * (ssh lands in cmd.exe/PowerShell there, where `bash -lc` does not exist);
104
+ * anything else — including unknown/absent — keeps the POSIX form unchanged.
105
+ * The forwarded terminal width rides across as an env var either way so the
106
+ * remote renders its table to the local screen.
107
+ */
108
+ export function buildRemoteCommand(forwardedArgs, columns, os) {
109
+ // `--device <box>` means "that box's own sessions" — so the peer must answer for
110
+ // ITSELF and not re-sweep its fleet. Without this the remote `agents sessions`
111
+ // fans back out to every device IT knows (including us), printing a spurious
112
+ // `<this-machine>: unreachable`. AGENTS_SESSIONS_LOCAL=1 pins the peer local,
113
+ // matching the JSON fan-out path (`remote-list.ts`).
114
+ if (remoteShellFor(os) === 'powershell') {
115
+ const env = { [NO_FANOUT_ENV]: '1' };
116
+ if (columns && columns > 0)
117
+ env.COLUMNS = String(columns);
118
+ return buildWindowsAgentsCommand({ args: forwardedArgs, env });
119
+ }
120
+ const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
121
+ // Forward the caller's terminal width so the remote renders the table to the
122
+ // local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
123
+ // cmd` scopes the env to that process — the remote's terminalWidth() reads it.
124
+ const envPrefix = `${NO_FANOUT_ENV}=1` + (columns && columns > 0 ? ` COLUMNS=${columns}` : '');
125
+ return `bash -lc ${shellQuote(`${envPrefix} ${inner}`)}`;
126
+ }
127
+ /**
128
+ * Classify an ssh `spawnSync` result. ssh(1) reserves exit 255 for its own
129
+ * connection-layer failures (host down, timeout, refused, auth, changed host
130
+ * key) — distinct from any other non-zero, which is the remote `agents sessions`
131
+ * exit code forwarded back (the query ran but failed). The two must be handled
132
+ * differently: 255 may fall back to cache, a forwarded failure must surface.
133
+ */
134
+ export function classifySshFailure(res) {
135
+ if (res.error)
136
+ return 'spawn-error';
137
+ if (res.status === 0)
138
+ return 'ok';
139
+ if (res.status === 255)
140
+ return 'unreachable';
141
+ return 'query-failed';
142
+ }
143
+ /** Root of the offline-replay cache (`~/.agents/.cache/remote-sessions/`). */
144
+ const REMOTE_CACHE_DIR = join(getCacheDir(), 'remote-sessions');
145
+ /**
146
+ * How long a successful remote fetch may be served without re-SSHing.
147
+ * Short on purpose: session listings must stay near-live (RUSH-2062). Match the
148
+ * active-session snapshot window so surfaces share one freshness model.
149
+ */
150
+ export const REMOTE_CACHE_MAX_AGE_MS = 15_000;
151
+ /**
152
+ * Deterministic cache path for a (host, forwarded-args) pair. The forwarded args
153
+ * are hashed so distinct queries cache independently; the host stays readable in
154
+ * the filename (sanitised so `user@host` and aliases are filesystem-safe).
155
+ */
156
+ export function remoteCachePath(host, forwardedArgs) {
157
+ const hash = createHash('sha256').update(forwardedArgs.join('\u0000')).digest('hex').slice(0, 16);
158
+ const safeHost = host.replace(/[^a-zA-Z0-9._@-]/g, '_');
159
+ return join(REMOTE_CACHE_DIR, `${safeHost}__${hash}.txt`);
160
+ }
161
+ /**
162
+ * Pure freshness check for a remote-sessions cache entry. A reachable host
163
+ * skips SSH only while this returns true; unreachable fallback ignores age.
164
+ */
165
+ export function isRemoteCacheFresh(mtimeMs, nowMs, maxAgeMs = REMOTE_CACHE_MAX_AGE_MS) {
166
+ if (!Number.isFinite(mtimeMs) || !Number.isFinite(maxAgeMs) || maxAgeMs < 0)
167
+ return false;
168
+ return nowMs - mtimeMs <= maxAgeMs;
169
+ }
170
+ /**
171
+ * Read a cached remote fetch. When `maxAgeMs` is set, returns null if the
172
+ * entry is older than the window (cache-first path for reachable hosts).
173
+ * Omit `maxAgeMs` to accept any age (unreachable fallback).
174
+ */
175
+ export function readRemoteCache(host, forwardedArgs, opts = {}) {
176
+ try {
177
+ const p = remoteCachePath(host, forwardedArgs);
178
+ if (!existsSync(p))
179
+ return null;
180
+ const mtimeMs = statSync(p).mtimeMs;
181
+ if (opts.maxAgeMs !== undefined) {
182
+ const now = opts.nowMs ?? Date.now();
183
+ if (!isRemoteCacheFresh(mtimeMs, now, opts.maxAgeMs))
184
+ return null;
185
+ }
186
+ return { output: readFileSync(p, 'utf8'), mtimeMs };
187
+ }
188
+ catch {
189
+ return null;
190
+ }
191
+ }
192
+ /** Banner shown above replayed cache rows when the peer is offline. */
193
+ export function formatStaleBanner(host, mtimeMs) {
194
+ const ago = formatRelativeTime(new Date(mtimeMs).toISOString());
195
+ return chalk.yellow(`${host}: offline — showing cached results from ${ago}`);
196
+ }
197
+ /** Message shown when a host is unreachable and there is no cache to fall back to. */
198
+ export function formatUnreachable(host) {
199
+ return chalk.red(`${host}: unreachable over SSH (asleep, offline, or host key changed?) — ConnectTimeout 10s`);
200
+ }
201
+ /** Persist a successful fetch for later cache-first / offline replay.
202
+ * Best-effort: a cache write must never break the live query. Exported for tests. */
203
+ export function writeRemoteCache(host, forwardedArgs, output) {
204
+ try {
205
+ mkdirSync(REMOTE_CACHE_DIR, { recursive: true });
206
+ writeFileSync(remoteCachePath(host, forwardedArgs), output);
207
+ }
208
+ catch {
209
+ // ignore — caching is an optimisation, not a guarantee
210
+ }
211
+ }
212
+ /**
213
+ * Serve a *fresh* cache entry for a reachable-host skip (no banner — the data
214
+ * is still within the freshness window). Returns false when missing/stale so
215
+ * the caller SSHes. RUSH-2062: without this, a reachable host never skipped SSH
216
+ * even when the cache was just written.
217
+ */
218
+ export function serveWarmRemoteCache(host, forwardedArgs, opts = {}) {
219
+ const hit = readRemoteCache(host, forwardedArgs, {
220
+ maxAgeMs: opts.maxAgeMs ?? REMOTE_CACHE_MAX_AGE_MS,
221
+ nowMs: opts.nowMs,
222
+ });
223
+ if (!hit)
224
+ return false;
225
+ process.stdout.write(hit.output);
226
+ return true;
227
+ }
228
+ /** Replay a cached fetch for an unreachable host (any age). Banner goes to
229
+ * stderr (so a piped stdout stays exactly the cached rows); returns false when
230
+ * nothing is cached for this exact (host, query). */
231
+ export function replayRemoteCache(host, forwardedArgs) {
232
+ const hit = readRemoteCache(host, forwardedArgs); // no maxAge — any age ok
233
+ if (!hit)
234
+ return false;
235
+ process.stderr.write(formatStaleBanner(host, hit.mtimeMs) + '\n');
236
+ process.stdout.write(hit.output);
237
+ return true;
238
+ }
239
+ /**
240
+ * Run the current `agents sessions` invocation on one or more remote machines over
241
+ * SSH, writing each remote's output to the terminal.
242
+ *
243
+ * Cache policy (RUSH-2062):
244
+ * - **Default:** serve a fresh cache hit without SSH; SSH only on miss/stale.
245
+ * - **`forceRefresh`:** always SSH, then rewrite the cache.
246
+ * - **Unreachable:** fall back to any cached output (with a stale banner).
247
+ *
248
+ * Sets `process.exitCode = 1` if any host could not be answered (live or cached).
249
+ * Reads the invocation from `process.argv` (override via `argv` for testing).
250
+ *
251
+ * Output is captured rather than `stdio: 'inherit'`-streamed so it can be cached.
252
+ * Session output is small and the remote returns quickly, so buffering is
253
+ * imperceptible; `maxBuffer` is generous for the rare large `--markdown <id>` dump.
254
+ */
255
+ export function runRemoteSessions(hosts, argv = process.argv, opts = {}) {
256
+ for (const host of hosts)
257
+ assertValidSshTarget(host); // fail fast on any bad target
258
+ const forwarded = ensureWholeIndex(buildForwardedArgs(argv, new Set(hosts)));
259
+ const cols = terminalWidth();
260
+ const multi = hosts.length > 1;
261
+ let failures = 0;
262
+ const forceRefresh = opts.forceRefresh === true
263
+ || process.env.AGENTS_SESSIONS_FORCE_REFRESH === '1';
264
+ for (const host of hosts) {
265
+ if (multi)
266
+ process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
267
+ // Cache-first: a warm hit skips SSH entirely so reachable hosts share one
268
+ // snapshot across menubar/CLI/watchdog instead of re-fanning every call.
269
+ if (!forceRefresh && serveWarmRemoteCache(host, forwarded, {
270
+ maxAgeMs: opts.maxAgeMs,
271
+ nowMs: opts.nowMs,
272
+ })) {
273
+ continue;
274
+ }
275
+ // Per-host: a Windows peer needs a PowerShell command, POSIX peers `bash -lc`.
276
+ const remoteCmd = buildRemoteCommand(forwarded, cols, resolveRemoteOsSync(host));
277
+ const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
278
+ encoding: 'utf8',
279
+ maxBuffer: 64 * 1024 * 1024,
280
+ });
281
+ switch (classifySshFailure(res)) {
282
+ case 'ok':
283
+ process.stdout.write(res.stdout ?? '');
284
+ if (res.stderr)
285
+ process.stderr.write(res.stderr);
286
+ writeRemoteCache(host, forwarded, res.stdout ?? '');
287
+ break;
288
+ case 'unreachable':
289
+ // Served-from-cache counts as answered (degraded, but with data + a clear
290
+ // banner), so it does not increment failures. No cache → a real failure.
291
+ if (!replayRemoteCache(host, forwarded)) {
292
+ failures++;
293
+ console.error(formatUnreachable(host));
294
+ }
295
+ break;
296
+ case 'spawn-error':
297
+ failures++;
298
+ console.error(chalk.red(`${host}: ${res.error?.message ?? 'failed to launch ssh'}`));
299
+ break;
300
+ case 'query-failed':
301
+ // The remote ran but its query exited non-zero — surface its own output
302
+ // and exit code; never mask a genuine error with stale cache.
303
+ failures++;
304
+ if (res.stdout)
305
+ process.stdout.write(res.stdout);
306
+ if (res.stderr)
307
+ process.stderr.write(res.stderr);
308
+ console.error(chalk.red(`${host}: remote query failed (exit ${res.status ?? 'signal'}).`));
309
+ break;
310
+ }
311
+ }
312
+ if (failures > 0)
313
+ process.exitCode = 1;
314
+ }
@@ -0,0 +1,101 @@
1
+ import type { ActiveSession } from '../active.js';
2
+ import { readActiveSessionsCache } from '../session-cache.js';
3
+ export declare const SESSION_WATCH_VERSION: 1;
4
+ export declare const SESSION_WATCH_HEARTBEAT_MS = 15000;
5
+ export type SessionWatchScopeStatus = 'available' | 'unavailable';
6
+ export type SessionWatchEnvelope = {
7
+ version: 1;
8
+ type: 'reset';
9
+ streamId: string;
10
+ sequence: number;
11
+ capturedAt: number;
12
+ scope: string;
13
+ rows: SessionWatchRow[];
14
+ } | {
15
+ version: 1;
16
+ type: 'upsert';
17
+ streamId: string;
18
+ sequence: number;
19
+ capturedAt: number;
20
+ scope: string;
21
+ rowKey: string;
22
+ row: SessionWatchRow;
23
+ } | {
24
+ version: 1;
25
+ type: 'remove';
26
+ streamId: string;
27
+ sequence: number;
28
+ capturedAt: number;
29
+ scope: string;
30
+ rowKey: string;
31
+ } | {
32
+ version: 1;
33
+ type: 'scope';
34
+ streamId: string;
35
+ sequence: number;
36
+ capturedAt: number;
37
+ scope: string;
38
+ status: SessionWatchScopeStatus;
39
+ reason?: string;
40
+ } | {
41
+ version: 1;
42
+ type: 'heartbeat';
43
+ streamId: string;
44
+ sequence: number;
45
+ scope: string;
46
+ capturedAt: number;
47
+ };
48
+ export interface SessionWatchRow extends Omit<ActiveSession, 'viewingIn'> {
49
+ rowKey: string;
50
+ sourceDevice: string;
51
+ resumable: boolean;
52
+ unwatched: boolean;
53
+ viewingIn: string | null;
54
+ recovery: {
55
+ command: 'agents';
56
+ args: string[];
57
+ cwd?: string;
58
+ } | null;
59
+ }
60
+ /** Stable, opaque identity for one row within one device scope. */
61
+ export declare function sessionWatchRowKey(scope: string, row: ActiveSession): string;
62
+ export declare function toSessionWatchRow(scope: string, row: ActiveSession): SessionWatchRow;
63
+ /** Stream-local sequencer and convergent row diff. */
64
+ export declare class SessionWatchState {
65
+ readonly streamId: string;
66
+ private sequence;
67
+ private readonly rows;
68
+ constructor(streamId?: string);
69
+ private base;
70
+ reset(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope;
71
+ update(scope: string, sourceRows: ActiveSession[]): SessionWatchEnvelope[];
72
+ patch(scope: string, upserts: ActiveSession[], removes: string[]): SessionWatchEnvelope[];
73
+ scope(scope: string, status: SessionWatchScopeStatus, reason?: string): SessionWatchEnvelope;
74
+ heartbeat(scope: string, capturedAt?: number): SessionWatchEnvelope;
75
+ }
76
+ export interface WatchLocalOptions {
77
+ scope: string;
78
+ signal: AbortSignal;
79
+ emit: (event: SessionWatchEnvelope) => void;
80
+ refreshMs?: number;
81
+ heartbeatMs?: number;
82
+ readCache?: typeof readActiveSessionsCache;
83
+ journalPath?: string;
84
+ journalPollMs?: number;
85
+ }
86
+ /**
87
+ * Keep one local subscription alive. Startup reads one canonical reset snapshot;
88
+ * steady state tails the canonical writer journal and never invokes a gather.
89
+ */
90
+ export declare function watchLocalSessions(options: WatchLocalOptions): Promise<void>;
91
+ export interface WatchFleetOptions {
92
+ signal: AbortSignal;
93
+ emit: (event: SessionWatchEnvelope) => void;
94
+ reconnectMs?: number;
95
+ }
96
+ /**
97
+ * Subscribe to every dialable compute device with one persistent SSH process.
98
+ * A peer disconnect emits `scope: unavailable` but no removes; reconnecting
99
+ * peer resets only its own scope. There is no recurring fleet list command.
100
+ */
101
+ export declare function watchFleetSessions(options: WatchFleetOptions): Promise<void>;