@phnx-labs/agents-cli 1.20.62 → 1.20.63

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 (65) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/README.md +10 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/browser.js +13 -3
  5. package/dist/commands/exec.js +41 -0
  6. package/dist/commands/funnel.d.ts +5 -0
  7. package/dist/commands/funnel.js +62 -0
  8. package/dist/commands/hosts.js +42 -0
  9. package/dist/commands/repo.d.ts +4 -4
  10. package/dist/commands/repo.js +30 -19
  11. package/dist/commands/routines.js +73 -16
  12. package/dist/commands/sessions-sync.d.ts +1 -0
  13. package/dist/commands/sessions-sync.js +16 -2
  14. package/dist/commands/sessions.js +8 -1
  15. package/dist/commands/setup.js +9 -0
  16. package/dist/commands/ssh.js +72 -2
  17. package/dist/commands/sync-provision.d.ts +23 -0
  18. package/dist/commands/sync-provision.js +107 -0
  19. package/dist/commands/webhook.d.ts +9 -0
  20. package/dist/commands/webhook.js +93 -0
  21. package/dist/index.js +6 -2
  22. package/dist/lib/agents.d.ts +26 -0
  23. package/dist/lib/agents.js +58 -18
  24. package/dist/lib/browser/ipc.js +5 -4
  25. package/dist/lib/browser/profiles.d.ts +13 -0
  26. package/dist/lib/browser/profiles.js +17 -0
  27. package/dist/lib/browser/service.d.ts +12 -1
  28. package/dist/lib/browser/service.js +48 -13
  29. package/dist/lib/browser/sessions-list.d.ts +40 -0
  30. package/dist/lib/browser/sessions-list.js +190 -0
  31. package/dist/lib/daemon.js +2 -0
  32. package/dist/lib/devices/fleet.d.ts +62 -0
  33. package/dist/lib/devices/fleet.js +128 -0
  34. package/dist/lib/funnel.d.ts +5 -0
  35. package/dist/lib/funnel.js +23 -0
  36. package/dist/lib/git.d.ts +21 -5
  37. package/dist/lib/git.js +64 -14
  38. package/dist/lib/hosts/credentials.d.ts +28 -0
  39. package/dist/lib/hosts/credentials.js +48 -0
  40. package/dist/lib/hosts/dispatch.d.ts +25 -0
  41. package/dist/lib/hosts/dispatch.js +68 -2
  42. package/dist/lib/hosts/passthrough.d.ts +13 -10
  43. package/dist/lib/hosts/passthrough.js +119 -29
  44. package/dist/lib/mailbox-gc.js +4 -16
  45. package/dist/lib/migrate.d.ts +12 -0
  46. package/dist/lib/migrate.js +55 -1
  47. package/dist/lib/routines.d.ts +29 -10
  48. package/dist/lib/routines.js +47 -15
  49. package/dist/lib/session/sync/agents.d.ts +2 -0
  50. package/dist/lib/session/sync/agents.js +39 -1
  51. package/dist/lib/session/sync/config.d.ts +8 -0
  52. package/dist/lib/session/sync/config.js +6 -1
  53. package/dist/lib/session/sync/provision.d.ts +49 -0
  54. package/dist/lib/session/sync/provision.js +91 -0
  55. package/dist/lib/session/sync/sync.d.ts +3 -0
  56. package/dist/lib/session/sync/sync.js +26 -6
  57. package/dist/lib/session/sync/transcript-crypto.d.ts +77 -0
  58. package/dist/lib/session/sync/transcript-crypto.js +147 -0
  59. package/dist/lib/startup/command-registry.d.ts +2 -0
  60. package/dist/lib/startup/command-registry.js +11 -0
  61. package/dist/lib/state.d.ts +10 -2
  62. package/dist/lib/state.js +14 -2
  63. package/dist/lib/triggers/webhook.d.ts +70 -27
  64. package/dist/lib/triggers/webhook.js +264 -43
  65. package/package.json +1 -1
