@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.
- package/CHANGELOG.md +77 -0
- package/README.md +14 -12
- package/dist/bootstrap.js +8 -6
- package/dist/cli/command-registry.d.ts +0 -5
- package/dist/cli/command-registry.js +1 -17
- package/dist/commands/apply.d.ts +7 -10
- package/dist/commands/apply.js +15 -35
- package/dist/commands/artifacts.js +4 -5
- package/dist/commands/audit.d.ts +6 -6
- package/dist/commands/audit.js +12 -12
- package/dist/commands/auth.js +7 -3
- package/dist/commands/beta.d.ts +7 -1
- package/dist/commands/beta.js +16 -9
- package/dist/commands/commands.js +1 -1
- package/dist/commands/doctor.js +1 -4
- package/dist/commands/events.d.ts +2 -2
- package/dist/commands/events.js +6 -5
- package/dist/commands/exec.d.ts +1 -1
- package/dist/commands/exec.js +8 -4
- package/dist/commands/factory.d.ts +1 -1
- package/dist/commands/fleet-capture.d.ts +1 -1
- package/dist/commands/fleet-capture.js +4 -5
- package/dist/commands/harness.js +1 -49
- package/dist/commands/hooks.js +1 -1
- package/dist/commands/insights.d.ts +1 -1
- package/dist/commands/insights.js +3 -2
- package/dist/commands/menubar.js +58 -1
- package/dist/commands/org.d.ts +5 -0
- package/dist/commands/org.js +82 -29
- package/dist/commands/output.js +2 -2
- package/dist/commands/prune.js +5 -4
- package/dist/commands/routines.test-fixture.d.ts +86 -0
- package/dist/commands/routines.test-fixture.js +345 -0
- package/dist/commands/sessions.test-fixture.d.ts +24 -0
- package/dist/commands/sessions.test-fixture.js +224 -0
- package/dist/commands/setup.js +5 -1
- package/dist/commands/share.d.ts +9 -10
- package/dist/commands/share.js +68 -50
- package/dist/commands/skills.js +1 -1
- package/dist/commands/ssh.js +15 -62
- package/dist/commands/subagents.js +1 -1
- package/dist/commands/view.js +5 -2
- package/dist/lib/analytics/mix-commands.d.ts +6 -28
- package/dist/lib/analytics/mix-commands.js +14 -40
- package/dist/lib/audit/log.d.ts +2 -2
- package/dist/lib/audit/log.js +2 -2
- package/dist/lib/beta.js +1 -1
- package/dist/lib/browser/domain-skills.d.ts +26 -6
- package/dist/lib/browser/domain-skills.js +81 -43
- package/dist/lib/daemon/daemon.js +11 -0
- package/dist/lib/daemon/daemon.test-fixture.d.ts +24 -0
- package/dist/lib/daemon/daemon.test-fixture.js +71 -0
- package/dist/lib/daemon-ticks.d.ts +38 -1
- package/dist/lib/daemon-ticks.js +52 -5
- package/dist/lib/device-config.js +1 -2
- package/dist/lib/devices/discovery-policy.d.ts +3 -3
- package/dist/lib/devices/discovery-policy.js +3 -3
- package/dist/lib/devices/fleet.d.ts +4 -8
- package/dist/lib/devices/fleet.js +3 -15
- package/dist/lib/devices/health-report.js +3 -3
- package/dist/lib/devices/pool.d.ts +0 -6
- package/dist/lib/devices/pool.js +0 -6
- package/dist/lib/devices/registry.d.ts +0 -15
- package/dist/lib/devices/registry.js +0 -9
- package/dist/lib/devices/stats-cache.d.ts +12 -0
- package/dist/lib/devices/stats-cache.js +24 -5
- package/dist/lib/fleet/capture.d.ts +1 -1
- package/dist/lib/fleet/manifest.js +1 -1
- package/dist/lib/fleet/remote-login.d.ts +2 -2
- package/dist/lib/fleet/remote-login.js +3 -4
- package/dist/lib/hosts/dispatch.d.ts +2 -0
- package/dist/lib/hosts/dispatch.js +8 -1
- package/dist/lib/hosts/passthrough.js +0 -1
- package/dist/lib/hosts/providers/devices.js +1 -13
- package/dist/lib/hosts/ready.d.ts +12 -3
- package/dist/lib/hosts/ready.js +27 -12
- package/dist/lib/hosts/registry.d.ts +4 -5
- package/dist/lib/hosts/registry.js +3 -7
- package/dist/lib/installations/versions.js +21 -2
- package/dist/lib/menubar/MenubarHelper.app/Contents/Info.plist +1 -5
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/_CodeSignature/CodeResources +2 -15
- package/dist/lib/menubar/install-menubar.d.ts +109 -0
- package/dist/lib/menubar/install-menubar.js +199 -4
- package/dist/lib/prix-account.d.ts +2 -1
- package/dist/lib/prix-account.js +2 -1
- package/dist/lib/remote-agents-json.js +1 -7
- package/dist/lib/routines-placement.d.ts +1 -1
- package/dist/lib/routines-placement.js +1 -1
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +0 -2
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +1 -13
- package/dist/lib/session/active.d.ts +4 -16
- package/dist/lib/session/active.js +2 -6
- package/dist/lib/session/db.d.ts +13 -98
- package/dist/lib/session/db.js +4 -11
- package/dist/lib/session/discover.d.ts +5 -81
- package/dist/lib/session/discover.js +5 -5
- package/dist/lib/session/remote/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote/remote-bundle.js +61 -0
- package/dist/lib/session/remote/remote-list.d.ts +148 -0
- package/dist/lib/session/remote/remote-list.js +607 -0
- package/dist/lib/session/remote/remote.d.ts +132 -0
- package/dist/lib/session/remote/remote.js +314 -0
- package/dist/lib/session/remote/watch.d.ts +101 -0
- package/dist/lib/session/remote/watch.js +241 -0
- package/dist/lib/session/remote-bundle.d.ts +1 -12
- package/dist/lib/session/remote-bundle.js +3 -61
- package/dist/lib/session/remote-list.d.ts +1 -148
- package/dist/lib/session/remote-list.js +3 -607
- package/dist/lib/session/remote.d.ts +1 -132
- package/dist/lib/session/remote.js +3 -314
- package/dist/lib/session/watch.d.ts +1 -101
- package/dist/lib/session/watch.js +3 -242
- package/dist/lib/share/config.d.ts +12 -2
- package/dist/lib/share/config.js +47 -11
- package/dist/lib/share/delete.js +1 -1
- package/dist/lib/share/publish.js +1 -1
- package/dist/lib/share/worker-template.js +2 -2
- package/dist/lib/smart-launch.d.ts +4 -4
- package/dist/lib/smart-launch.js +8 -18
- package/dist/lib/staleness/index.d.ts +3 -1
- package/dist/lib/staleness/index.js +21 -1
- package/dist/lib/staleness/types.d.ts +11 -0
- package/dist/lib/staleness/writers/commands.js +13 -4
- package/dist/lib/staleness/writers/hooks.js +3 -1
- package/dist/lib/staleness/writers/mcp.d.ts +0 -7
- package/dist/lib/staleness/writers/mcp.js +16 -1
- package/dist/lib/staleness/writers/rules.js +1 -1
- package/dist/lib/staleness/writers/skills.js +3 -1
- package/dist/lib/staleness/writers/subagents.d.ts +0 -9
- package/dist/lib/staleness/writers/subagents.js +18 -1
- package/dist/lib/staleness/writers/types.d.ts +10 -0
- package/dist/lib/startup/command-registry.d.ts +7 -1
- package/dist/lib/startup/command-registry.js +17 -5
- package/dist/lib/startup/root-command.d.ts +18 -1
- package/dist/lib/startup/root-command.js +18 -1
- package/dist/lib/state.js +7 -0
- package/dist/lib/teams/placement-probe.js +27 -12
- package/dist/lib/teams/scheduler.d.ts +3 -1
- package/dist/lib/types.d.ts +1 -1
- package/dist/lib/view-types.d.ts +3 -0
- package/package.json +2 -3
- package/dist/bin/agents +0 -0
- package/dist/commands/serve.d.ts +0 -10
- package/dist/commands/serve.js +0 -68
- package/dist/commands/trends.d.ts +0 -10
- package/dist/commands/trends.js +0 -12
- package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
- package/dist/lib/serve/control.d.ts +0 -95
- package/dist/lib/serve/control.js +0 -260
- package/dist/lib/serve/data.d.ts +0 -81
- package/dist/lib/serve/data.js +0 -91
- package/dist/lib/serve/page.d.ts +0 -7
- package/dist/lib/serve/page.js +0 -140
- package/dist/lib/serve/server.d.ts +0 -80
- package/dist/lib/serve/server.js +0 -145
- package/dist/lib/serve/stream.d.ts +0 -43
- package/dist/lib/serve/stream.js +0 -116
- package/dist/lib/serve/token.d.ts +0 -35
- 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>;
|