@phnx-labs/agents-cli 1.20.62 → 1.20.64
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 +62 -0
- package/README.md +19 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/browser.js +13 -3
- package/dist/commands/exec.js +96 -28
- package/dist/commands/feed.d.ts +4 -0
- package/dist/commands/feed.js +27 -8
- package/dist/commands/funnel.d.ts +5 -0
- package/dist/commands/funnel.js +62 -0
- package/dist/commands/hosts.js +42 -0
- package/dist/commands/lease.d.ts +23 -0
- package/dist/commands/lease.js +201 -0
- package/dist/commands/mailboxes.d.ts +20 -0
- package/dist/commands/mailboxes.js +390 -0
- package/dist/commands/repo.d.ts +4 -4
- package/dist/commands/repo.js +30 -19
- package/dist/commands/routines.js +92 -29
- package/dist/commands/sessions-export.d.ts +2 -0
- package/dist/commands/sessions-export.js +279 -0
- package/dist/commands/sessions-import.d.ts +2 -0
- package/dist/commands/sessions-import.js +230 -0
- package/dist/commands/sessions-sync.d.ts +1 -0
- package/dist/commands/sessions-sync.js +16 -2
- package/dist/commands/sessions.js +12 -1
- package/dist/commands/setup.js +9 -0
- package/dist/commands/ssh.js +170 -5
- package/dist/commands/sync-provision.d.ts +23 -0
- package/dist/commands/sync-provision.js +107 -0
- package/dist/commands/usage.d.ts +2 -0
- package/dist/commands/usage.js +7 -2
- package/dist/commands/view.d.ts +1 -1
- package/dist/commands/webhook.d.ts +9 -0
- package/dist/commands/webhook.js +93 -0
- package/dist/index.js +7 -2
- package/dist/lib/agents.d.ts +44 -0
- package/dist/lib/agents.js +85 -35
- package/dist/lib/browser/drivers/ssh.js +19 -2
- package/dist/lib/browser/ipc.js +5 -4
- package/dist/lib/browser/profiles.d.ts +13 -0
- package/dist/lib/browser/profiles.js +17 -0
- package/dist/lib/browser/service.d.ts +12 -1
- package/dist/lib/browser/service.js +48 -13
- package/dist/lib/browser/sessions-list.d.ts +40 -0
- package/dist/lib/browser/sessions-list.js +190 -0
- package/dist/lib/comms-render.d.ts +37 -0
- package/dist/lib/comms-render.js +89 -0
- package/dist/lib/crabbox/cli.d.ts +72 -0
- package/dist/lib/crabbox/cli.js +158 -9
- package/dist/lib/crabbox/runtimes.d.ts +13 -0
- package/dist/lib/crabbox/runtimes.js +24 -0
- package/dist/lib/daemon.js +8 -1
- package/dist/lib/devices/fleet.d.ts +62 -0
- package/dist/lib/devices/fleet.js +128 -0
- package/dist/lib/devices/health.d.ts +77 -0
- package/dist/lib/devices/health.js +186 -0
- package/dist/lib/funnel.d.ts +5 -0
- package/dist/lib/funnel.js +23 -0
- package/dist/lib/git.d.ts +21 -5
- package/dist/lib/git.js +64 -14
- package/dist/lib/hosts/credentials.d.ts +28 -0
- package/dist/lib/hosts/credentials.js +48 -0
- package/dist/lib/hosts/dispatch.d.ts +25 -0
- package/dist/lib/hosts/dispatch.js +68 -2
- package/dist/lib/hosts/passthrough.d.ts +13 -10
- package/dist/lib/hosts/passthrough.js +119 -29
- package/dist/lib/mailbox-gc.js +4 -16
- package/dist/lib/mailbox.d.ts +39 -0
- package/dist/lib/mailbox.js +112 -0
- package/dist/lib/migrate.d.ts +12 -0
- package/dist/lib/migrate.js +55 -1
- package/dist/lib/paths.d.ts +13 -0
- package/dist/lib/paths.js +26 -4
- package/dist/lib/routines.d.ts +50 -12
- package/dist/lib/routines.js +82 -27
- package/dist/lib/runner.js +255 -13
- package/dist/lib/sandbox.d.ts +9 -1
- package/dist/lib/sandbox.js +11 -2
- package/dist/lib/session/bundle.d.ts +150 -0
- package/dist/lib/session/bundle.js +189 -0
- package/dist/lib/session/remote-bundle.d.ts +12 -0
- package/dist/lib/session/remote-bundle.js +61 -0
- package/dist/lib/session/sync/agents.d.ts +56 -6
- package/dist/lib/session/sync/agents.js +0 -0
- package/dist/lib/session/sync/config.d.ts +8 -0
- package/dist/lib/session/sync/config.js +6 -1
- package/dist/lib/session/sync/manifest.d.ts +14 -3
- package/dist/lib/session/sync/manifest.js +4 -0
- package/dist/lib/session/sync/provision.d.ts +49 -0
- package/dist/lib/session/sync/provision.js +91 -0
- package/dist/lib/session/sync/sync.d.ts +26 -2
- package/dist/lib/session/sync/sync.js +192 -69
- package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
- package/dist/lib/session/sync/transcript-crypto.js +147 -0
- package/dist/lib/ssh-tunnel.js +13 -1
- package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
- package/dist/lib/staleness/detectors/subagents.js +5 -192
- package/dist/lib/staleness/writers/subagents.d.ts +10 -0
- package/dist/lib/staleness/writers/subagents.js +11 -102
- package/dist/lib/startup/command-registry.d.ts +4 -0
- package/dist/lib/startup/command-registry.js +16 -0
- package/dist/lib/state.d.ts +10 -2
- package/dist/lib/state.js +14 -2
- package/dist/lib/subagents-registry.d.ts +85 -0
- package/dist/lib/subagents-registry.js +393 -0
- package/dist/lib/subagents.d.ts +8 -8
- package/dist/lib/subagents.js +32 -663
- package/dist/lib/sync-umbrella.d.ts +1 -0
- package/dist/lib/sync-umbrella.js +14 -3
- package/dist/lib/triggers/webhook.d.ts +70 -27
- package/dist/lib/triggers/webhook.js +264 -43
- package/dist/lib/types.d.ts +9 -0
- package/dist/lib/usage.d.ts +42 -3
- package/dist/lib/usage.js +162 -22
- package/package.json +1 -1
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Device resource probing for `agents devices list`.
|
|
3
|
+
*
|
|
4
|
+
* One SSH round-trip per device gathers load average, memory pressure, and core
|
|
5
|
+
* count (mac + linux), parsed into a {@link DeviceStats}. Probes run in parallel
|
|
6
|
+
* with a bounded timeout so the list stays responsive — a slow or hung box
|
|
7
|
+
* degrades to "no stats" instead of blocking the whole table.
|
|
8
|
+
*
|
|
9
|
+
* The parsers are pure and unit-tested (health.test.ts). They mirror the ones in
|
|
10
|
+
* the Factory extension (apps/factory/src/core/deviceHealth.ts) — kept as a
|
|
11
|
+
* separate copy on purpose: the CLI does not import across packages.
|
|
12
|
+
*/
|
|
13
|
+
import { execFile } from 'child_process';
|
|
14
|
+
import { buildSshInvocation, writeAskpassShim } from './connect.js';
|
|
15
|
+
/** Default per-device probe budget. Short enough that the list never hangs on a
|
|
16
|
+
* wedged box, long enough for a cold relayed SSH handshake. */
|
|
17
|
+
export const PROBE_TIMEOUT_MS = 2_500;
|
|
18
|
+
const SEP = '---AGSTAT---';
|
|
19
|
+
/** One-shot remote snapshot: load, then memory (mac vm_stat else linux
|
|
20
|
+
* meminfo), then core count (linux nproc else mac hw.ncpu). */
|
|
21
|
+
const PROBE_SNIPPET = `uptime; echo ${SEP}; (vm_stat 2>/dev/null || cat /proc/meminfo 2>/dev/null); echo ${SEP}; (nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null)`;
|
|
22
|
+
/** Compact human byte size: 512M, 64G, 1.5T (binary units, ≤1 decimal). */
|
|
23
|
+
export function fmtBytes(bytes) {
|
|
24
|
+
if (bytes === undefined || !Number.isFinite(bytes) || bytes < 0)
|
|
25
|
+
return '—';
|
|
26
|
+
const units = ['B', 'K', 'M', 'G', 'T', 'P'];
|
|
27
|
+
let v = bytes;
|
|
28
|
+
let i = 0;
|
|
29
|
+
while (v >= 1024 && i < units.length - 1) {
|
|
30
|
+
v /= 1024;
|
|
31
|
+
i++;
|
|
32
|
+
}
|
|
33
|
+
const s = v >= 100 || i <= 1 ? Math.round(v).toString() : v.toFixed(1).replace(/\.0$/, '');
|
|
34
|
+
return `${s}${units[i]}`;
|
|
35
|
+
}
|
|
36
|
+
export function parseUptime(out) {
|
|
37
|
+
const m = out.match(/load average[s]?:\s*([0-9]+[.,][0-9]+|[0-9]+)/i);
|
|
38
|
+
if (!m)
|
|
39
|
+
return {};
|
|
40
|
+
const v = parseFloat(m[1].replace(',', '.'));
|
|
41
|
+
if (!Number.isFinite(v))
|
|
42
|
+
return {};
|
|
43
|
+
return { loadAvg1: v };
|
|
44
|
+
}
|
|
45
|
+
export function parseVmStat(out) {
|
|
46
|
+
const pageSize = parseInt(out.match(/page size of\s+([0-9]+)\s+bytes/)?.[1] ?? '4096', 10);
|
|
47
|
+
const active = out.match(/Pages active:\s+([0-9]+)/);
|
|
48
|
+
const wired = out.match(/Pages wired down:\s+([0-9]+)/);
|
|
49
|
+
const compressed = out.match(/Pages occupied by compressor:\s+([0-9]+)/);
|
|
50
|
+
const free = out.match(/Pages free:\s+([0-9]+)/);
|
|
51
|
+
if (!active || !wired || !compressed || !free)
|
|
52
|
+
return {};
|
|
53
|
+
// macOS reclaims inactive + speculative pages on demand, so they count as
|
|
54
|
+
// available — folding them into "free" (as Activity Monitor / vm_pressure do)
|
|
55
|
+
// rather than "used" keeps the headroom bucket honest on a Mac.
|
|
56
|
+
const inactive = out.match(/Pages inactive:\s+([0-9]+)/);
|
|
57
|
+
const speculative = out.match(/Pages speculative:\s+([0-9]+)/);
|
|
58
|
+
const usedPages = parseInt(active[1], 10) + parseInt(wired[1], 10) + parseInt(compressed[1], 10);
|
|
59
|
+
const freePages = parseInt(free[1], 10) +
|
|
60
|
+
parseInt(inactive?.[1] ?? '0', 10) +
|
|
61
|
+
parseInt(speculative?.[1] ?? '0', 10);
|
|
62
|
+
const totalPages = usedPages + freePages;
|
|
63
|
+
if (totalPages <= 0)
|
|
64
|
+
return {};
|
|
65
|
+
return {
|
|
66
|
+
memPercent: (usedPages / totalPages) * 100,
|
|
67
|
+
memTotalBytes: totalPages * pageSize,
|
|
68
|
+
memFreeBytes: freePages * pageSize,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
export function parseLinuxMemInfo(out) {
|
|
72
|
+
const total = out.match(/^MemTotal:\s+([0-9]+)/im);
|
|
73
|
+
const available = out.match(/^MemAvailable:\s+([0-9]+)/im);
|
|
74
|
+
if (!total || !available)
|
|
75
|
+
return {};
|
|
76
|
+
const tKb = parseInt(total[1], 10);
|
|
77
|
+
const aKb = parseInt(available[1], 10);
|
|
78
|
+
if (tKb <= 0)
|
|
79
|
+
return {};
|
|
80
|
+
return {
|
|
81
|
+
memPercent: Math.max(0, Math.min(100, ((tKb - aKb) / tKb) * 100)),
|
|
82
|
+
memTotalBytes: tKb * 1024,
|
|
83
|
+
memFreeBytes: aKb * 1024,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
export function parseNcpu(out) {
|
|
87
|
+
const n = parseInt(out.trim().split(/\s+/)[0] ?? '', 10);
|
|
88
|
+
return Number.isFinite(n) && n > 0 ? { ncpu: n } : {};
|
|
89
|
+
}
|
|
90
|
+
/** Assemble a DeviceStats from the three snippet sections. */
|
|
91
|
+
export function parseProbeOutput(host, stdout, fetchedAt) {
|
|
92
|
+
const [uptimePart = '', memPart = '', ncpuPart = ''] = stdout.split(SEP);
|
|
93
|
+
const { loadAvg1 } = parseUptime(uptimePart);
|
|
94
|
+
const mem = memPart.includes('MemTotal') ? parseLinuxMemInfo(memPart) : parseVmStat(memPart);
|
|
95
|
+
const { ncpu } = parseNcpu(ncpuPart);
|
|
96
|
+
const loadPercent = loadAvg1 !== undefined && ncpu ? (loadAvg1 / ncpu) * 100 : undefined;
|
|
97
|
+
return {
|
|
98
|
+
host,
|
|
99
|
+
reachable: true,
|
|
100
|
+
loadAvg1,
|
|
101
|
+
ncpu,
|
|
102
|
+
loadPercent,
|
|
103
|
+
memPercent: mem.memPercent,
|
|
104
|
+
memTotalBytes: mem.memTotalBytes,
|
|
105
|
+
memFreeBytes: mem.memFreeBytes,
|
|
106
|
+
fetchedAt,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
/** Sum cores and memory across reachable devices for the summary footer. */
|
|
110
|
+
export function fleetCapacity(statsList) {
|
|
111
|
+
const cap = { reachable: 0, cores: 0, memTotalBytes: 0, memFreeBytes: 0 };
|
|
112
|
+
for (const s of statsList) {
|
|
113
|
+
if (!s.reachable)
|
|
114
|
+
continue;
|
|
115
|
+
cap.reachable++;
|
|
116
|
+
cap.cores += s.ncpu ?? 0;
|
|
117
|
+
cap.memTotalBytes += s.memTotalBytes ?? 0;
|
|
118
|
+
cap.memFreeBytes += s.memFreeBytes ?? 0;
|
|
119
|
+
}
|
|
120
|
+
return cap;
|
|
121
|
+
}
|
|
122
|
+
export function headroom(stats) {
|
|
123
|
+
if (!stats || !stats.reachable)
|
|
124
|
+
return 'unknown';
|
|
125
|
+
const signals = [stats.loadPercent, stats.memPercent].filter((v) => typeof v === 'number');
|
|
126
|
+
if (signals.length === 0)
|
|
127
|
+
return 'unknown';
|
|
128
|
+
const worst = Math.max(...signals);
|
|
129
|
+
if (worst < 15)
|
|
130
|
+
return 'idle';
|
|
131
|
+
if (worst < 40)
|
|
132
|
+
return 'light';
|
|
133
|
+
if (worst < 75)
|
|
134
|
+
return 'busy';
|
|
135
|
+
return 'loaded';
|
|
136
|
+
}
|
|
137
|
+
/** Probe one device over the same ssh path as `agents ssh <name>`. Never throws;
|
|
138
|
+
* an unreachable/slow/misconfigured device resolves to `reachable: false`. */
|
|
139
|
+
export function probeDeviceStats(device, opts = {}) {
|
|
140
|
+
const host = device.name;
|
|
141
|
+
const fetchedAt = opts.now ?? Date.now();
|
|
142
|
+
let args;
|
|
143
|
+
let env;
|
|
144
|
+
try {
|
|
145
|
+
const shim = writeAskpassShim();
|
|
146
|
+
// buildSshInvocation joins the cmd with spaces and hands the string to the
|
|
147
|
+
// remote login shell, which evaluates the snippet's `;`/`||` directly — no
|
|
148
|
+
// `sh -c` wrapper needed (and a wrapper would only re-quote the first token).
|
|
149
|
+
({ args, env } = buildSshInvocation(device, [PROBE_SNIPPET], shim));
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
return Promise.resolve({ host, reachable: false, fetchedAt });
|
|
153
|
+
}
|
|
154
|
+
return new Promise((resolve) => {
|
|
155
|
+
execFile('ssh', args, { encoding: 'utf-8', env: { ...process.env, ...env }, timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
|
|
156
|
+
if (err || !stdout)
|
|
157
|
+
return resolve({ host, reachable: false, fetchedAt });
|
|
158
|
+
resolve(parseProbeOutput(host, stdout, fetchedAt));
|
|
159
|
+
});
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/** Probe the local machine directly (no ssh round-trip) — used for the "this
|
|
163
|
+
* machine" row so it always shows real numbers even if it isn't ssh-reachable
|
|
164
|
+
* from itself. */
|
|
165
|
+
export function probeLocalStats(host, opts = {}) {
|
|
166
|
+
const fetchedAt = opts.now ?? Date.now();
|
|
167
|
+
return new Promise((resolve) => {
|
|
168
|
+
execFile('sh', ['-c', PROBE_SNIPPET], { encoding: 'utf-8', timeout: opts.timeoutMs ?? PROBE_TIMEOUT_MS }, (err, stdout) => {
|
|
169
|
+
if (err || !stdout)
|
|
170
|
+
return resolve({ host, reachable: false, fetchedAt });
|
|
171
|
+
resolve(parseProbeOutput(host, stdout, fetchedAt));
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/** Probe many devices concurrently; returns a name→stats map. Bounded by the
|
|
176
|
+
* per-probe timeout, so total wall time ≈ the slowest single probe. The device
|
|
177
|
+
* named `selfName` is probed locally instead of over ssh. */
|
|
178
|
+
export async function probeFleetStats(devices, opts = {}) {
|
|
179
|
+
const entries = await Promise.all(devices.map(async (d) => {
|
|
180
|
+
const stats = d.name === opts.selfName
|
|
181
|
+
? await probeLocalStats(d.name, opts)
|
|
182
|
+
: await probeDeviceStats(d, opts);
|
|
183
|
+
return [d.name, stats];
|
|
184
|
+
}));
|
|
185
|
+
return new Map(entries);
|
|
186
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare const FUNNEL_PORTS: readonly [443, 8443, 10000];
|
|
2
|
+
export type FunnelPort = typeof FUNNEL_PORTS[number];
|
|
3
|
+
export declare function parseFunnelPort(value: string | number): FunnelPort;
|
|
4
|
+
export declare function buildFunnelStatusCommand(): string;
|
|
5
|
+
export declare function buildFunnelUpCommand(publicPort: FunnelPort, localPort: number): string;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { shellQuote } from './ssh-exec.js';
|
|
2
|
+
export const FUNNEL_PORTS = [443, 8443, 10000];
|
|
3
|
+
export function parseFunnelPort(value) {
|
|
4
|
+
const port = typeof value === 'number' ? value : Number.parseInt(value, 10);
|
|
5
|
+
if (FUNNEL_PORTS.includes(port))
|
|
6
|
+
return port;
|
|
7
|
+
throw new Error(`Tailscale Funnel public port must be one of: ${FUNNEL_PORTS.join(', ')}`);
|
|
8
|
+
}
|
|
9
|
+
export function buildFunnelStatusCommand() {
|
|
10
|
+
return 'tailscale funnel status';
|
|
11
|
+
}
|
|
12
|
+
export function buildFunnelUpCommand(publicPort, localPort) {
|
|
13
|
+
if (!Number.isInteger(localPort) || localPort <= 0 || localPort > 65535) {
|
|
14
|
+
throw new Error('Local port must be between 1 and 65535');
|
|
15
|
+
}
|
|
16
|
+
return [
|
|
17
|
+
'tailscale',
|
|
18
|
+
'funnel',
|
|
19
|
+
'--bg',
|
|
20
|
+
`--https=${publicPort}`,
|
|
21
|
+
`http://localhost:${localPort}`,
|
|
22
|
+
].map(shellQuote).join(' ');
|
|
23
|
+
}
|
package/dist/lib/git.d.ts
CHANGED
|
@@ -76,13 +76,24 @@ export declare function setRemoteUrl(repoPath: string, url: string): Promise<voi
|
|
|
76
76
|
* Check if a GitHub repo exists.
|
|
77
77
|
*/
|
|
78
78
|
export declare function checkGitHubRepoExists(owner: string, repo: string): Promise<boolean>;
|
|
79
|
-
/**
|
|
80
|
-
|
|
81
|
-
*/
|
|
82
|
-
export declare function commitAndPush(repoPath: string, message: string): Promise<{
|
|
79
|
+
/** Result of {@link commitAndPush}. */
|
|
80
|
+
export type CommitAndPushResult = {
|
|
83
81
|
success: boolean;
|
|
84
82
|
error?: string;
|
|
85
|
-
|
|
83
|
+
/** Human detail for success: "already up to date", "pushed abc..def", "committed and pushed …". */
|
|
84
|
+
detail?: string;
|
|
85
|
+
branch?: string;
|
|
86
|
+
committed?: boolean;
|
|
87
|
+
pushed?: boolean;
|
|
88
|
+
};
|
|
89
|
+
/**
|
|
90
|
+
* Commit (if dirty) and push a repo.
|
|
91
|
+
*
|
|
92
|
+
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
93
|
+
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
94
|
+
* there is nothing to commit.
|
|
95
|
+
*/
|
|
96
|
+
export declare function commitAndPush(repoPath: string, message: string): Promise<CommitAndPushResult>;
|
|
86
97
|
/**
|
|
87
98
|
* Check if repo has uncommitted changes.
|
|
88
99
|
*/
|
|
@@ -166,11 +177,16 @@ export declare function displayHomePath(dir: string): string;
|
|
|
166
177
|
/**
|
|
167
178
|
* Pull changes in an existing repo.
|
|
168
179
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
180
|
+
*
|
|
181
|
+
* Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
|
|
182
|
+
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
183
|
+
* divergent branches".
|
|
169
184
|
*/
|
|
170
185
|
export declare function pullRepo(dir: string): Promise<{
|
|
171
186
|
success: boolean;
|
|
172
187
|
commit: string;
|
|
173
188
|
error?: string;
|
|
189
|
+
branch?: string;
|
|
174
190
|
}>;
|
|
175
191
|
/**
|
|
176
192
|
* Rebase a repo onto its remote, optionally pushing local commits back up.
|
package/dist/lib/git.js
CHANGED
|
@@ -366,23 +366,67 @@ export async function checkGitHubRepoExists(owner, repo) {
|
|
|
366
366
|
}
|
|
367
367
|
}
|
|
368
368
|
/**
|
|
369
|
-
* Commit and push
|
|
369
|
+
* Commit (if dirty) and push a repo.
|
|
370
|
+
*
|
|
371
|
+
* Clean tree + local ahead of origin still pushes — "nothing to commit" is not
|
|
372
|
+
* "nothing to push". Reports "already up to date" only when `ahead === 0` and
|
|
373
|
+
* there is nothing to commit.
|
|
370
374
|
*/
|
|
371
375
|
export async function commitAndPush(repoPath, message) {
|
|
372
376
|
try {
|
|
373
377
|
const git = simpleGit(repoPath);
|
|
374
|
-
|
|
375
|
-
const
|
|
376
|
-
|
|
377
|
-
|
|
378
|
+
let status = await git.status();
|
|
379
|
+
const branch = status.current || 'main';
|
|
380
|
+
let committed = false;
|
|
381
|
+
if (status.files.length > 0) {
|
|
382
|
+
await git.add('-A');
|
|
383
|
+
await git.commit(message);
|
|
384
|
+
committed = true;
|
|
385
|
+
status = await git.status();
|
|
386
|
+
}
|
|
387
|
+
const ahead = status.ahead ?? 0;
|
|
388
|
+
if (!committed && ahead === 0) {
|
|
389
|
+
return {
|
|
390
|
+
success: true,
|
|
391
|
+
detail: 'already up to date',
|
|
392
|
+
branch,
|
|
393
|
+
committed: false,
|
|
394
|
+
pushed: false,
|
|
395
|
+
};
|
|
396
|
+
}
|
|
397
|
+
// Capture remote tip before push for a real ref range in the detail string.
|
|
398
|
+
let before = '';
|
|
399
|
+
try {
|
|
400
|
+
before = (await git.raw(['rev-parse', '--short=8', `origin/${branch}`])).trim();
|
|
401
|
+
}
|
|
402
|
+
catch {
|
|
403
|
+
/* origin/<branch> may not exist yet (first push) */
|
|
378
404
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
405
|
+
await git.push('origin', branch);
|
|
406
|
+
let after = '';
|
|
407
|
+
try {
|
|
408
|
+
after = (await git.raw(['rev-parse', '--short=8', 'HEAD'])).trim();
|
|
409
|
+
}
|
|
410
|
+
catch {
|
|
411
|
+
after = 'unknown';
|
|
412
|
+
}
|
|
413
|
+
const range = before && after && before !== after
|
|
414
|
+
? `${before}..${after}`
|
|
415
|
+
: after || undefined;
|
|
416
|
+
const detail = committed
|
|
417
|
+
? range
|
|
418
|
+
? `committed and pushed ${range}`
|
|
419
|
+
: 'committed and pushed'
|
|
420
|
+
: range
|
|
421
|
+
? `pushed ${range}`
|
|
422
|
+
: 'pushed';
|
|
423
|
+
return {
|
|
424
|
+
success: true,
|
|
425
|
+
detail,
|
|
426
|
+
branch,
|
|
427
|
+
committed,
|
|
428
|
+
pushed: true,
|
|
429
|
+
};
|
|
386
430
|
}
|
|
387
431
|
catch (err) {
|
|
388
432
|
return { success: false, error: err.message };
|
|
@@ -630,6 +674,10 @@ export function displayHomePath(dir) {
|
|
|
630
674
|
/**
|
|
631
675
|
* Pull changes in an existing repo.
|
|
632
676
|
* Refuses to pull if the working tree is dirty -- user must commit or discard changes first.
|
|
677
|
+
*
|
|
678
|
+
* Uses `git pull --rebase` (same strategy as {@link syncRepoGit}) so a diverged
|
|
679
|
+
* branch reconciles instead of failing with "Need to specify how to reconcile
|
|
680
|
+
* divergent branches".
|
|
633
681
|
*/
|
|
634
682
|
export async function pullRepo(dir) {
|
|
635
683
|
try {
|
|
@@ -642,13 +690,15 @@ export async function pullRepo(dir) {
|
|
|
642
690
|
error: `Working tree has uncommitted changes. Commit or discard them before pulling.\n\n cd ${displayHomePath(dir)} && git status`,
|
|
643
691
|
};
|
|
644
692
|
}
|
|
645
|
-
|
|
646
|
-
await git.
|
|
693
|
+
const branch = status.current || 'main';
|
|
694
|
+
await git.fetch('origin');
|
|
695
|
+
await git.pull('origin', branch, { '--rebase': 'true' });
|
|
647
696
|
installGithooksSymlinks(dir);
|
|
648
697
|
const log = await git.log({ maxCount: 1 });
|
|
649
698
|
return {
|
|
650
699
|
success: true,
|
|
651
700
|
commit: log.latest?.hash.slice(0, 8) || 'unknown',
|
|
701
|
+
branch,
|
|
652
702
|
};
|
|
653
703
|
}
|
|
654
704
|
catch (err) {
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import type { AgentId } from '../types.js';
|
|
10
|
+
import { type DetectedRuntime } from '../crabbox/runtimes.js';
|
|
11
|
+
export interface HostCredentials {
|
|
12
|
+
runtimes: AgentId[];
|
|
13
|
+
detected: DetectedRuntime[];
|
|
14
|
+
claudeCredentialsJson?: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
18
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
19
|
+
*/
|
|
20
|
+
export declare function buildHostCredentialScript(opts: HostCredentials): {
|
|
21
|
+
setup: string;
|
|
22
|
+
teardown: string;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
26
|
+
* after it exits, regardless of success or failure.
|
|
27
|
+
*/
|
|
28
|
+
export declare function wrapHostCommandWithCredentials(innerCommand: string, opts: HostCredentials): string;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential provisioning for `agents run --host --copy-creds`.
|
|
3
|
+
*
|
|
4
|
+
* Reuses the lease flow's runtime detection + credential-script builder so a
|
|
5
|
+
* persistent host can boot logged-in the same way an ephemeral leased box does.
|
|
6
|
+
* Unlike `--lease`, a host is persistent, so copying tokens is strictly opt-in
|
|
7
|
+
* per run and we shred the files after the run to bound the credential window.
|
|
8
|
+
*/
|
|
9
|
+
import { buildCredentialScript, CLAUDE_TOKEN_REMOTE } from '../crabbox/runtimes.js';
|
|
10
|
+
function getShredPaths(runtimes) {
|
|
11
|
+
const pathsById = {
|
|
12
|
+
claude: ['.claude.json', CLAUDE_TOKEN_REMOTE],
|
|
13
|
+
codex: ['.codex/auth.json'],
|
|
14
|
+
gemini: ['.gemini/google_accounts.json'],
|
|
15
|
+
grok: ['.grok/auth.json'],
|
|
16
|
+
};
|
|
17
|
+
return runtimes.flatMap((id) => pathsById[id] ?? []);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build the setup (write credential files) and teardown (shred them) scripts for
|
|
21
|
+
* a host run. Returns shell snippets meant to be run on the remote host.
|
|
22
|
+
*/
|
|
23
|
+
export function buildHostCredentialScript(opts) {
|
|
24
|
+
const setup = buildCredentialScript(opts.runtimes, opts.detected, {
|
|
25
|
+
claudeCredentialsJson: opts.claudeCredentialsJson,
|
|
26
|
+
});
|
|
27
|
+
const teardown = getShredPaths(opts.runtimes)
|
|
28
|
+
.map((p) => `rm -f "$HOME/${p}" 2>/dev/null || true`)
|
|
29
|
+
.join('\n');
|
|
30
|
+
return { setup, teardown };
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Wrap a remote command so credentials are written before it runs and shredded
|
|
34
|
+
* after it exits, regardless of success or failure.
|
|
35
|
+
*/
|
|
36
|
+
export function wrapHostCommandWithCredentials(innerCommand, opts) {
|
|
37
|
+
const { setup, teardown } = buildHostCredentialScript(opts);
|
|
38
|
+
return [
|
|
39
|
+
'set -uo pipefail',
|
|
40
|
+
setup,
|
|
41
|
+
innerCommand,
|
|
42
|
+
'rc=$?',
|
|
43
|
+
teardown,
|
|
44
|
+
'exit $rc',
|
|
45
|
+
]
|
|
46
|
+
.filter((l) => l.length > 0)
|
|
47
|
+
.join('\n');
|
|
48
|
+
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
*/
|
|
11
11
|
import type { Host } from './types.js';
|
|
12
12
|
import { type HostTask } from './tasks.js';
|
|
13
|
+
import { type HostCredentials } from './credentials.js';
|
|
13
14
|
/**
|
|
14
15
|
* Build a `cd <dir> && ` prefix that resolves on the REMOTE host.
|
|
15
16
|
*
|
|
@@ -36,6 +37,26 @@ export interface DispatchResult {
|
|
|
36
37
|
}
|
|
37
38
|
/** Terminate a detached dispatch that its caller could not persist locally. */
|
|
38
39
|
export declare function terminateDispatchedTask(task: HostTask): void;
|
|
40
|
+
/**
|
|
41
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
42
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
43
|
+
*
|
|
44
|
+
* Protocol (printed to stdout for the local caller):
|
|
45
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
46
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
47
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
48
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
49
|
+
*/
|
|
50
|
+
export declare function buildStopRemoteCommand(pid: number, remoteExit: string): string;
|
|
51
|
+
/**
|
|
52
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
53
|
+
*
|
|
54
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
55
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
56
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
57
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
58
|
+
*/
|
|
59
|
+
export declare function stopDispatchedTask(task: HostTask): HostTask;
|
|
39
60
|
export interface DispatchOptions {
|
|
40
61
|
agent: string;
|
|
41
62
|
prompt: string;
|
|
@@ -76,6 +97,8 @@ export interface DispatchOptions {
|
|
|
76
97
|
/** Stream progress and block until completion (default true). */
|
|
77
98
|
follow?: boolean;
|
|
78
99
|
timeoutMs?: number;
|
|
100
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
101
|
+
copyCreds?: HostCredentials;
|
|
79
102
|
}
|
|
80
103
|
/**
|
|
81
104
|
* Build the remote `agents run …` argv for a host dispatch. Pure so the
|
|
@@ -116,6 +139,8 @@ export interface InteractiveDispatchOptions {
|
|
|
116
139
|
raw?: boolean;
|
|
117
140
|
/** Forward `--interactive` to the remote so a prompt-bearing run still starts the TUI. */
|
|
118
141
|
forceInteractive?: boolean;
|
|
142
|
+
/** Copy runtime credentials to the host before the run and shred them after. */
|
|
143
|
+
copyCreds?: HostCredentials;
|
|
119
144
|
}
|
|
120
145
|
/**
|
|
121
146
|
* Build the remote `agents run …` argv for an INTERACTIVE host dispatch. The
|
|
@@ -16,6 +16,7 @@ import { remoteShellFor } from './remote-cmd.js';
|
|
|
16
16
|
import { resolveRemoteOsSync } from './remote-os.js';
|
|
17
17
|
import { saveTask, updateTask, terminalPatch } from './tasks.js';
|
|
18
18
|
import { followHostTask } from './progress.js';
|
|
19
|
+
import { wrapHostCommandWithCredentials } from './credentials.js';
|
|
19
20
|
// Use $HOME (not ~) so the path is correct whether or not it's quoted and
|
|
20
21
|
// regardless of the run's cwd. Task ids are 8 hex chars, so these paths are
|
|
21
22
|
// injection-safe to interpolate unquoted into remote commands.
|
|
@@ -91,6 +92,64 @@ export function terminateDispatchedTask(task) {
|
|
|
91
92
|
terminateRemoteLaunch(task);
|
|
92
93
|
updateTask(task.id, terminalPatch(143));
|
|
93
94
|
}
|
|
95
|
+
/**
|
|
96
|
+
* Build the remote shell used by {@link stopDispatchedTask}. Exported for
|
|
97
|
+
* unit tests — the keep-log / no-clobber contract lives in this script.
|
|
98
|
+
*
|
|
99
|
+
* Protocol (printed to stdout for the local caller):
|
|
100
|
+
* - `SIGNALED` — process group was live; SIGTERM/KILL applied; wrote 143
|
|
101
|
+
* - `ALREADY` + code — group gone; adopted existing `.exit` (never overwrite)
|
|
102
|
+
* - `GONE` — group gone and no `.exit`; write 143 as the local stop outcome
|
|
103
|
+
* Exit 1 if the group is still alive after TERM/KILL (can't stop it).
|
|
104
|
+
*/
|
|
105
|
+
export function buildStopRemoteCommand(pid, remoteExit) {
|
|
106
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
107
|
+
throw new Error(`Invalid remote task pid: ${pid}`);
|
|
108
|
+
}
|
|
109
|
+
// Only force-write 143 when we actually signaled a live group (or nothing
|
|
110
|
+
// left a code). Never `echo 143` over a real completed-run exit code.
|
|
111
|
+
return (`if kill -TERM -- -${pid} 2>/dev/null; then ` +
|
|
112
|
+
`sleep 1; kill -KILL -- -${pid} 2>/dev/null || true; ` +
|
|
113
|
+
`echo 143 > ${remoteExit}; echo SIGNALED; ` +
|
|
114
|
+
`elif kill -0 -- -${pid} 2>/dev/null; then ` +
|
|
115
|
+
`exit 1; ` +
|
|
116
|
+
`else ` +
|
|
117
|
+
`code=$(cat ${remoteExit} 2>/dev/null | tr -d '[:space:]'); ` +
|
|
118
|
+
`if [ -n "$code" ]; then echo "ALREADY $code"; ` +
|
|
119
|
+
`else echo 143 > ${remoteExit}; echo GONE; fi; ` +
|
|
120
|
+
`fi`);
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Stop a running host task from the origin machine (`agents hosts stop <id>`).
|
|
124
|
+
*
|
|
125
|
+
* Unlike {@link terminateDispatchedTask} (rollback cleanup after a failed
|
|
126
|
+
* persist), this keeps the remote log so `agents hosts logs <id>` still works,
|
|
127
|
+
* writes a terminal `.exit` marker only when we actually stopped a live group
|
|
128
|
+
* (or no code existed), and never clobbers a real completed-run exit code.
|
|
129
|
+
*/
|
|
130
|
+
export function stopDispatchedTask(task) {
|
|
131
|
+
if (task.status !== 'running') {
|
|
132
|
+
throw new Error(`Task ${task.id} is already ${task.status}`);
|
|
133
|
+
}
|
|
134
|
+
if (!task.pid) {
|
|
135
|
+
throw new Error(`Cannot stop remote task ${task.id}: launch returned no PID.`);
|
|
136
|
+
}
|
|
137
|
+
const command = buildStopRemoteCommand(task.pid, task.remoteExit);
|
|
138
|
+
const result = sshExec(task.target, command, { timeoutMs: 10000, multiplex: true });
|
|
139
|
+
if (result.code !== 0) {
|
|
140
|
+
throw new Error(`Failed to stop remote task ${task.id} on ${task.host}: ` +
|
|
141
|
+
`${(result.stderr || result.stdout).trim() || 'ssh error'}`);
|
|
142
|
+
}
|
|
143
|
+
const line = result.stdout.trim().split('\n').pop() ?? '';
|
|
144
|
+
let code = 143;
|
|
145
|
+
if (line.startsWith('ALREADY ')) {
|
|
146
|
+
const parsed = parseInt(line.slice('ALREADY '.length), 10);
|
|
147
|
+
if (Number.isFinite(parsed))
|
|
148
|
+
code = parsed;
|
|
149
|
+
}
|
|
150
|
+
// SIGNALED / GONE / ALREADY all end with a terminal local record.
|
|
151
|
+
return updateTask(task.id, terminalPatch(code)) ?? { ...task, ...terminalPatch(code) };
|
|
152
|
+
}
|
|
94
153
|
/**
|
|
95
154
|
* The launch + task-record + optional follow core. Both `dispatchToHost` (run)
|
|
96
155
|
* and `dispatchAgentsCommand` (teams) build their `forwardedArgs` and call here,
|
|
@@ -117,7 +176,10 @@ async function launchDetached(host, target, opts) {
|
|
|
117
176
|
// Inner command run under a login shell so PATH resolves `agents`.
|
|
118
177
|
const invocation = ['agents', ...opts.forwardedArgs].map(shellQuote).join(' ');
|
|
119
178
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
120
|
-
|
|
179
|
+
let inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
|
|
180
|
+
if (opts.copyCreds) {
|
|
181
|
+
inner = wrapHostCommandWithCredentials(inner, opts.copyCreds);
|
|
182
|
+
}
|
|
121
183
|
// Outer: ensure dir, launch the login-shell wrapper as a new process-group
|
|
122
184
|
// leader, and print that leader PID.
|
|
123
185
|
const launch = `mkdir -p ${REMOTE_DIR}; ${buildDetachedLaunchCommand(inner)}`;
|
|
@@ -268,7 +330,10 @@ export async function runInteractiveOnHost(host, opts) {
|
|
|
268
330
|
process.stderr.write(`[hosts] warning: ${w}\n`);
|
|
269
331
|
const invocation = ['agents', ...buildInteractiveRunForwardedArgs(opts)].map(shellQuote).join(' ');
|
|
270
332
|
const cwd = remoteCdPrefix(opts.remoteCwd);
|
|
271
|
-
|
|
333
|
+
let remoteCmd = `${cwd}${invocation}`;
|
|
334
|
+
if (opts.copyCreds) {
|
|
335
|
+
remoteCmd = wrapHostCommandWithCredentials(remoteCmd, opts.copyCreds);
|
|
336
|
+
}
|
|
272
337
|
return sshStream(target, remoteCmd, { tty: process.stdin.isTTY, multiplex: true });
|
|
273
338
|
}
|
|
274
339
|
/** Dispatch an `agents run <agent> "<prompt>"` onto a host (the `run --host` path). */
|
|
@@ -288,6 +353,7 @@ export async function dispatchToHost(host, opts) {
|
|
|
288
353
|
// On resume the remote session keeps its existing id; record that id so the
|
|
289
354
|
// task stays mapped to the same session.
|
|
290
355
|
sessionId: opts.resume ?? opts.sessionId,
|
|
356
|
+
copyCreds: opts.copyCreds,
|
|
291
357
|
});
|
|
292
358
|
}
|
|
293
359
|
/**
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generic `--host` passthrough — the single choke point that runs an allowlisted
|
|
3
|
-
* `agents <command>` on a remote host instead of locally
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* code. Called once from `index.ts` before commander parses; returns `true` when
|
|
7
|
-
* it handled the invocation (the local command must then NOT run).
|
|
3
|
+
* `agents <command>` on a remote host instead of locally. Called once from
|
|
4
|
+
* `index.ts` before commander parses; returns `true` when it handled the
|
|
5
|
+
* invocation (the local command must then NOT run).
|
|
8
6
|
*
|
|
9
7
|
* Transport is SSH (via `ssh-exec.ts`), never a daemon: SSH is the one hardened
|
|
10
8
|
* choke point already used everywhere, and it gives auth + encryption + host-key
|
|
@@ -12,17 +10,22 @@
|
|
|
12
10
|
* long-running case — `teams start --watch` — dispatches detached so the remote
|
|
13
11
|
* supervisor outlives a dropped connection.
|
|
14
12
|
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
13
|
+
* Commands with their own richer `--host` handling (`run`/`sessions`/`feed`/
|
|
14
|
+
* `computer`/`secrets`/`logs`/…) are listed in {@link OWN_HOST_COMMANDS} and
|
|
15
|
+
* fall through to their local actions. Everything else either routes via this
|
|
16
|
+
* table or, when `--host`/`--device` is present, exits with a clear
|
|
17
|
+
* "not supported" message — never commander's raw `unknown option`.
|
|
18
18
|
*/
|
|
19
19
|
/** Pull the value of `--host`/`-H`/`--remote-cwd` (any form) out of an argv. */
|
|
20
20
|
export declare function flagValue(args: string[], long: string, short?: string): string | undefined;
|
|
21
21
|
/**
|
|
22
22
|
* Route `agents <command> … --host <name>` to a remote if the command is
|
|
23
23
|
* host-routable and a `--host` (or its `--device` alias) was given. Returns
|
|
24
|
-
* `false` (run locally) when neither flag is present, the command
|
|
25
|
-
*
|
|
24
|
+
* `false` (run locally) when neither flag is present, the command owns its own
|
|
25
|
+
* host handling, the target is this very machine, or placement flags need the
|
|
26
|
+
* local action. Returns `true` after printing a clear error when the flag is
|
|
27
|
+
* present on a command that is neither routable nor self-handling — so the user
|
|
28
|
+
* never sees commander's raw `unknown option '--host'`.
|
|
26
29
|
*
|
|
27
30
|
* @param command the resolved subcommand name (`process.argv`'s first non-flag).
|
|
28
31
|
* @param allArgs `process.argv.slice(2)` — the command name followed by its args.
|