@@ -419,6 +419,8 @@ export async function runDaemon() {
419
419
  log('INFO', `sessions sync: pushed ${r.pushed}, pulled ${r.pulled}, merged ${r.merged}` +
420
420
  (r.errors.length ? `, ${r.errors.length} error(s): ${r.errors[0]}` : ''));
421
421
  }
422
+ if (r.warnings.length)
423
+ log('WARN', `sessions sync: ${r.warnings[0]}`);
422
424
  }
423
425
  catch (err) {
424
426
  log('ERROR', `sessions sync failed: ${err.message}`);
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import type { DeviceProfile, DeviceRegistry } from './registry.js';
11
+ export type FleetSkipReason = 'offline' | 'no-address';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export declare const FLEET_VERSION_RE: RegExp;
14
+ export interface FleetTarget {
15
+ device: DeviceProfile;
16
+ /** When set, this device is not reached (skip with reason). */
17
+ skip?: FleetSkipReason;
18
+ }
19
+ export interface FleetRunResult {
20
+ name: string;
21
+ status: 'ok' | 'failed' | 'skipped';
22
+ code: number | null;
23
+ reason?: FleetSkipReason | string;
24
+ /** Truncated combined stderr/stdout for failures. */
25
+ detail?: string;
26
+ }
27
+ /**
28
+ * Classify each registered device for a fleet operation.
29
+ *
30
+ * - Tailscale-offline → skip `offline`
31
+ * - No address → skip `no-address`
32
+ * - Everything else is a target (including this machine, reached over ssh when
33
+ * it has a registry address — same path as any other box).
34
+ */
35
+ export declare function planFleetTargets(reg: DeviceRegistry): FleetTarget[];
36
+ /** Human label for a skip reason. */
37
+ export declare function skipLabel(reason: FleetSkipReason): string;
38
+ /**
39
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
40
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
41
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
42
+ * misconfigured device cannot abort the fleet loop.
43
+ */
44
+ export declare function runOnDevice(device: DeviceProfile, cmd: string[], opts?: {
45
+ timeoutMs?: number;
46
+ }): {
47
+ code: number | null;
48
+ stdout: string;
49
+ stderr: string;
50
+ };
51
+ /**
52
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
53
+ * Rejects anything that is not a plain npm version/tag token so a version pin
54
+ * cannot inject shell metacharacters into the remote command line.
55
+ */
56
+ export declare function upgradeCommand(version?: string): string[];
57
+ /**
58
+ * Execute a command across planned targets. Pure orchestration over
59
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
60
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
61
+ */
62
+ export declare function runFleet(targets: FleetTarget[], cmd: string[], runner?: typeof runOnDevice): FleetRunResult[];
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Fleet-wide device operations — pick online targets and run a command on each.
3
+ *
4
+ * Used by `agents fleet update` / `agents fleet run` (aliases of the same
5
+ * subcommands under `agents devices`). Offline devices are skipped with a
6
+ * reason so a single dead node never blocks the rest of the rollout. Per-device
7
+ * throws (misconfigured auth, etc.) become `failed` rows — they never abort
8
+ * the remaining devices.
9
+ */
10
+ import { spawnSync } from 'child_process';
11
+ import { buildSshInvocation, sshTargetFor, writeAskpassShim } from './connect.js';
12
+ /** npm dist-tags / semver pins only — rejects shell metacharacters. */
13
+ export const FLEET_VERSION_RE = /^[A-Za-z0-9._-]+$/;
14
+ /**
15
+ * Classify each registered device for a fleet operation.
16
+ *
17
+ * - Tailscale-offline → skip `offline`
18
+ * - No address → skip `no-address`
19
+ * - Everything else is a target (including this machine, reached over ssh when
20
+ * it has a registry address — same path as any other box).
21
+ */
22
+ export function planFleetTargets(reg) {
23
+ const names = Object.keys(reg).sort();
24
+ return names.map((name) => {
25
+ const device = reg[name];
26
+ if (device.tailscale && !device.tailscale.online) {
27
+ return { device, skip: 'offline' };
28
+ }
29
+ try {
30
+ sshTargetFor(device);
31
+ }
32
+ catch {
33
+ return { device, skip: 'no-address' };
34
+ }
35
+ return { device };
36
+ });
37
+ }
38
+ /** Human label for a skip reason. */
39
+ export function skipLabel(reason) {
40
+ switch (reason) {
41
+ case 'offline':
42
+ return 'offline';
43
+ case 'no-address':
44
+ return 'no address';
45
+ }
46
+ }
47
+ /**
48
+ * Run `cmd` on one device via the same ssh path as `agents ssh <name> …`.
49
+ * Captures stdout/stderr (not inherited) so the fleet table can summarize.
50
+ * Throws from buildSshInvocation are returned as a non-zero result so a single
51
+ * misconfigured device cannot abort the fleet loop.
52
+ */
53
+ export function runOnDevice(device, cmd, opts = {}) {
54
+ try {
55
+ const shim = writeAskpassShim();
56
+ const { args, env } = buildSshInvocation(device, cmd, shim);
57
+ const res = spawnSync('ssh', args, {
58
+ encoding: 'utf-8',
59
+ env: { ...process.env, ...env },
60
+ timeout: opts.timeoutMs ?? 600_000,
61
+ });
62
+ return {
63
+ code: res.status,
64
+ stdout: res.stdout?.toString() ?? '',
65
+ stderr: (res.stderr?.toString() ?? '') + (res.error ? String(res.error.message) : ''),
66
+ };
67
+ }
68
+ catch (err) {
69
+ return {
70
+ code: 1,
71
+ stdout: '',
72
+ stderr: err instanceof Error ? err.message : String(err),
73
+ };
74
+ }
75
+ }
76
+ /**
77
+ * Build `agents upgrade --yes` argv, optionally pinned to a version/dist-tag.
78
+ * Rejects anything that is not a plain npm version/tag token so a version pin
79
+ * cannot inject shell metacharacters into the remote command line.
80
+ */
81
+ export function upgradeCommand(version) {
82
+ if (version !== undefined && version !== '') {
83
+ if (!FLEET_VERSION_RE.test(version)) {
84
+ throw new Error(`Invalid version '${version}'. Use a semver or dist-tag (letters, digits, . _ - only).`);
85
+ }
86
+ return ['agents', 'upgrade', version, '--yes'];
87
+ }
88
+ return ['agents', 'upgrade', '--yes'];
89
+ }
90
+ /**
91
+ * Execute a command across planned targets. Pure orchestration over
92
+ * {@link runOnDevice}; testable by injecting `runner`. Per-device throws from
93
+ * the runner are recorded as `failed` so one bad device never aborts the rest.
94
+ */
95
+ export function runFleet(targets, cmd, runner = runOnDevice) {
96
+ const results = [];
97
+ for (const t of targets) {
98
+ if (t.skip) {
99
+ results.push({
100
+ name: t.device.name,
101
+ status: 'skipped',
102
+ code: null,
103
+ reason: t.skip,
104
+ });
105
+ continue;
106
+ }
107
+ try {
108
+ const res = runner(t.device, cmd);
109
+ const ok = res.code === 0;
110
+ const detail = (res.stderr || res.stdout).trim().slice(0, 200);
111
+ results.push({
112
+ name: t.device.name,
113
+ status: ok ? 'ok' : 'failed',
114
+ code: res.code,
115
+ detail: ok ? undefined : detail || undefined,
116
+ });
117
+ }
118
+ catch (err) {
119
+ results.push({
120
+ name: t.device.name,
121
+ status: 'failed',
122
+ code: 1,
123
+ detail: (err instanceof Error ? err.message : String(err)).slice(0, 200),
124
+ });
125
+ }
126
+ }
127
+ return results;
128
+ }
@@ -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.