@phnx-labs/agents-cli 1.20.35 → 1.20.37
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 +91 -0
- package/LICENSE +185 -21
- package/README.md +8 -4
- package/dist/commands/audit.d.ts +14 -0
- package/dist/commands/audit.js +68 -0
- package/dist/commands/browser.js +82 -8
- package/dist/commands/check.d.ts +15 -0
- package/dist/commands/check.js +84 -0
- package/dist/commands/cloud.js +143 -3
- package/dist/commands/computer.js +81 -0
- package/dist/commands/daemon.js +4 -1
- package/dist/commands/doctor.js +1 -89
- package/dist/commands/events.js +3 -3
- package/dist/commands/exec.d.ts +19 -0
- package/dist/commands/exec.js +277 -11
- package/dist/commands/hosts.js +10 -6
- package/dist/commands/inspect.js +8 -9
- package/dist/commands/lock.d.ts +12 -0
- package/dist/commands/lock.js +70 -0
- package/dist/commands/message.d.ts +15 -0
- package/dist/commands/message.js +56 -0
- package/dist/commands/routines.js +101 -5
- package/dist/commands/secrets-migrate.js +106 -57
- package/dist/commands/secrets.d.ts +31 -18
- package/dist/commands/secrets.js +156 -75
- package/dist/commands/serve.d.ts +10 -0
- package/dist/commands/serve.js +37 -0
- package/dist/commands/sessions-inject.d.ts +14 -0
- package/dist/commands/sessions-inject.js +111 -0
- package/dist/commands/sessions-picker.d.ts +2 -0
- package/dist/commands/sessions-picker.js +24 -3
- package/dist/commands/sessions-resume.js +20 -8
- package/dist/commands/sessions-sync.d.ts +3 -0
- package/dist/commands/sessions-sync.js +44 -4
- package/dist/commands/sessions.d.ts +78 -1
- package/dist/commands/sessions.js +506 -66
- package/dist/commands/setup.js +4 -2
- package/dist/commands/sync.d.ts +3 -1
- package/dist/commands/sync.js +156 -4
- package/dist/commands/teams.js +217 -0
- package/dist/commands/versions.js +2 -4
- package/dist/commands/watchdog.d.ts +18 -0
- package/dist/commands/watchdog.js +238 -0
- package/dist/index.js +25 -2
- package/dist/lib/audit/log.d.ts +92 -0
- package/dist/lib/audit/log.js +177 -0
- package/dist/lib/auto-pull.js +2 -1
- package/dist/lib/browser/chrome.d.ts +10 -0
- package/dist/lib/browser/chrome.js +18 -7
- package/dist/lib/browser/drivers/ssh.js +2 -1
- package/dist/lib/browser/har.d.ts +84 -0
- package/dist/lib/browser/har.js +77 -0
- package/dist/lib/browser/ipc.js +24 -3
- package/dist/lib/browser/profiles.d.ts +1 -1
- package/dist/lib/browser/profiles.js +8 -10
- package/dist/lib/browser/refs.d.ts +65 -0
- package/dist/lib/browser/refs.js +73 -1
- package/dist/lib/browser/runtime-state.js +1 -0
- package/dist/lib/browser/service.d.ts +38 -2
- package/dist/lib/browser/service.js +112 -8
- package/dist/lib/browser/types.d.ts +14 -1
- package/dist/lib/budget/live-cloud.d.ts +42 -0
- package/dist/lib/budget/live-cloud.js +79 -0
- package/dist/lib/budget/live-team.d.ts +31 -0
- package/dist/lib/budget/live-team.js +115 -0
- package/dist/lib/cloud/codex.js +4 -0
- package/dist/lib/cloud/rush.d.ts +12 -1
- package/dist/lib/cloud/rush.js +13 -3
- package/dist/lib/cloud/types.d.ts +9 -0
- package/dist/lib/computer/dispatch.d.ts +8 -0
- package/dist/lib/computer/dispatch.js +125 -0
- package/dist/lib/computer/loop.d.ts +62 -0
- package/dist/lib/computer/loop.js +98 -0
- package/dist/lib/computer/model.d.ts +44 -0
- package/dist/lib/computer/model.js +157 -0
- package/dist/lib/concurrency.d.ts +19 -0
- package/dist/lib/concurrency.js +33 -0
- package/dist/lib/daemon.d.ts +57 -0
- package/dist/lib/daemon.js +196 -18
- package/dist/lib/devices/registry.d.ts +7 -0
- package/dist/lib/devices/registry.js +24 -0
- package/dist/lib/devices/resolve-target.d.ts +24 -0
- package/dist/lib/devices/resolve-target.js +80 -0
- package/dist/lib/devices/tailscale.js +1 -1
- package/dist/lib/drift.d.ts +52 -0
- package/dist/lib/drift.js +112 -0
- package/dist/lib/events.d.ts +1 -1
- package/dist/lib/events.js +31 -13
- package/dist/lib/exec.d.ts +17 -0
- package/dist/lib/exec.js +79 -13
- package/dist/lib/git.d.ts +27 -0
- package/dist/lib/git.js +56 -1
- package/dist/lib/hooks/cache.d.ts +6 -0
- package/dist/lib/hooks/cache.js +54 -12
- package/dist/lib/hooks.d.ts +27 -0
- package/dist/lib/hooks.js +127 -8
- package/dist/lib/hosts/dispatch.d.ts +15 -0
- package/dist/lib/hosts/dispatch.js +39 -6
- package/dist/lib/hosts/logs.js +30 -1
- package/dist/lib/hosts/option.js +1 -1
- package/dist/lib/hosts/passthrough.js +3 -1
- package/dist/lib/hosts/ready.d.ts +29 -6
- package/dist/lib/hosts/ready.js +66 -15
- package/dist/lib/hosts/registry.d.ts +19 -2
- package/dist/lib/hosts/registry.js +58 -2
- package/dist/lib/hosts/remote-cmd.d.ts +62 -1
- package/dist/lib/hosts/remote-cmd.js +70 -1
- package/dist/lib/hosts/remote-os.d.ts +17 -0
- package/dist/lib/hosts/remote-os.js +30 -0
- package/dist/lib/hosts/session-index.d.ts +34 -0
- package/dist/lib/hosts/session-index.js +56 -0
- package/dist/lib/hosts/tasks.d.ts +14 -0
- package/dist/lib/hosts/tasks.js +15 -0
- package/dist/lib/lock.d.ts +93 -0
- package/dist/lib/lock.js +207 -0
- package/dist/lib/loop.js +16 -1
- package/dist/lib/machine-id.d.ts +21 -0
- package/dist/lib/machine-id.js +26 -0
- package/dist/lib/mailbox-target.d.ts +36 -0
- package/dist/lib/mailbox-target.js +45 -0
- package/dist/lib/mailbox.d.ts +47 -0
- package/dist/lib/mailbox.js +194 -0
- package/dist/lib/mcp.d.ts +5 -0
- package/dist/lib/mcp.js +24 -8
- package/dist/lib/migrate.d.ts +19 -0
- package/dist/lib/migrate.js +134 -26
- package/dist/lib/overdue.js +3 -0
- package/dist/lib/picker.d.ts +2 -0
- package/dist/lib/picker.js +4 -1
- package/dist/lib/platform/exec.d.ts +46 -0
- package/dist/lib/platform/exec.js +74 -0
- package/dist/lib/platform/process.d.ts +31 -0
- package/dist/lib/platform/process.js +34 -1
- package/dist/lib/platform/winpath.js +2 -0
- package/dist/lib/plugins.js +16 -6
- package/dist/lib/profiles.d.ts +25 -0
- package/dist/lib/profiles.js +22 -6
- package/dist/lib/pty-client.js +2 -1
- package/dist/lib/rotate.d.ts +61 -0
- package/dist/lib/rotate.js +52 -0
- package/dist/lib/routines.d.ts +40 -2
- package/dist/lib/routines.js +66 -8
- package/dist/lib/runner.d.ts +11 -2
- package/dist/lib/runner.js +49 -7
- package/dist/lib/scheduler.js +6 -1
- package/dist/lib/secrets/bundles.d.ts +60 -4
- package/dist/lib/secrets/bundles.js +131 -12
- package/dist/lib/secrets/filestore.d.ts +3 -0
- package/dist/lib/secrets/filestore.js +42 -16
- package/dist/lib/secrets/index.d.ts +43 -2
- package/dist/lib/secrets/index.js +102 -3
- package/dist/lib/secrets/mcp.d.ts +93 -0
- package/dist/lib/secrets/mcp.js +205 -0
- package/dist/lib/secrets/remote.js +12 -5
- package/dist/lib/secrets/sync.js +83 -4
- package/dist/lib/secrets/windows.js +14 -3
- package/dist/lib/serve/data.d.ts +81 -0
- package/dist/lib/serve/data.js +91 -0
- package/dist/lib/serve/page.d.ts +7 -0
- package/dist/lib/serve/page.js +140 -0
- package/dist/lib/serve/server.d.ts +46 -0
- package/dist/lib/serve/server.js +115 -0
- package/dist/lib/session/active.d.ts +73 -0
- package/dist/lib/session/active.js +199 -22
- package/dist/lib/session/db.d.ts +2 -1
- package/dist/lib/session/db.js +41 -5
- package/dist/lib/session/discover.d.ts +39 -0
- package/dist/lib/session/discover.js +127 -29
- package/dist/lib/session/ghostty-tabs.d.ts +33 -0
- package/dist/lib/session/ghostty-tabs.js +126 -0
- package/dist/lib/session/inject.d.ts +18 -0
- package/dist/lib/session/inject.js +21 -0
- package/dist/lib/session/parse.js +23 -20
- package/dist/lib/session/pid-registry.d.ts +1 -0
- package/dist/lib/session/pid-registry.js +24 -0
- package/dist/lib/session/provenance.d.ts +14 -2
- package/dist/lib/session/provenance.js +39 -8
- package/dist/lib/session/relative-time.js +6 -2
- package/dist/lib/session/remote-active.js +19 -17
- package/dist/lib/session/remote-list.d.ts +51 -0
- package/dist/lib/session/remote-list.js +205 -0
- package/dist/lib/session/remote.d.ts +7 -1
- package/dist/lib/session/remote.js +20 -4
- package/dist/lib/session/sync/config.d.ts +14 -15
- package/dist/lib/session/sync/config.js +60 -20
- package/dist/lib/session/types.d.ts +23 -0
- package/dist/lib/shims.d.ts +36 -6
- package/dist/lib/shims.js +91 -29
- package/dist/lib/ssh-exec.js +2 -0
- package/dist/lib/ssh-tunnel.js +2 -1
- package/dist/lib/startup/command-registry.d.ts +6 -0
- package/dist/lib/startup/command-registry.js +13 -1
- package/dist/lib/state.d.ts +13 -0
- package/dist/lib/state.js +103 -9
- package/dist/lib/sync-umbrella.d.ts +14 -7
- package/dist/lib/sync-umbrella.js +21 -13
- package/dist/lib/teams/forEach.d.ts +110 -0
- package/dist/lib/teams/forEach.js +186 -0
- package/dist/lib/teams/index.d.ts +1 -0
- package/dist/lib/teams/index.js +1 -0
- package/dist/lib/teams/pr-watch.d.ts +226 -0
- package/dist/lib/teams/pr-watch.js +371 -0
- package/dist/lib/teams/supervisor.d.ts +14 -1
- package/dist/lib/teams/supervisor.js +19 -0
- package/dist/lib/teams/worktree.d.ts +9 -0
- package/dist/lib/teams/worktree.js +32 -0
- package/dist/lib/terminal/backends/index.d.ts +2 -1
- package/dist/lib/terminal/backends/index.js +3 -1
- package/dist/lib/terminal/backends/vscodium-agent.d.ts +31 -0
- package/dist/lib/terminal/backends/vscodium-agent.js +72 -0
- package/dist/lib/terminal/index.d.ts +4 -1
- package/dist/lib/terminal/index.js +4 -1
- package/dist/lib/terminal/inject.d.ts +204 -0
- package/dist/lib/terminal/inject.js +247 -0
- package/dist/lib/terminal/resolve.d.ts +64 -0
- package/dist/lib/terminal/resolve.js +90 -0
- package/dist/lib/terminal/types.d.ts +1 -1
- package/dist/lib/tmux/session.d.ts +10 -0
- package/dist/lib/tmux/session.js +31 -0
- package/dist/lib/triggers/webhook.d.ts +85 -0
- package/dist/lib/triggers/webhook.js +141 -0
- package/dist/lib/versions.d.ts +23 -0
- package/dist/lib/versions.js +119 -13
- package/dist/lib/watchdog/index.d.ts +3 -0
- package/dist/lib/watchdog/index.js +5 -0
- package/dist/lib/watchdog/read.d.ts +35 -0
- package/dist/lib/watchdog/read.js +149 -0
- package/dist/lib/watchdog/runner.d.ts +127 -0
- package/dist/lib/watchdog/runner.js +322 -0
- package/dist/lib/watchdog/watchdog.d.ts +40 -0
- package/dist/lib/watchdog/watchdog.js +166 -0
- package/dist/lib/watchdog/watchdogTail.d.ts +5 -0
- package/dist/lib/watchdog/watchdogTail.js +154 -0
- package/dist/lib/workflows.d.ts +166 -0
- package/dist/lib/workflows.js +193 -0
- package/package.json +5 -4
|
@@ -15,9 +15,22 @@
|
|
|
15
15
|
* (gone, or owned by another uid) yields `undefined`, never a guess.
|
|
16
16
|
*
|
|
17
17
|
* `reply` is a read-only hint, not a send channel: it reports whether a rail
|
|
18
|
-
* that can type back into this session exists today
|
|
19
|
-
*
|
|
20
|
-
*
|
|
18
|
+
* that can type back into this session exists today and, if so, the exact
|
|
19
|
+
* address to type into. Two env-derived rails, in precedence order:
|
|
20
|
+
*
|
|
21
|
+
* - tmux — `$TMUX_PANE` (+ `$TMUX` socket): `tmux send-keys -t <pane>`. Works
|
|
22
|
+
* inside ANY host app (a tmux pane can live under iTerm/Ghostty/VS
|
|
23
|
+
* Code), so it wins whenever present.
|
|
24
|
+
* - iterm — `$ITERM_SESSION_ID` (`w<n>t<n>p<n>:<UUID>`): the exact iTerm2
|
|
25
|
+
* split, addressable by its session UUID via `tell session id …`
|
|
26
|
+
* (focus-safe, no `activate`). Used when there's no tmux above it.
|
|
27
|
+
*
|
|
28
|
+
* Both are inherited env vars, so the split the agent lives in is recovered with
|
|
29
|
+
* zero cooperation from the agent. Inherited/ignored stdin with neither rail =>
|
|
30
|
+
* null (not env-addressable — resolution may still find an IDE rail off disk,
|
|
31
|
+
* see src/lib/terminal/resolve.ts). The feed uses `reply` to decide whether to
|
|
32
|
+
* show a Send box; the resolver turns it into a concrete inject target (Gap 2,
|
|
33
|
+
* src/lib/terminal/inject.ts).
|
|
21
34
|
*/
|
|
22
35
|
import * as os from 'os';
|
|
23
36
|
import { execFile } from 'child_process';
|
|
@@ -32,7 +45,20 @@ export const PROVENANCE_ENV_KEYS = [
|
|
|
32
45
|
'TMUX_PANE',
|
|
33
46
|
'TERM_PROGRAM',
|
|
34
47
|
'STY',
|
|
48
|
+
'ITERM_SESSION_ID',
|
|
35
49
|
];
|
|
50
|
+
/**
|
|
51
|
+
* Extract the iTerm2 session UUID from `$ITERM_SESSION_ID`, whose value is
|
|
52
|
+
* `w<window>t<tab>p<pane>:<UUID>` — the `:`-suffix is exactly what iTerm2's
|
|
53
|
+
* `id of session` returns, so it's the address for `tell session id …`. Returns
|
|
54
|
+
* undefined for an empty/absent value; tolerates a bare value with no `:`.
|
|
55
|
+
*/
|
|
56
|
+
export function parseItermSession(value) {
|
|
57
|
+
if (!value)
|
|
58
|
+
return undefined;
|
|
59
|
+
const uuid = value.includes(':') ? value.slice(value.lastIndexOf(':') + 1) : value;
|
|
60
|
+
return uuid.trim() || undefined;
|
|
61
|
+
}
|
|
36
62
|
/** Parse the NUL-separated body of /proc/<pid>/environ into a plain object. */
|
|
37
63
|
export function parseProcEnviron(buf) {
|
|
38
64
|
const env = {};
|
|
@@ -99,13 +125,18 @@ export function deriveProvenance(env, hostname) {
|
|
|
99
125
|
else if (env.STY) {
|
|
100
126
|
mux = { kind: 'screen', session: env.STY };
|
|
101
127
|
}
|
|
102
|
-
//
|
|
103
|
-
//
|
|
104
|
-
//
|
|
105
|
-
//
|
|
128
|
+
// Env-addressable rails, in precedence order. A tmux pane wins because it can
|
|
129
|
+
// live inside any host app (`tmux send-keys -t <pane>` reaches it regardless of
|
|
130
|
+
// whether iTerm/Ghostty/VS Code is above it). Absent tmux, an iTerm2 split is
|
|
131
|
+
// addressable by its session UUID. Everything else (inherited stdin from
|
|
132
|
+
// `agents run`, ignored stdin from teams, a plain VS Code integrated terminal)
|
|
133
|
+
// is not env-addressable — the resolver may still find an IDE rail off disk.
|
|
134
|
+
const itermSession = parseItermSession(env.ITERM_SESSION_ID);
|
|
106
135
|
const reply = mux?.kind === 'tmux' && mux.pane
|
|
107
136
|
? { rail: 'tmux', target: mux.pane, socket: mux.socket }
|
|
108
|
-
:
|
|
137
|
+
: itermSession
|
|
138
|
+
? { rail: 'iterm', session: itermSession }
|
|
139
|
+
: null;
|
|
109
140
|
return {
|
|
110
141
|
host: hostname,
|
|
111
142
|
transport: ssh ? 'ssh' : 'local',
|
|
@@ -21,8 +21,12 @@ export function formatRelativeTime(isoTimestamp) {
|
|
|
21
21
|
return `${diffHrs} hour${diffHrs === 1 ? '' : 's'} ago`;
|
|
22
22
|
if (diffDays < 7)
|
|
23
23
|
return `${diffDays} day${diffDays === 1 ? '' : 's'} ago`;
|
|
24
|
-
// Older: show date
|
|
24
|
+
// Older: show the date. Append a 2-digit year for anything outside the current
|
|
25
|
+
// year so "Jun 28" is never ambiguous across years (e.g. "Jun 28 '25").
|
|
25
26
|
const d = new Date(then);
|
|
26
27
|
const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
|
|
27
|
-
|
|
28
|
+
const label = `${months[d.getMonth()]} ${d.getDate()}`;
|
|
29
|
+
return d.getFullYear() === new Date(now).getFullYear()
|
|
30
|
+
? label
|
|
31
|
+
: `${label} '${String(d.getFullYear()).slice(-2)}`;
|
|
28
32
|
}
|
|
@@ -18,7 +18,9 @@ import { spawn } from 'child_process';
|
|
|
18
18
|
import chalk from 'chalk';
|
|
19
19
|
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
20
20
|
import { sshTargetFor } from '../devices/connect.js';
|
|
21
|
+
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
21
22
|
import { loadDevices } from '../devices/registry.js';
|
|
23
|
+
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
22
24
|
import { machineId, normalizeHost } from './sync/config.js';
|
|
23
25
|
/** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
|
|
24
26
|
* reachable-but-slow remote still answers before we give up. */
|
|
@@ -29,8 +31,16 @@ const REMOTE_TIMEOUT_MS = 12_000;
|
|
|
29
31
|
* on an unknown option. A remote new enough to fan out reads it and stays local.
|
|
30
32
|
*/
|
|
31
33
|
export const NO_FANOUT_ENV = 'AGENTS_SESSIONS_LOCAL';
|
|
32
|
-
/** The command run on each peer: answer for itself, as JSON, without recursing.
|
|
33
|
-
|
|
34
|
+
/** The command run on each peer: answer for itself, as JSON, without recursing.
|
|
35
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
36
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`. */
|
|
37
|
+
function remoteActiveCommand(os) {
|
|
38
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
39
|
+
return buildWindowsAgentsCommand({
|
|
40
|
+
args: ['sessions', '--active', '--json'],
|
|
41
|
+
env: { [NO_FANOUT_ENV]: '1' },
|
|
42
|
+
});
|
|
43
|
+
}
|
|
34
44
|
const inner = `${NO_FANOUT_ENV}=1 agents sessions --active --json`;
|
|
35
45
|
return `bash -lc ${shellQuote(inner)}`;
|
|
36
46
|
}
|
|
@@ -80,8 +90,8 @@ function sshCapture(target, remoteCmd, timeoutMs) {
|
|
|
80
90
|
child.on('close', (code) => done(code));
|
|
81
91
|
});
|
|
82
92
|
}
|
|
83
|
-
async function fetchByTarget(target, machine, display) {
|
|
84
|
-
const { code, stdout } = await sshCapture(target, remoteActiveCommand(), REMOTE_TIMEOUT_MS);
|
|
93
|
+
async function fetchByTarget(target, machine, display, os) {
|
|
94
|
+
const { code, stdout } = await sshCapture(target, remoteActiveCommand(os), REMOTE_TIMEOUT_MS);
|
|
85
95
|
if (code !== 0) {
|
|
86
96
|
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
87
97
|
return [];
|
|
@@ -98,17 +108,9 @@ export async function gatherRemoteActive(hosts) {
|
|
|
98
108
|
const self = machineId();
|
|
99
109
|
const targets = [];
|
|
100
110
|
if (hosts && hosts.length > 0) {
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
catch {
|
|
106
|
-
process.stderr.write(chalk.gray(` ${h}: not a valid ssh target — skipped\n`));
|
|
107
|
-
continue;
|
|
108
|
-
}
|
|
109
|
-
const bareHost = h.split('@').pop() || h;
|
|
110
|
-
targets.push({ target: h, machine: normalizeHost(bareHost), name: h });
|
|
111
|
-
}
|
|
111
|
+
// Resolve each token through the device registry so an explicit --host/--device
|
|
112
|
+
// dials the exact same address (and machine id) as the auto-discovery sweep.
|
|
113
|
+
targets.push(...await resolveExplicitTargets(hosts));
|
|
112
114
|
}
|
|
113
115
|
else {
|
|
114
116
|
let reg;
|
|
@@ -129,13 +131,13 @@ export async function gatherRemoteActive(hosts) {
|
|
|
129
131
|
if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
|
|
130
132
|
continue;
|
|
131
133
|
try {
|
|
132
|
-
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name });
|
|
134
|
+
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
133
135
|
}
|
|
134
136
|
catch {
|
|
135
137
|
// No address on the profile — nothing to dial; skip silently.
|
|
136
138
|
}
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
|
-
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name)));
|
|
141
|
+
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, t.os)));
|
|
140
142
|
return { sessions: results.flat(), deviceCount: targets.length };
|
|
141
143
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import type { SessionMeta } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
4
|
+
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
5
|
+
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
6
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
7
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function remoteListCommand(forwardedArgs: string[], os?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
12
|
+
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
13
|
+
* a non-array yields `[]`, and non-object entries are dropped rather than
|
|
14
|
+
* throwing. The `machine` we dialed always wins over any value the peer set on
|
|
15
|
+
* its own rows, so grouping keys off the computer we asked. Exported for unit
|
|
16
|
+
* testing without a live tailnet.
|
|
17
|
+
*/
|
|
18
|
+
export declare function parseRemoteList(stdout: string, machine: string): SessionMeta[];
|
|
19
|
+
export interface RemoteListResult {
|
|
20
|
+
sessions: SessionMeta[];
|
|
21
|
+
/** How many peer machines we attempted to reach (drives the empty-fleet tip). */
|
|
22
|
+
deviceCount: number;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
26
|
+
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
27
|
+
* online devices from `ag devices`, excluding this machine and any without an
|
|
28
|
+
* address. `forwardedArgs` are the caller's own sessions args (query + filters,
|
|
29
|
+
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
30
|
+
*/
|
|
31
|
+
export declare function gatherRemoteList(forwardedArgs: string[], hosts?: string[]): Promise<RemoteListResult>;
|
|
32
|
+
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
33
|
+
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
34
|
+
* undefined when no registered device with an address matches. */
|
|
35
|
+
export declare function resolvePeerTarget(machine: string): Promise<{
|
|
36
|
+
target: string;
|
|
37
|
+
os?: string;
|
|
38
|
+
} | undefined>;
|
|
39
|
+
/**
|
|
40
|
+
* Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
|
|
41
|
+
* stdio). `args` is the full arg vector after the binary — callers pass e.g.
|
|
42
|
+
* `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
|
|
43
|
+
* picked session lives on another machine: its transcript and agent binary are
|
|
44
|
+
* there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
|
|
45
|
+
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
46
|
+
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
47
|
+
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
48
|
+
*/
|
|
49
|
+
export declare function runOnPeer(args: string[], machine: string, opts?: {
|
|
50
|
+
tty?: boolean;
|
|
51
|
+
}): Promise<'ok' | 'no-target'>;
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cross-machine fan-out for the default `agents sessions` listing.
|
|
3
|
+
*
|
|
4
|
+
* `discoverSessions()` only scans the local disk. To browse the whole fleet in
|
|
5
|
+
* one list — without syncing anything — we run `agents sessions <same query>
|
|
6
|
+
* --json` on each peer over SSH and merge the parsed `SessionMeta[]`, tagging
|
|
7
|
+
* every row with the machine it came from so the picker/table can label and
|
|
8
|
+
* group by computer.
|
|
9
|
+
*
|
|
10
|
+
* This is the browse-listing sibling of `remote-active.ts` (which fans out
|
|
11
|
+
* `--active`): same transport, same device set, same recursion guard. The peer
|
|
12
|
+
* runs with `AGENTS_SESSIONS_LOCAL=1` so it answers only for itself and the
|
|
13
|
+
* sweep never recurses. A dead or slow host is skipped with a stderr note,
|
|
14
|
+
* never fatal — one asleep laptop must not blank the list.
|
|
15
|
+
*/
|
|
16
|
+
import { spawn } from 'child_process';
|
|
17
|
+
import chalk from 'chalk';
|
|
18
|
+
import { SSH_OPTS, controlOpts, assertValidSshTarget, shellQuote } from '../ssh-exec.js';
|
|
19
|
+
import { sshTargetFor } from '../devices/connect.js';
|
|
20
|
+
import { resolveExplicitTargets } from '../devices/resolve-target.js';
|
|
21
|
+
import { loadDevices } from '../devices/registry.js';
|
|
22
|
+
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
23
|
+
import { machineId, normalizeHost } from './sync/config.js';
|
|
24
|
+
import { NO_FANOUT_ENV } from './remote-active.js';
|
|
25
|
+
import { terminalWidth } from './width.js';
|
|
26
|
+
/** Per-host SSH budget. Slightly above SSH_OPTS' ConnectTimeout=10 so a
|
|
27
|
+
* reachable-but-slow remote still answers before we give up. */
|
|
28
|
+
const REMOTE_TIMEOUT_MS = 12_000;
|
|
29
|
+
/**
|
|
30
|
+
* The command run on each peer: answer for itself, as JSON, without recursing.
|
|
31
|
+
* `forwardedArgs` carry the caller's own query/filters (already including the
|
|
32
|
+
* leading `sessions` and a `--json`) so each peer returns a comparable slice.
|
|
33
|
+
* A Windows peer gets a PowerShell invocation (ssh lands in cmd.exe/PowerShell
|
|
34
|
+
* there, where `bash -lc` is not a command); every other OS keeps `bash -lc`.
|
|
35
|
+
*/
|
|
36
|
+
export function remoteListCommand(forwardedArgs, os) {
|
|
37
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
38
|
+
return buildWindowsAgentsCommand({
|
|
39
|
+
args: forwardedArgs,
|
|
40
|
+
env: { [NO_FANOUT_ENV]: '1' },
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
const inner = [`${NO_FANOUT_ENV}=1`, 'agents', ...forwardedArgs].map((t, i) => i === 0 ? t : shellQuote(t)).join(' ');
|
|
44
|
+
return `bash -lc ${shellQuote(inner)}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Parse a peer's `sessions --json` stdout into `SessionMeta[]`, tagging each
|
|
48
|
+
* with `machine`. Defensive against version skew / partial output: non-JSON or
|
|
49
|
+
* a non-array yields `[]`, and non-object entries are dropped rather than
|
|
50
|
+
* throwing. The `machine` we dialed always wins over any value the peer set on
|
|
51
|
+
* its own rows, so grouping keys off the computer we asked. Exported for unit
|
|
52
|
+
* testing without a live tailnet.
|
|
53
|
+
*/
|
|
54
|
+
export function parseRemoteList(stdout, machine) {
|
|
55
|
+
let parsed;
|
|
56
|
+
try {
|
|
57
|
+
parsed = JSON.parse(stdout);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return [];
|
|
61
|
+
}
|
|
62
|
+
if (!Array.isArray(parsed))
|
|
63
|
+
return [];
|
|
64
|
+
const out = [];
|
|
65
|
+
for (const x of parsed) {
|
|
66
|
+
if (x && typeof x === 'object' && !Array.isArray(x)) {
|
|
67
|
+
// `_remote` marks these as living on the peer's disk (not a local mirror),
|
|
68
|
+
// so the picker routes read/resume back over SSH instead of the local FS.
|
|
69
|
+
out.push({ ...x, machine, _remote: true });
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return out;
|
|
73
|
+
}
|
|
74
|
+
/** Run one remote `agents sessions … --json` and capture stdout. Resolves
|
|
75
|
+
* `{ code: null }` on spawn error or timeout (host treated as dead). */
|
|
76
|
+
function sshCapture(target, remoteCmd, timeoutMs) {
|
|
77
|
+
assertValidSshTarget(target);
|
|
78
|
+
return new Promise((resolve) => {
|
|
79
|
+
const args = [...SSH_OPTS, ...controlOpts(), target, remoteCmd];
|
|
80
|
+
const child = spawn('ssh', args, { stdio: ['ignore', 'pipe', 'ignore'] });
|
|
81
|
+
let stdout = '';
|
|
82
|
+
let settled = false;
|
|
83
|
+
const done = (code) => {
|
|
84
|
+
if (settled)
|
|
85
|
+
return;
|
|
86
|
+
settled = true;
|
|
87
|
+
clearTimeout(timer);
|
|
88
|
+
resolve({ code, stdout });
|
|
89
|
+
};
|
|
90
|
+
const timer = setTimeout(() => { child.kill('SIGKILL'); done(null); }, timeoutMs);
|
|
91
|
+
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
|
92
|
+
child.on('error', () => done(null));
|
|
93
|
+
child.on('close', (code) => done(code));
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
async function fetchByTarget(target, machine, display, forwardedArgs, os) {
|
|
97
|
+
const { code, stdout } = await sshCapture(target, remoteListCommand(forwardedArgs, os), REMOTE_TIMEOUT_MS);
|
|
98
|
+
if (code !== 0) {
|
|
99
|
+
process.stderr.write(chalk.gray(` ${display}: unreachable or no agents CLI — skipped\n`));
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
return parseRemoteList(stdout, machine);
|
|
103
|
+
}
|
|
104
|
+
/**
|
|
105
|
+
* Gather listing sessions from other machines. With an explicit `hosts` list
|
|
106
|
+
* (from `--host`), fan out to exactly those. Otherwise sweep the registered,
|
|
107
|
+
* online devices from `ag devices`, excluding this machine and any without an
|
|
108
|
+
* address. `forwardedArgs` are the caller's own sessions args (query + filters,
|
|
109
|
+
* already `--json`) so every peer returns the same slice this machine asked for.
|
|
110
|
+
*/
|
|
111
|
+
export async function gatherRemoteList(forwardedArgs, hosts) {
|
|
112
|
+
const self = machineId();
|
|
113
|
+
const targets = [];
|
|
114
|
+
if (hosts && hosts.length > 0) {
|
|
115
|
+
// Resolve each token through the device registry so an explicit --host/--device
|
|
116
|
+
// dials the exact same address (and machine id) as the auto-discovery sweep.
|
|
117
|
+
targets.push(...await resolveExplicitTargets(hosts));
|
|
118
|
+
}
|
|
119
|
+
else {
|
|
120
|
+
let reg;
|
|
121
|
+
try {
|
|
122
|
+
reg = await loadDevices();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { sessions: [], deviceCount: 0 };
|
|
126
|
+
}
|
|
127
|
+
for (const d of Object.values(reg)) {
|
|
128
|
+
if (d.tailscale?.online !== true)
|
|
129
|
+
continue;
|
|
130
|
+
if (normalizeHost(d.name) === self)
|
|
131
|
+
continue;
|
|
132
|
+
// Only machines that can actually run the CLI. iOS/tablet nodes register as
|
|
133
|
+
// `unknown` platform and can never answer, so skip them rather than burn a
|
|
134
|
+
// full ConnectTimeout on each.
|
|
135
|
+
if (d.platform !== 'windows' && d.platform !== 'linux' && d.platform !== 'macos')
|
|
136
|
+
continue;
|
|
137
|
+
try {
|
|
138
|
+
targets.push({ target: sshTargetFor(d), machine: normalizeHost(d.name), name: d.name, os: d.platform });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
// No address on the profile — nothing to dial; skip silently.
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
const results = await Promise.all(targets.map((t) => fetchByTarget(t.target, t.machine, t.name, forwardedArgs, t.os)));
|
|
146
|
+
return { sessions: results.flat(), deviceCount: targets.length };
|
|
147
|
+
}
|
|
148
|
+
/** Resolve a peer's SSH target (and OS) from the device registry by its
|
|
149
|
+
* normalized machine id — the same id the fan-out tags rows with. Returns
|
|
150
|
+
* undefined when no registered device with an address matches. */
|
|
151
|
+
export async function resolvePeerTarget(machine) {
|
|
152
|
+
let reg;
|
|
153
|
+
try {
|
|
154
|
+
reg = await loadDevices();
|
|
155
|
+
}
|
|
156
|
+
catch {
|
|
157
|
+
return undefined;
|
|
158
|
+
}
|
|
159
|
+
for (const d of Object.values(reg)) {
|
|
160
|
+
if (normalizeHost(d.name) !== machine)
|
|
161
|
+
continue;
|
|
162
|
+
try {
|
|
163
|
+
return { target: sshTargetFor(d), os: d.platform };
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return undefined; // matched the machine, but it has no address to dial
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Run `agents <args>` ON a peer over SSH, attached to this terminal (inherited
|
|
173
|
+
* stdio). `args` is the full arg vector after the binary — callers pass e.g.
|
|
174
|
+
* `['sessions', id, '--markdown']` or `['sessions', 'resume', id]`. Used when a
|
|
175
|
+
* picked session lives on another machine: its transcript and agent binary are
|
|
176
|
+
* there, so both reading (no TTY) and resuming (TTY) must execute on the peer —
|
|
177
|
+
* not via a local `--host` hop, which would discover locally and dead-end for a
|
|
178
|
+
* session that exists only on the peer. Resolves 'no-target' when the machine
|
|
179
|
+
* isn't a dialable registered device; the caller surfaces a clear message.
|
|
180
|
+
*/
|
|
181
|
+
export async function runOnPeer(args, machine, opts = {}) {
|
|
182
|
+
const peer = await resolvePeerTarget(machine);
|
|
183
|
+
if (!peer)
|
|
184
|
+
return 'no-target';
|
|
185
|
+
assertValidSshTarget(peer.target); // registry-sourced, but validate like the fan-out does
|
|
186
|
+
const cols = terminalWidth();
|
|
187
|
+
const remoteCmd = remoteShellFor(peer.os) === 'powershell'
|
|
188
|
+
? buildWindowsAgentsCommand({ args, env: cols > 0 ? { COLUMNS: String(cols) } : undefined })
|
|
189
|
+
: `bash -lc ${shellQuote((cols > 0 ? [`COLUMNS=${cols}`] : []).concat(['agents', ...args].map(shellQuote)).join(' '))}`;
|
|
190
|
+
const sshArgs = [...SSH_OPTS, ...controlOpts()];
|
|
191
|
+
if (opts.tty)
|
|
192
|
+
sshArgs.push('-tt'); // force a PTY so the resumed agent is interactive
|
|
193
|
+
sshArgs.push(peer.target, remoteCmd);
|
|
194
|
+
return new Promise((resolve) => {
|
|
195
|
+
const child = spawn('ssh', sshArgs, { stdio: 'inherit' });
|
|
196
|
+
// ssh prints its own connection errors to the inherited stderr; a spawn
|
|
197
|
+
// failure (e.g. ssh not on PATH) has no such output, so name it. Either way
|
|
198
|
+
// we resolve once it settles so the picker flow completes.
|
|
199
|
+
child.on('error', (err) => {
|
|
200
|
+
process.stderr.write(chalk.red(`Failed to reach ${machine}: ${err?.message ?? 'ssh failed to launch'}\n`));
|
|
201
|
+
resolve('ok');
|
|
202
|
+
});
|
|
203
|
+
child.on('close', () => resolve('ok'));
|
|
204
|
+
});
|
|
205
|
+
}
|
|
@@ -22,8 +22,14 @@ export declare function buildForwardedArgs(argv: string[], hosts?: Set<string>):
|
|
|
22
22
|
* Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
|
|
23
23
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
24
24
|
* quoted again so it survives `bash -lc <...>`.
|
|
25
|
+
*
|
|
26
|
+
* `os` selects the remote shell: a Windows host gets a PowerShell invocation
|
|
27
|
+
* (ssh lands in cmd.exe/PowerShell there, where `bash -lc` does not exist);
|
|
28
|
+
* anything else — including unknown/absent — keeps the POSIX form unchanged.
|
|
29
|
+
* The forwarded terminal width rides across as an env var either way so the
|
|
30
|
+
* remote renders its table to the local screen.
|
|
25
31
|
*/
|
|
26
|
-
export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number): string;
|
|
32
|
+
export declare function buildRemoteCommand(forwardedArgs: string[], columns?: number, os?: string): string;
|
|
27
33
|
/** The four outcomes of one `ssh <host> agents sessions …` invocation. */
|
|
28
34
|
export type SshOutcome = 'ok' | 'unreachable' | 'query-failed' | 'spawn-error';
|
|
29
35
|
/**
|
|
@@ -28,6 +28,8 @@ import { createHash } from 'crypto';
|
|
|
28
28
|
import chalk from 'chalk';
|
|
29
29
|
import { getCacheDir } from '../state.js';
|
|
30
30
|
import { SSH_OPTS, controlOpts } from '../ssh-exec.js';
|
|
31
|
+
import { remoteShellFor, buildWindowsAgentsCommand } from '../hosts/remote-cmd.js';
|
|
32
|
+
import { resolveRemoteOsSync } from '../hosts/remote-os.js';
|
|
31
33
|
import { formatRelativeTime } from './relative-time.js';
|
|
32
34
|
import { terminalWidth } from './width.js';
|
|
33
35
|
/**
|
|
@@ -61,10 +63,12 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
61
63
|
const out = [];
|
|
62
64
|
for (let i = 0; i < args.length; i++) {
|
|
63
65
|
const a = args[i];
|
|
64
|
-
if (a === '--host' || a === '-H') {
|
|
66
|
+
if (a === '--host' || a === '-H' || a === '--device') {
|
|
65
67
|
// Commander's `<target...>` variadic accepts both `--host a --host b` and
|
|
66
68
|
// `--host a b` — consume every consecutive token that is a known host so
|
|
67
69
|
// the variadic form doesn't leak the extra hosts into the remote argv.
|
|
70
|
+
// `--device` is an alias for `--host` (its values are merged into the same
|
|
71
|
+
// host set), so strip it the same way — else the peer would re-fan-out.
|
|
68
72
|
// Fall back to consuming the single next token when we have no host set
|
|
69
73
|
// (e.g. malformed input) so the flag value never leaks either way.
|
|
70
74
|
if (hosts.size > 0) {
|
|
@@ -76,7 +80,7 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
76
80
|
}
|
|
77
81
|
continue;
|
|
78
82
|
}
|
|
79
|
-
if (a.startsWith('--host=') || a.startsWith('-H='))
|
|
83
|
+
if (a.startsWith('--host=') || a.startsWith('-H=') || a.startsWith('--device='))
|
|
80
84
|
continue;
|
|
81
85
|
if (/^-H.+/.test(a))
|
|
82
86
|
continue; // glued short form: -Hyosemite-s1
|
|
@@ -88,8 +92,18 @@ export function buildForwardedArgs(argv, hosts = new Set()) {
|
|
|
88
92
|
* Build the single remote command string for `ssh <host> <cmd>`. Forwarded args
|
|
89
93
|
* are quoted for the inner login shell, then the whole `agents …` invocation is
|
|
90
94
|
* quoted again so it survives `bash -lc <...>`.
|
|
95
|
+
*
|
|
96
|
+
* `os` selects the remote shell: a Windows host gets a PowerShell invocation
|
|
97
|
+
* (ssh lands in cmd.exe/PowerShell there, where `bash -lc` does not exist);
|
|
98
|
+
* anything else — including unknown/absent — keeps the POSIX form unchanged.
|
|
99
|
+
* The forwarded terminal width rides across as an env var either way so the
|
|
100
|
+
* remote renders its table to the local screen.
|
|
91
101
|
*/
|
|
92
|
-
export function buildRemoteCommand(forwardedArgs, columns) {
|
|
102
|
+
export function buildRemoteCommand(forwardedArgs, columns, os) {
|
|
103
|
+
if (remoteShellFor(os) === 'powershell') {
|
|
104
|
+
const env = columns && columns > 0 ? { COLUMNS: String(columns) } : undefined;
|
|
105
|
+
return buildWindowsAgentsCommand({ args: forwardedArgs, env });
|
|
106
|
+
}
|
|
93
107
|
const inner = ['agents', ...forwardedArgs].map(shellQuote).join(' ');
|
|
94
108
|
// Forward the caller's terminal width so the remote renders the table to the
|
|
95
109
|
// local screen (over SSH the remote's own COLUMNS is unset/wrong). `VAR=val
|
|
@@ -176,12 +190,14 @@ export function runRemoteSessions(hosts, argv = process.argv) {
|
|
|
176
190
|
for (const host of hosts)
|
|
177
191
|
assertValidSshTarget(host); // fail fast on any bad target
|
|
178
192
|
const forwarded = buildForwardedArgs(argv, new Set(hosts));
|
|
179
|
-
const
|
|
193
|
+
const cols = terminalWidth();
|
|
180
194
|
const multi = hosts.length > 1;
|
|
181
195
|
let failures = 0;
|
|
182
196
|
for (const host of hosts) {
|
|
183
197
|
if (multi)
|
|
184
198
|
process.stdout.write(chalk.cyan(`\n── ${host} ──\n`));
|
|
199
|
+
// Per-host: a Windows peer needs a PowerShell command, POSIX peers `bash -lc`.
|
|
200
|
+
const remoteCmd = buildRemoteCommand(forwarded, cols, resolveRemoteOsSync(host));
|
|
185
201
|
const res = spawnSync('ssh', [...SSH_OPTS, ...controlOpts(), host, remoteCmd], {
|
|
186
202
|
encoding: 'utf8',
|
|
187
203
|
maxBuffer: 64 * 1024 * 1024,
|
|
@@ -5,6 +5,19 @@
|
|
|
5
5
|
*/
|
|
6
6
|
/** Secrets bundle holding the R2 credentials. */
|
|
7
7
|
export declare const SYNC_BUNDLE = "r2.backups";
|
|
8
|
+
/** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
|
|
9
|
+
export declare const SYNC_ENABLED_ENV = "AGENTS_SESSIONS_SYNC";
|
|
10
|
+
/** Durable, machine-local path holding the sync enable flag. */
|
|
11
|
+
export declare function syncStateFilePath(): string;
|
|
12
|
+
/**
|
|
13
|
+
* Whether automatic session sync is enabled on this machine. Defaults to true;
|
|
14
|
+
* an unrecognized env value falls through to the file; an absent/unreadable file
|
|
15
|
+
* falls through to the default. Read fresh every call (no memoization) so a
|
|
16
|
+
* `--disable` takes effect on the daemon's next ~90s cycle without a restart.
|
|
17
|
+
*/
|
|
18
|
+
export declare function isSyncEnabled(): boolean;
|
|
19
|
+
/** Persist the machine-local sync enable flag (durable across cache wipes). */
|
|
20
|
+
export declare function setSyncEnabled(enabled: boolean): void;
|
|
8
21
|
export interface R2Config {
|
|
9
22
|
accountId: string;
|
|
10
23
|
bucket: string;
|
|
@@ -33,18 +46,4 @@ export declare function loadR2Config(): R2Config;
|
|
|
33
46
|
* does not re-storm every cycle. `now` is injectable for tests.
|
|
34
47
|
*/
|
|
35
48
|
export declare function isSyncConfigured(now?: number): boolean;
|
|
36
|
-
|
|
37
|
-
* Normalize a raw hostname into a stable device id: first label only,
|
|
38
|
-
* lowercased, non-alphanumerics collapsed to hyphens. `zion.tail…ts.net` and
|
|
39
|
-
* `ZION` both become `zion`. The single source for this transform — machineId()
|
|
40
|
-
* and cross-machine session grouping must agree or the local machine won't
|
|
41
|
-
* match its own registry key.
|
|
42
|
-
*/
|
|
43
|
-
export declare function normalizeHost(raw: string): string;
|
|
44
|
-
/**
|
|
45
|
-
* This machine's stable, human-readable id, used as its R2 prefix and mirror
|
|
46
|
-
* directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already
|
|
47
|
-
* unique and readable; we lowercase and strip any domain suffix. Overridable
|
|
48
|
-
* via AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
|
|
49
|
-
*/
|
|
50
|
-
export declare function machineId(): string;
|
|
49
|
+
export { machineId, normalizeHost } from '../../machine-id.js';
|
|
@@ -3,10 +3,65 @@
|
|
|
3
3
|
* machine's stable identity. Credentials come from the `r2.backups` secrets
|
|
4
4
|
* bundle (OS keychain on macOS, libsecret on Linux) — never from env or disk.
|
|
5
5
|
*/
|
|
6
|
-
import * as
|
|
6
|
+
import * as fs from 'fs';
|
|
7
|
+
import * as path from 'path';
|
|
7
8
|
import { readAndResolveBundleEnv } from '../../secrets/bundles.js';
|
|
9
|
+
import { getHistoryDir } from '../../state.js';
|
|
8
10
|
/** Secrets bundle holding the R2 credentials. */
|
|
9
11
|
export const SYNC_BUNDLE = 'r2.backups';
|
|
12
|
+
// ── Enable / disable switch ─────────────────────────────────────────────────
|
|
13
|
+
// Whether the daemon's automatic cross-machine sync (and `agents sync
|
|
14
|
+
// --sessions`) may run on THIS machine. Independent of credential presence
|
|
15
|
+
// (isSyncConfigured): a machine can hold valid R2 creds yet still opt out of the
|
|
16
|
+
// background push/pull — e.g. when on-demand `agents sessions --host` is
|
|
17
|
+
// preferred over the ad-hoc R2 mirror. Manual `agents sessions sync` is an
|
|
18
|
+
// explicit user action and is deliberately NOT gated by this switch.
|
|
19
|
+
//
|
|
20
|
+
// Resolution order: the AGENTS_SESSIONS_SYNC env var (a recognized on/off value
|
|
21
|
+
// wins outright, for ad-hoc overrides and tests), then a durable machine-local
|
|
22
|
+
// flag file, then the default (enabled). The flag lives in the durable
|
|
23
|
+
// ~/.agents/.history tree — NOT .cache — so a cache wipe can never silently
|
|
24
|
+
// re-enable a sync the operator turned off.
|
|
25
|
+
/** Env var that overrides the persisted enable flag (on/off/true/false/1/0/yes/no). */
|
|
26
|
+
export const SYNC_ENABLED_ENV = 'AGENTS_SESSIONS_SYNC';
|
|
27
|
+
const SYNC_ENABLED_FILE = 'sessions-sync.json';
|
|
28
|
+
const OFF_VALUES = new Set(['0', 'off', 'false', 'no', 'disabled']);
|
|
29
|
+
const ON_VALUES = new Set(['1', 'on', 'true', 'yes', 'enabled']);
|
|
30
|
+
/** Durable, machine-local path holding the sync enable flag. */
|
|
31
|
+
export function syncStateFilePath() {
|
|
32
|
+
return path.join(getHistoryDir(), SYNC_ENABLED_FILE);
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Whether automatic session sync is enabled on this machine. Defaults to true;
|
|
36
|
+
* an unrecognized env value falls through to the file; an absent/unreadable file
|
|
37
|
+
* falls through to the default. Read fresh every call (no memoization) so a
|
|
38
|
+
* `--disable` takes effect on the daemon's next ~90s cycle without a restart.
|
|
39
|
+
*/
|
|
40
|
+
export function isSyncEnabled() {
|
|
41
|
+
const envRaw = process.env[SYNC_ENABLED_ENV]?.trim().toLowerCase();
|
|
42
|
+
if (envRaw) {
|
|
43
|
+
if (OFF_VALUES.has(envRaw))
|
|
44
|
+
return false;
|
|
45
|
+
if (ON_VALUES.has(envRaw))
|
|
46
|
+
return true;
|
|
47
|
+
// Unrecognized value: ignore and consult the persisted flag.
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
const parsed = JSON.parse(fs.readFileSync(syncStateFilePath(), 'utf-8'));
|
|
51
|
+
if (parsed && typeof parsed.enabled === 'boolean')
|
|
52
|
+
return parsed.enabled;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
// Absent or unreadable → default enabled.
|
|
56
|
+
}
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
/** Persist the machine-local sync enable flag (durable across cache wipes). */
|
|
60
|
+
export function setSyncEnabled(enabled) {
|
|
61
|
+
const p = syncStateFilePath();
|
|
62
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
63
|
+
fs.writeFileSync(p, JSON.stringify({ enabled }, null, 2) + '\n', 'utf-8');
|
|
64
|
+
}
|
|
10
65
|
/**
|
|
11
66
|
* Resolve R2 credentials from the `r2.backups` bundle. Throws a clear,
|
|
12
67
|
* actionable error if the bundle or any key is missing — sync cannot proceed
|
|
@@ -93,22 +148,7 @@ export function isSyncConfigured(now = Date.now()) {
|
|
|
93
148
|
return false;
|
|
94
149
|
}
|
|
95
150
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
* and cross-machine session grouping must agree or the local machine won't
|
|
101
|
-
* match its own registry key.
|
|
102
|
-
*/
|
|
103
|
-
export function normalizeHost(raw) {
|
|
104
|
-
return raw.split('.')[0].trim().toLowerCase().replace(/[^a-z0-9_-]/g, '-') || 'unknown';
|
|
105
|
-
}
|
|
106
|
-
/**
|
|
107
|
-
* This machine's stable, human-readable id, used as its R2 prefix and mirror
|
|
108
|
-
* directory name. Tailnet hostnames (zion, yosemite-s0, mac-mini) are already
|
|
109
|
-
* unique and readable; we lowercase and strip any domain suffix. Overridable
|
|
110
|
-
* via AGENTS_SYNC_MACHINE_ID for tests and unusual setups.
|
|
111
|
-
*/
|
|
112
|
-
export function machineId() {
|
|
113
|
-
return normalizeHost(process.env.AGENTS_SYNC_MACHINE_ID || os.hostname());
|
|
114
|
-
}
|
|
151
|
+
// machineId() and normalizeHost() now live in the dependency-free leaf
|
|
152
|
+
// ../../machine-id.ts so low-level modules (state.ts) can use them without an
|
|
153
|
+
// import cycle. Re-exported here for existing importers.
|
|
154
|
+
export { machineId, normalizeHost } from '../../machine-id.js';
|