@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +62 -0
  2. package/README.md +19 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +96 -28
  6. package/dist/commands/feed.d.ts +4 -0
  7. package/dist/commands/feed.js +27 -8
  8. package/dist/commands/funnel.d.ts +5 -0
  9. package/dist/commands/funnel.js +62 -0
  10. package/dist/commands/hosts.js +42 -0
  11. package/dist/commands/lease.d.ts +23 -0
  12. package/dist/commands/lease.js +201 -0
  13. package/dist/commands/mailboxes.d.ts +20 -0
  14. package/dist/commands/mailboxes.js +390 -0
  15. package/dist/commands/repo.d.ts +4 -4
  16. package/dist/commands/repo.js +30 -19
  17. package/dist/commands/routines.js +92 -29
  18. package/dist/commands/sessions-export.d.ts +2 -0
  19. package/dist/commands/sessions-export.js +279 -0
  20. package/dist/commands/sessions-import.d.ts +2 -0
  21. package/dist/commands/sessions-import.js +230 -0
  22. package/dist/commands/sessions-sync.d.ts +1 -0
  23. package/dist/commands/sessions-sync.js +16 -2
  24. package/dist/commands/sessions.js +12 -1
  25. package/dist/commands/setup.js +9 -0
  26. package/dist/commands/ssh.js +170 -5
  27. package/dist/commands/sync-provision.d.ts +23 -0
  28. package/dist/commands/sync-provision.js +107 -0
  29. package/dist/commands/usage.d.ts +2 -0
  30. package/dist/commands/usage.js +7 -2
  31. package/dist/commands/view.d.ts +1 -1
  32. package/dist/commands/webhook.d.ts +9 -0
  33. package/dist/commands/webhook.js +93 -0
  34. package/dist/index.js +7 -2
  35. package/dist/lib/agents.d.ts +44 -0
  36. package/dist/lib/agents.js +85 -35
  37. package/dist/lib/browser/drivers/ssh.js +19 -2
  38. package/dist/lib/browser/ipc.js +5 -4
  39. package/dist/lib/browser/profiles.d.ts +13 -0
  40. package/dist/lib/browser/profiles.js +17 -0
  41. package/dist/lib/browser/service.d.ts +12 -1
  42. package/dist/lib/browser/service.js +48 -13
  43. package/dist/lib/browser/sessions-list.d.ts +40 -0
  44. package/dist/lib/browser/sessions-list.js +190 -0
  45. package/dist/lib/comms-render.d.ts +37 -0
  46. package/dist/lib/comms-render.js +89 -0
  47. package/dist/lib/crabbox/cli.d.ts +72 -0
  48. package/dist/lib/crabbox/cli.js +158 -9
  49. package/dist/lib/crabbox/runtimes.d.ts +13 -0
  50. package/dist/lib/crabbox/runtimes.js +24 -0
  51. package/dist/lib/daemon.js +8 -1
  52. package/dist/lib/devices/fleet.d.ts +62 -0
  53. package/dist/lib/devices/fleet.js +128 -0
  54. package/dist/lib/devices/health.d.ts +77 -0
  55. package/dist/lib/devices/health.js +186 -0
  56. package/dist/lib/funnel.d.ts +5 -0
  57. package/dist/lib/funnel.js +23 -0
  58. package/dist/lib/git.d.ts +21 -5
  59. package/dist/lib/git.js +64 -14
  60. package/dist/lib/hosts/credentials.d.ts +28 -0
  61. package/dist/lib/hosts/credentials.js +48 -0
  62. package/dist/lib/hosts/dispatch.d.ts +25 -0
  63. package/dist/lib/hosts/dispatch.js +68 -2
  64. package/dist/lib/hosts/passthrough.d.ts +13 -10
  65. package/dist/lib/hosts/passthrough.js +119 -29
  66. package/dist/lib/mailbox-gc.js +4 -16
  67. package/dist/lib/mailbox.d.ts +39 -0
  68. package/dist/lib/mailbox.js +112 -0
  69. package/dist/lib/migrate.d.ts +12 -0
  70. package/dist/lib/migrate.js +55 -1
  71. package/dist/lib/paths.d.ts +13 -0
  72. package/dist/lib/paths.js +26 -4
  73. package/dist/lib/routines.d.ts +50 -12
  74. package/dist/lib/routines.js +82 -27
  75. package/dist/lib/runner.js +255 -13
  76. package/dist/lib/sandbox.d.ts +9 -1
  77. package/dist/lib/sandbox.js +11 -2
  78. package/dist/lib/session/bundle.d.ts +150 -0
  79. package/dist/lib/session/bundle.js +189 -0
  80. package/dist/lib/session/remote-bundle.d.ts +12 -0
  81. package/dist/lib/session/remote-bundle.js +61 -0
  82. package/dist/lib/session/sync/agents.d.ts +56 -6
  83. package/dist/lib/session/sync/agents.js +0 -0
  84. package/dist/lib/session/sync/config.d.ts +8 -0
  85. package/dist/lib/session/sync/config.js +6 -1
  86. package/dist/lib/session/sync/manifest.d.ts +14 -3
  87. package/dist/lib/session/sync/manifest.js +4 -0
  88. package/dist/lib/session/sync/provision.d.ts +49 -0
  89. package/dist/lib/session/sync/provision.js +91 -0
  90. package/dist/lib/session/sync/sync.d.ts +26 -2
  91. package/dist/lib/session/sync/sync.js +192 -69
  92. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  93. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  94. package/dist/lib/ssh-tunnel.js +13 -1
  95. package/dist/lib/staleness/detectors/subagents.d.ts +5 -0
  96. package/dist/lib/staleness/detectors/subagents.js +5 -192
  97. package/dist/lib/staleness/writers/subagents.d.ts +10 -0
  98. package/dist/lib/staleness/writers/subagents.js +11 -102
  99. package/dist/lib/startup/command-registry.d.ts +4 -0
  100. package/dist/lib/startup/command-registry.js +16 -0
  101. package/dist/lib/state.d.ts +10 -2
  102. package/dist/lib/state.js +14 -2
  103. package/dist/lib/subagents-registry.d.ts +85 -0
  104. package/dist/lib/subagents-registry.js +393 -0
  105. package/dist/lib/subagents.d.ts +8 -8
  106. package/dist/lib/subagents.js +32 -663
  107. package/dist/lib/sync-umbrella.d.ts +1 -0
  108. package/dist/lib/sync-umbrella.js +14 -3
  109. package/dist/lib/triggers/webhook.d.ts +70 -27
  110. package/dist/lib/triggers/webhook.js +264 -43
  111. package/dist/lib/types.d.ts +9 -0
  112. package/dist/lib/usage.d.ts +42 -3
  113. package/dist/lib/usage.js +162 -22
  114. 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
- * Commit and push changes in a repo.
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 changes in a repo.
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
- // Check for changes
375
- const status = await git.status();
376
- if (status.files.length === 0) {
377
- return { success: true }; // Nothing to commit
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
- // Stage all changes
380
- await git.add('-A');
381
- // Commit
382
- await git.commit(message);
383
- // Push
384
- await git.push('origin', 'main');
385
- return { success: true };
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
- await git.fetch();
646
- await git.pull();
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
- const inner = `${cwd}${invocation} > ${remoteLog} 2>&1; echo $? > ${remoteExit}`;
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
- const remoteCmd = `${cwd}${invocation}`;
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, so read-only and
4
- * config commands (`view`, `usage`, `cost`, `doctor`, `inspect`, `list`, `sync`)
5
- * and the team lifecycle (`teams …`) all gain remote support with no per-command
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
- * `run`, `sessions`, and `feed` are deliberately absent from the table below:
16
- * they own richer `--host` handling in their own command actions (detached run
17
- * dispatch or multi-host fan-out) and must fall through to it.
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 isn't in the
25
- * table, or the target is this very machine.
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.