@phnx-labs/agents-cli 1.22.37 → 1.22.39

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 +50 -0
  2. package/README.md +8 -8
  3. package/dist/bin/agents +0 -0
  4. package/dist/bootstrap.js +3 -2
  5. package/dist/commands/artifacts-setup.d.ts +53 -0
  6. package/dist/commands/{setup-share.js → artifacts-setup.js} +59 -13
  7. package/dist/commands/artifacts.d.ts +18 -0
  8. package/dist/commands/artifacts.js +58 -0
  9. package/dist/commands/browser.js +2 -0
  10. package/dist/commands/config.js +31 -1
  11. package/dist/commands/exec.js +2 -2
  12. package/dist/commands/models.js +67 -0
  13. package/dist/commands/setup.js +5 -5
  14. package/dist/commands/share.d.ts +20 -7
  15. package/dist/commands/share.js +74 -75
  16. package/dist/commands/ssh.js +156 -8
  17. package/dist/lib/browser/hygiene.d.ts +90 -0
  18. package/dist/lib/browser/hygiene.js +146 -0
  19. package/dist/lib/browser/ipc.js +12 -0
  20. package/dist/lib/browser/service.d.ts +75 -1
  21. package/dist/lib/browser/service.js +201 -11
  22. package/dist/lib/browser/types.d.ts +44 -1
  23. package/dist/lib/config-keys.d.ts +11 -3
  24. package/dist/lib/config-keys.js +22 -3
  25. package/dist/lib/config-machine-keys.js +1 -0
  26. package/dist/lib/device-config.d.ts +34 -0
  27. package/dist/lib/device-config.js +96 -0
  28. package/dist/lib/devices/pool.d.ts +56 -0
  29. package/dist/lib/devices/pool.js +85 -0
  30. package/dist/lib/exec.d.ts +4 -1
  31. package/dist/lib/exec.js +8 -2
  32. package/dist/lib/git.d.ts +1 -1
  33. package/dist/lib/git.js +1 -1
  34. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  35. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  36. package/dist/lib/migrate.d.ts +36 -0
  37. package/dist/lib/migrate.js +107 -0
  38. package/dist/lib/routines.d.ts +3 -1
  39. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  40. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  41. package/dist/lib/share/analytics.js +1 -1
  42. package/dist/lib/share/capture.d.ts +1 -1
  43. package/dist/lib/share/capture.js +3 -3
  44. package/dist/lib/share/config.d.ts +2 -2
  45. package/dist/lib/share/config.js +5 -5
  46. package/dist/lib/share/delete.js +3 -3
  47. package/dist/lib/share/provision.js +3 -3
  48. package/dist/lib/share/publish.d.ts +1 -1
  49. package/dist/lib/share/publish.js +3 -3
  50. package/dist/lib/share/worker-template.d.ts +12 -1
  51. package/dist/lib/share/worker-template.js +13 -2
  52. package/dist/lib/smart-launch.d.ts +33 -3
  53. package/dist/lib/smart-launch.js +61 -6
  54. package/dist/lib/startup/command-registry.d.ts +10 -2
  55. package/dist/lib/startup/command-registry.js +16 -7
  56. package/dist/lib/tmux/orphan-reap.d.ts +15 -19
  57. package/dist/lib/tmux/orphan-reap.js +15 -21
  58. package/dist/lib/tmux/session.js +4 -3
  59. package/dist/lib/triggers/handlers.js +10 -0
  60. package/dist/lib/triggers/webhook.js +10 -0
  61. package/dist/lib/types.d.ts +2 -2
  62. package/package.json +1 -1
  63. package/dist/commands/set.d.ts +0 -15
  64. package/dist/commands/set.js +0 -79
  65. package/dist/commands/setup-share.d.ts +0 -17
@@ -57,8 +57,8 @@ interface ShareDeleteCliOpts {
57
57
  githubUser?: string;
58
58
  json?: boolean;
59
59
  }
60
- /** Shared handler for `agents share delete <targets...>` and the top-level
61
- * `agents unshare <targets...>` alias. Deletes each target independently and
60
+ /** Shared handler for `agents artifacts share delete <targets...>` and the
61
+ * top-level `agents unshare <targets...>` alias. Deletes each target independently and
62
62
  * continues past a failed one (rm-style), reporting all results and exiting
63
63
  * non-zero if any target failed to verify as gone.
64
64
  *
@@ -66,10 +66,23 @@ interface ShareDeleteCliOpts {
66
66
  * never exposed as a CLI flag, only used to inject a fake config/checker/deleter
67
67
  * without touching the keychain or a live endpoint. */
68
68
  export declare function runShareDelete(targets: string[], opts: ShareDeleteCliOpts, deleteFn?: typeof deleteShare): Promise<void>;
69
- export declare function registerShareCommands(program: Command): void;
69
+ /**
70
+ * Register the `share` subtree under its parent group — `agents artifacts share`
71
+ * (see commands/artifacts.ts). The top-level `agents unshare` alias is a sibling
72
+ * registration on the ROOT program, so it is {@link registerUnshareCommand}, not
73
+ * part of this subtree.
74
+ */
75
+ export declare function registerShareCommands(artifactsCmd: Command): void;
76
+ /**
77
+ * Register the top-level `agents unshare <targets...>` alias of
78
+ * `agents artifacts share delete`. It takes the ROOT program (not the artifacts
79
+ * group) because it is deliberately a top-level convenience verb — taking a page
80
+ * down is the one artifact action typed often enough to keep at the root.
81
+ */
82
+ export declare function registerUnshareCommand(program: Command): void;
70
83
  /** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
71
- * endpoint config + write token. Shared by `agents share setup` and the unified
72
- * `agents setup share` wizard. */
84
+ * endpoint config + write token. Shared by both modes of `agents artifacts setup`
85
+ * (the flag-driven provision and the interactive wizard). */
73
86
  export declare function runShareProvision(opts: {
74
87
  bundle: string;
75
88
  worker: string;
@@ -101,8 +114,8 @@ export declare function runShareUpdate(opts?: {
101
114
  request?: CloudflareRequester;
102
115
  }): Promise<ShareUpdateResult>;
103
116
  /** Join an existing share endpoint (no provisioning): prompt for the endpoint
104
- * details + write token and persist them. Shared by `agents share join` and the
105
- * unified `agents setup share` wizard. */
117
+ * details + write token and persist them. Shared by `agents artifacts share join`
118
+ * and the `agents artifacts setup` wizard. */
106
119
  export declare function runShareJoin(baseUrl?: string, opts?: {
107
120
  token?: string;
108
121
  }): Promise<void>;
@@ -1,5 +1,9 @@
1
- // `agents share` — publish an HTML file to your own Cloudflare R2 behind a tiny
2
- // Worker, and get a shareable link (~$0). See apps/cli/docs/share.md.
1
+ // `agents artifacts share` — publish an HTML file to your own Cloudflare R2
2
+ // behind a tiny Worker, and get a shareable link (~$0). See apps/cli/docs/share.md.
3
+ //
4
+ // Registered under the `artifacts` group by commands/artifacts.ts; the
5
+ // provisioning door lives beside it at `agents artifacts setup`
6
+ // (commands/artifacts-setup.ts), which calls `runShareProvision` below.
3
7
  import { existsSync } from 'node:fs';
4
8
  import chalk from 'chalk';
5
9
  import { DEFAULT_BUCKET_NAME, DEFAULT_CF_BUNDLE, DEFAULT_SHARE_DOMAIN, DEFAULT_WORKER_NAME, generateWriteToken, readCloudflareCreds, readShareConfig, readWriteToken, readWriteTokenEnv, readWriteTokenFromBundle, storeWriteToken, writeShareConfig, } from '../lib/share/config.js';
@@ -35,10 +39,10 @@ export function shareTemplateStatus(cfg) {
35
39
  }
36
40
  /** Shown whenever the deployed Worker has no `?format=json` listing route — an
37
41
  * endpoint provisioned before this feature. Points at the RUSH-2449 update path
38
- * (`agents share update`) instead of letting the caller hit a 404 or an HTML body
39
- * and get a confusing parse error. */
40
- const OUTDATED_TEMPLATE_HINT = 'Your deployed share Worker has no machine-readable listing route — it predates `agents share list`. ' +
41
- 'Run `agents share update` to deploy the current Worker template, then retry (`agents share status` shows whether an update is due).';
42
+ * (`agents artifacts share update`) instead of letting the caller hit a 404 or an
43
+ * HTML body and get a confusing parse error. */
44
+ const OUTDATED_TEMPLATE_HINT = 'Your deployed share Worker has no machine-readable listing route — it predates `agents artifacts share list`. ' +
45
+ 'Run `agents artifacts share update` to deploy the current Worker template, then retry (`agents artifacts share status` shows whether an update is due).';
42
46
  async function defaultListingFetch(url) {
43
47
  const res = await fetch(url, { headers: { accept: 'application/json' } });
44
48
  return { status: res.status, contentType: res.headers.get('content-type') ?? '', body: await res.text() };
@@ -80,7 +84,7 @@ export function parseShareListing(user, body) {
80
84
  export async function runShareList(opts = {}) {
81
85
  const cfg = opts.config ?? readShareConfig();
82
86
  if (!cfg) {
83
- throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
87
+ throw new Error("Not set up yet. Run 'agents artifacts setup' (provision your own endpoint) or 'agents artifacts share join' (use an existing one).");
84
88
  }
85
89
  // A known-stale template can't have the listing route — say so before any network
86
90
  // call. 'unknown' (provisioned before templateHash tracking) is attempted, then
@@ -100,14 +104,14 @@ export async function runShareList(opts = {}) {
100
104
  // endpoint predates the listing route entirely. The recorded templateHash
101
105
  // disambiguates: a 'current' template HAS the route, so its 404 means an empty
102
106
  // namespace ("nothing published"); otherwise the route may be absent, so point
103
- // at `agents share update`.
107
+ // at `agents artifacts share update`.
104
108
  if (templateStatus === 'current') {
105
109
  return { user, count: 0, objects: [] };
106
110
  }
107
111
  throw new Error(OUTDATED_TEMPLATE_HINT);
108
112
  }
109
113
  if (res.status !== 200) {
110
- throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents share setup' completed.`);
114
+ throw new Error(`Listing failed (${res.status}) for ${listUrl}. Check the endpoint is reachable, or that 'agents artifacts setup' completed.`);
111
115
  }
112
116
  if (!/application\/json/i.test(res.contentType)) {
113
117
  // A 200 that isn't JSON means the old Worker ignored ?format=json and served
@@ -157,8 +161,8 @@ export function formatShareDeleteResult(result, json = false) {
157
161
  }
158
162
  return lines.join('\n');
159
163
  }
160
- /** Shared handler for `agents share delete <targets...>` and the top-level
161
- * `agents unshare <targets...>` alias. Deletes each target independently and
164
+ /** Shared handler for `agents artifacts share delete <targets...>` and the
165
+ * top-level `agents unshare <targets...>` alias. Deletes each target independently and
162
166
  * continues past a failed one (rm-style), reporting all results and exiting
163
167
  * non-zero if any target failed to verify as gone.
164
168
  *
@@ -200,10 +204,10 @@ function registerShareDeleteOptions(cmd) {
200
204
  }
201
205
  const SHARE_DELETE_EXAMPLES = `
202
206
  # Delete by full URL — also takes down the sibling OG cover
203
- agents share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
207
+ agents artifacts share delete https://share.agents-cli.sh/octocat/my-plan-a1b2
204
208
 
205
209
  # Delete by <user>/<slug>, or a bare slug in your own namespace
206
- agents share delete octocat/my-plan-a1b2
210
+ agents artifacts share delete octocat/my-plan-a1b2
207
211
  agents unshare my-plan-a1b2
208
212
 
209
213
  # Several at once
@@ -220,17 +224,23 @@ const SHARE_DELETE_NOTES = `
220
224
  Worker's DELETE is idempotent and returns {"ok":true} even for a key that was
221
225
  never there, so that response alone is never proof of a takedown.
222
226
 
223
- agents share delete === agents unshare (same command, different name).
227
+ agents artifacts share delete === agents unshare (same command, different name).
224
228
  `;
225
- export function registerShareCommands(program) {
226
- const shareCmd = program
229
+ /**
230
+ * Register the `share` subtree under its parent group — `agents artifacts share`
231
+ * (see commands/artifacts.ts). The top-level `agents unshare` alias is a sibling
232
+ * registration on the ROOT program, so it is {@link registerUnshareCommand}, not
233
+ * part of this subtree.
234
+ */
235
+ export function registerShareCommands(artifactsCmd) {
236
+ const shareCmd = artifactsCmd
227
237
  .command('share')
228
238
  .description('Publish an HTML file to your own Cloudflare R2 and get a shareable link (~$0).')
229
239
  .argument('[file]', 'file to publish (HTML or any static asset)')
230
240
  .option('--slug <slug>', 'custom URL slug under your namespace (default: <project>-<feature>-<hash>)')
231
241
  .option('--github-user <user>', 'GitHub username for the share namespace (default: resolved from gh/git config)')
232
242
  .option('--expire <spec>', "auto-expire (default 30d). e.g. 12h, 30d, 2026-08-01, or 'never'")
233
- .option('--unlisted', 'hide from the public gallery and `agents share list` (direct URL still works)')
243
+ .option('--unlisted', 'hide from the public gallery and `agents artifacts share list` (direct URL still works)')
234
244
  .option('--private', 'alias of --unlisted')
235
245
  .option('--force', 'publish even when the file contains emails or credential-shaped strings')
236
246
  .option('--no-cover', 'skip the OG preview image (HTML pages get one by default)')
@@ -266,30 +276,30 @@ export function registerShareCommands(program) {
266
276
  setHelpSections(shareCmd, {
267
277
  examples: `
268
278
  # Publish an HTML file — auto OG cover, default 30d expiry, shareable link
269
- agents share ./out/plan.html
279
+ agents artifacts share ./out/plan.html
270
280
 
271
281
  # Hide from the public gallery (direct URL still works) and expire sooner
272
- agents share ./out/report.html --unlisted --expire 12h
282
+ agents artifacts share ./out/report.html --unlisted --expire 12h
273
283
 
274
284
  # Permanent public page (opt out of the default 30d expiry)
275
- agents share ./out/landing.html --slug landing --expire never
285
+ agents artifacts share ./out/landing.html --slug landing --expire never
276
286
 
277
287
  # Custom slug, expiring in 7 days
278
- agents share ./out/report.html --slug q3-report --expire 7d
288
+ agents artifacts share ./out/report.html --slug q3-report --expire 7d
279
289
  ${SHARE_DELETE_EXAMPLES}
280
290
  # One-time setup (or join an existing endpoint)
281
- agents share setup
282
- agents share join https://share.agents-cli.sh
291
+ agents artifacts setup
292
+ agents artifacts share join https://share.agents-cli.sh
283
293
 
284
294
  # Push a worker-template.ts change out to an already-provisioned endpoint
285
- agents share update
295
+ agents artifacts share update
286
296
  `,
287
297
  notes: `
288
298
  Default expiry is 30d so an accidental publish decays. Pass --expire never for
289
299
  a permanent link. --unlisted / --private hides the page from the public gallery
290
- and agents share list; the direct URL is still world-readable (unlisted, not
291
- secret). A pre-publish scan refuses emails and credential-shaped strings
292
- unless --force is passed.
300
+ and agents artifacts share list; the direct URL is still world-readable
301
+ (unlisted, not secret). A pre-publish scan refuses emails and credential-shaped
302
+ strings unless --force is passed.
293
303
  ${SHARE_DELETE_NOTES}
294
304
  `,
295
305
  });
@@ -300,32 +310,6 @@ ${SHARE_DELETE_NOTES}
300
310
  shareDeleteCmd.action(async (targets, opts) => {
301
311
  await runShareDelete(targets, opts);
302
312
  });
303
- const unshareCmd = registerShareDeleteOptions(program
304
- .command('unshare <targets...>')
305
- .description('Alias of `agents share delete` — take down a published page (and by default its OG cover).'));
306
- setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
307
- unshareCmd.action(async (targets, opts) => {
308
- await runShareDelete(targets, opts);
309
- });
310
- shareCmd
311
- .command('setup')
312
- .description('One-time: provision an R2 bucket + Worker on your Cloudflare and save the config.')
313
- .option('--bundle <name>', 'secrets bundle holding the Cloudflare API token', DEFAULT_CF_BUNDLE)
314
- .option('--worker <name>', 'Worker name', DEFAULT_WORKER_NAME)
315
- .option('--bucket <name>', 'R2 bucket name', DEFAULT_BUCKET_NAME)
316
- .option('--account <id>', 'Cloudflare account id (else read from the bundle / prompt)')
317
- .option('--token <t>', 'Cloudflare API token (else read from the --bundle)')
318
- .option('--domain <host>', `custom domain to map (default: ${DEFAULT_SHARE_DOMAIN}; workers.dev if zone is not visible)`)
319
- .option('--analytics-token <token>', 'Cloudflare Web Analytics token to inject into published HTML pages')
320
- .action(async (opts) => {
321
- try {
322
- await runShareProvision(opts);
323
- }
324
- catch (e) {
325
- console.error(chalk.red(e.message));
326
- process.exitCode = 1;
327
- }
328
- });
329
313
  shareCmd
330
314
  .command('join')
331
315
  .description('Use an existing synced share endpoint and write token (no provisioning).')
@@ -370,15 +354,15 @@ ${SHARE_DELETE_NOTES}
370
354
  setHelpSections(shareUpdateCmd, {
371
355
  examples: `
372
356
  # Push a worker-template.ts change out to your already-provisioned endpoint
373
- agents share update
357
+ agents artifacts share update
374
358
 
375
359
  # Force a re-deploy even though the template hash already matches
376
- agents share update --force
360
+ agents artifacts share update --force
377
361
  `,
378
362
  notes: `
379
- Reuses the existing account/worker/bucket from 'agents share status' and the
363
+ Reuses the existing account/worker/bucket from 'agents artifacts share status' and the
380
364
  existing write token — it never re-provisions a bucket, touches routes, or
381
- regenerates the token. See 'agents share status' for whether an update is due.
365
+ regenerates the token. See 'agents artifacts share status' for whether an update is due.
382
366
  `,
383
367
  });
384
368
  shareCmd
@@ -387,7 +371,7 @@ ${SHARE_DELETE_NOTES}
387
371
  .action(async () => {
388
372
  const cfg = readShareConfig();
389
373
  if (!cfg) {
390
- console.log(chalk.dim("Not configured. Run 'agents share setup' or 'agents share join'."));
374
+ console.log(chalk.dim("Not configured. Run 'agents artifacts setup' or 'agents artifacts share join'."));
391
375
  return;
392
376
  }
393
377
  console.log(`${chalk.bold('endpoint')} ${chalk.green(cfg.baseUrl)}`);
@@ -399,8 +383,8 @@ ${SHARE_DELETE_NOTES}
399
383
  const templateLabel = templateStatus === 'current'
400
384
  ? chalk.green('current')
401
385
  : templateStatus === 'outdated'
402
- ? chalk.yellow('outdated — run `agents share update`')
403
- : chalk.dim("unknown — provisioned before version tracking; run `agents share update` to adopt it");
386
+ ? chalk.yellow('outdated — run `agents artifacts share update`')
387
+ : chalk.dim("unknown — provisioned before version tracking; run `agents artifacts share update` to adopt it");
404
388
  console.log(`${chalk.bold('template')} ${templateLabel}`);
405
389
  });
406
390
  const shareListCmd = shareCmd
@@ -421,21 +405,21 @@ ${SHARE_DELETE_NOTES}
421
405
  setHelpSections(shareListCmd, {
422
406
  examples: `
423
407
  # Everything you've published, newest first
424
- agents share list
408
+ agents artifacts share list
425
409
 
426
410
  # Machine-readable — e.g. pull every still-public URL with jq
427
- agents share list --json | jq -r '.objects[].url'
411
+ agents artifacts share list --json | jq -r '.objects[].url'
428
412
 
429
413
  # List another namespace
430
- agents share list --github-user octocat
414
+ agents artifacts share list --github-user octocat
431
415
  `,
432
416
  notes: `
433
417
  Lists the ACTIVE pages in your namespace — expired links and the sibling .png OG
434
418
  covers are omitted (it mirrors the public gallery). It reads the endpoint's JSON
435
419
  listing route, which ships with the current Worker template. If your deployed
436
- Worker predates this feature the command says so and points you at 'agents share
437
- update' (RUSH-2449) rather than returning a wrong or empty result — see 'agents
438
- share status' for whether an update is due.
420
+ Worker predates this feature the command says so and points you at 'agents
421
+ artifacts share update' (RUSH-2449) rather than returning a wrong or empty result
422
+ — see 'agents artifacts share status' for whether an update is due.
439
423
  `,
440
424
  });
441
425
  shareCmd
@@ -444,7 +428,7 @@ ${SHARE_DELETE_NOTES}
444
428
  .action(async () => {
445
429
  const cfg = readShareConfig();
446
430
  if (!cfg) {
447
- console.log(chalk.dim("Not configured. Run 'agents share setup' or 'agents share join'."));
431
+ console.log(chalk.dim("Not configured. Run 'agents artifacts setup' or 'agents artifacts share join'."));
448
432
  return;
449
433
  }
450
434
  if (!analyticsEnabled(cfg)) {
@@ -464,9 +448,24 @@ ${SHARE_DELETE_NOTES}
464
448
  }
465
449
  });
466
450
  }
451
+ /**
452
+ * Register the top-level `agents unshare <targets...>` alias of
453
+ * `agents artifacts share delete`. It takes the ROOT program (not the artifacts
454
+ * group) because it is deliberately a top-level convenience verb — taking a page
455
+ * down is the one artifact action typed often enough to keep at the root.
456
+ */
457
+ export function registerUnshareCommand(program) {
458
+ const unshareCmd = registerShareDeleteOptions(program
459
+ .command('unshare <targets...>')
460
+ .description('Alias of `agents artifacts share delete` — take down a published page (and by default its OG cover).'));
461
+ setHelpSections(unshareCmd, { examples: SHARE_DELETE_EXAMPLES, notes: SHARE_DELETE_NOTES });
462
+ unshareCmd.action(async (targets, opts) => {
463
+ await runShareDelete(targets, opts);
464
+ });
465
+ }
467
466
  /** Provision a fresh R2 bucket + Worker on the user's Cloudflare and persist the
468
- * endpoint config + write token. Shared by `agents share setup` and the unified
469
- * `agents setup share` wizard. */
467
+ * endpoint config + write token. Shared by both modes of `agents artifacts setup`
468
+ * (the flag-driven provision and the interactive wizard). */
470
469
  export async function runShareProvision(opts) {
471
470
  const { default: ora } = await import('ora');
472
471
  const { input } = await import('@inquirer/prompts');
@@ -521,7 +520,7 @@ export async function runShareProvision(opts) {
521
520
  writeShareConfig(cfg);
522
521
  storeWriteToken(token);
523
522
  console.log(chalk.green(`\nShare endpoint ready → ${chalk.bold(baseUrl)}`));
524
- console.log(chalk.dim('Publish with: ') + chalk.cyan('agents share <file>'));
523
+ console.log(chalk.dim('Publish with: ') + chalk.cyan('agents artifacts share <file>'));
525
524
  console.log(chalk.dim(`Fleet: push the token with 'agents secrets export share --host <box>' and pull config with 'agents repo pull'.`));
526
525
  }
527
526
  catch (e) {
@@ -539,7 +538,7 @@ export async function runShareProvision(opts) {
539
538
  export async function runShareUpdate(opts = {}) {
540
539
  const cfg = readShareConfig();
541
540
  if (!cfg) {
542
- throw new Error("Not configured. Run 'agents share setup' (to provision) or 'agents share join' first.");
541
+ throw new Error("Not configured. Run 'agents artifacts setup' (to provision) or 'agents artifacts share join' first.");
543
542
  }
544
543
  const { apiToken, accountId: acctFromBundle } = readCloudflareCreds(opts.bundle ?? DEFAULT_CF_BUNDLE, {
545
544
  apiToken: opts.token,
@@ -568,14 +567,14 @@ function cleanHostname(domain) {
568
567
  }
569
568
  }
570
569
  /** Join an existing share endpoint (no provisioning): prompt for the endpoint
571
- * details + write token and persist them. Shared by `agents share join` and the
572
- * unified `agents setup share` wizard. */
570
+ * details + write token and persist them. Shared by `agents artifacts share join`
571
+ * and the `agents artifacts setup` wizard. */
573
572
  export async function runShareJoin(baseUrl, opts = {}) {
574
573
  const { password, input } = await import('@inquirer/prompts');
575
574
  const existing = readShareConfig();
576
575
  const clean = baseUrl?.replace(/\/+$/, '');
577
576
  if (!clean && !existing) {
578
- throw new Error("No synced share endpoint found. Pull config first with 'agents repo pull', or pass the endpoint URL: agents share join <baseUrl>.");
577
+ throw new Error("No synced share endpoint found. Pull config first with 'agents repo pull', or pass the endpoint URL: agents artifacts share join <baseUrl>.");
579
578
  }
580
579
  let cfg;
581
580
  if (existing && (!clean || clean === existing.baseUrl)) {
@@ -608,5 +607,5 @@ export async function runShareJoin(baseUrl, opts = {}) {
608
607
  throw new Error('A write token is required to join.');
609
608
  writeShareConfig(cfg);
610
609
  storeWriteToken(token);
611
- console.log(chalk.green(`Joined ${chalk.bold(cfg.baseUrl)} — publish with `) + chalk.cyan('agents share <file>'));
610
+ console.log(chalk.green(`Joined ${chalk.bold(cfg.baseUrl)} — publish with `) + chalk.cyan('agents artifacts share <file>'));
612
611
  }
@@ -56,7 +56,8 @@ import { crabboxList, crabboxFind, crabboxSshArgv } from '../lib/crabbox/cli.js'
56
56
  import { boxAddress, boxStatus, fmtIdleShort, fmtExpiresShort, registerLeaseCommand } from './lease.js';
57
57
  import { authCellColor, formatCheckedAge, isDeadVerdict, readAuthHealthCache, summarizeHostAuth, summarizeVerdicts, verdictColor, verdictLabel, writeFleetAuthRows, } from '../lib/auth-health.js';
58
58
  import { runFleetLogin } from '../lib/fleet/remote-login.js';
59
- import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec } from '../lib/device-config.js';
59
+ import { getConfigValue, listConfig, setConfigValue, unsetConfigValue, configKeySpec, autoPoolMode, configuredDeviceRole, listConfiguredDeviceRoles, setConfiguredDeviceRole, } from '../lib/device-config.js';
60
+ import { filterAutoPool, listWorkerDevices } from '../lib/devices/pool.js';
60
61
  import { registerCommandGroups, setHelpSections } from '../lib/help.js';
61
62
  /** One-line summary of a device for `list`. `isSelf` marks the machine this
62
63
  * command is running on so it stands out from the rest of the tailnet.
@@ -77,7 +78,20 @@ function deviceSummary(d, isSelf = false, stats, isInteractive = false) {
77
78
  const name = isSelf ? chalk.bold.cyan(d.name.padEnd(16)) : chalk.bold(d.name.padEnd(16));
78
79
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
79
80
  const interactive = isInteractive ? chalk.yellow(' ★ interactive') : '';
80
- return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}`;
81
+ const role = roleTag(d.name, listConfiguredDeviceRoles());
82
+ return `${marker}${name} ${String(d.platform).padEnd(8)} ${(d.user ? d.user + '@' : '') + addr} ${online}${reach}${here}${interactive}${role}`;
83
+ }
84
+ /** The fleet-wide role mark, rendered for a device row. Empty when unmarked —
85
+ * an unmarked device is the common case and must not add a column of noise. */
86
+ function roleTag(name, roles) {
87
+ const role = roles[name];
88
+ if (!role)
89
+ return '';
90
+ if (role === 'worker')
91
+ return chalk.green(' worker');
92
+ if (role === 'personal')
93
+ return chalk.yellow(' personal');
94
+ return chalk.gray(' control');
81
95
  }
82
96
  const HEADROOM_BADGE = {
83
97
  idle: chalk.green('○ idle'),
@@ -107,6 +121,7 @@ function pctCell(v, width) {
107
121
  function renderDeviceTable(reg, names, self, statsMap, full = false, interactiveHost) {
108
122
  if (!statsMap)
109
123
  return names.map((n) => deviceSummary(reg[n], n === self, undefined, n === interactiveHost));
124
+ const deviceRoles = listConfiguredDeviceRoles();
110
125
  const lines = [];
111
126
  const head = ' ' +
112
127
  chalk.gray('device'.padEnd(16)) +
@@ -149,7 +164,7 @@ function renderDeviceTable(reg, names, self, statsMap, full = false, interactive
149
164
  const badge = HEADROOM_BADGE[headroom(stats)];
150
165
  const here = isSelf ? chalk.cyan(' ← this machine') : '';
151
166
  const interactive = name === interactiveHost ? chalk.yellow(' ★ interactive') : '';
152
- lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}`);
167
+ lines.push(`${marker}${label}${plat} ${cores}${load}${mem}${freeTotal} ${badge}${relay}${here}${interactive}${roleTag(name, deviceRoles)}`);
153
168
  }
154
169
  // Fleet capacity summary — total cores + how much RAM is free right now.
155
170
  const cap = fleetCapacity(statsMap.values());
@@ -1161,6 +1176,84 @@ function registerDevicesCommands(program) {
1161
1176
  }
1162
1177
  await runDevicesConfigMenu(name);
1163
1178
  };
1179
+ /**
1180
+ * The `devices role` engine — read or write the fleet-wide role mark, and say
1181
+ * what it does to automatic placement.
1182
+ *
1183
+ * A role written here lands in the SHARED block (`fleet.devices.<name>.config.role`)
1184
+ * because every box has to agree on it. The vocabulary is deliberately
1185
+ * `worker | personal` only: a paired cockpit's `control` role lives in the
1186
+ * per-machine device registry, is written by `agents devices pair-ios`, and is
1187
+ * what the existing dial-exclusion filters (`isControlDevice`) read. Accepting
1188
+ * `control` here too would promise a fleet-wide dial exclusion this key cannot
1189
+ * deliver — those filters read each box's own registry, so the mark would only
1190
+ * hold on the machine that ran the command.
1191
+ */
1192
+ const runDevicesRole = async (name, role, opts) => {
1193
+ if (!name) {
1194
+ if (role)
1195
+ throw new Error('Name a device: agents devices role <name> <worker|personal>');
1196
+ const roles = listConfiguredDeviceRoles();
1197
+ const mode = autoPoolMode();
1198
+ const reg = await loadDevices();
1199
+ const online = Object.entries(reg)
1200
+ .filter(([, d]) => d?.tailscale?.online !== false)
1201
+ .map(([n]) => n);
1202
+ const pool = filterAutoPool(online, { mode, roles });
1203
+ if (opts.json) {
1204
+ writeJson({ mode, roles, autoPool: pool });
1205
+ return;
1206
+ }
1207
+ const marked = Object.entries(roles);
1208
+ if (marked.length === 0) {
1209
+ console.log(chalk.gray('No device is marked. `--device auto` considers every online device.'));
1210
+ }
1211
+ else {
1212
+ for (const [device, r] of marked) {
1213
+ const tint = r === 'worker' ? chalk.green : r === 'personal' ? chalk.yellow : chalk.gray;
1214
+ console.log(` ${device.padEnd(20)} ${tint(r)}`);
1215
+ }
1216
+ }
1217
+ console.log();
1218
+ console.log(chalk.bold('--device auto picks from: ') + (pool.length > 0 ? pool.join(', ') : chalk.red('nothing — no eligible device')));
1219
+ if (mode === 'all')
1220
+ console.log(chalk.gray('auto.pool=all — worker marks are ignored (a personal device is still excluded).'));
1221
+ return;
1222
+ }
1223
+ await mustGetDevice(name);
1224
+ if (opts.clear || role === 'none') {
1225
+ setConfiguredDeviceRole(name, undefined);
1226
+ if (opts.json)
1227
+ writeJson({ device: name, role: null });
1228
+ else
1229
+ console.log(chalk.green(`Cleared the role on '${name}'.`));
1230
+ return;
1231
+ }
1232
+ if (!role) {
1233
+ const current = configuredDeviceRole(name);
1234
+ if (opts.json)
1235
+ writeJson({ device: name, role: current ?? null });
1236
+ else
1237
+ console.log(` ${name.padEnd(20)} ${current ? chalk.cyan(current) : chalk.gray('— (unmarked)')}`);
1238
+ return;
1239
+ }
1240
+ // configuredDeviceRole's key spec validates the value; a bad one throws with
1241
+ // the accepted list, which the command's catch turns into exit 1.
1242
+ setConfiguredDeviceRole(name, role);
1243
+ if (opts.json) {
1244
+ writeJson({ device: name, role, autoPoolWorkers: listWorkerDevices() });
1245
+ return;
1246
+ }
1247
+ console.log(chalk.green(`Marked '${name}' role=${role}.`));
1248
+ const workers = listWorkerDevices();
1249
+ if (workers.length > 0) {
1250
+ console.log(chalk.gray(`\`--device auto\` now picks only from: ${workers.join(', ')}`));
1251
+ }
1252
+ else {
1253
+ console.log(chalk.gray('No device is marked worker, so `--device auto` still considers every online device.'));
1254
+ }
1255
+ console.log(chalk.gray('Sync it to the fleet with `agents repo push`.'));
1256
+ };
1164
1257
  /** The interactive settings menu: pick a key, edit it, repeat. TTY-only. */
1165
1258
  const runDevicesConfigMenu = async (name) => {
1166
1259
  const { select, input, confirm } = await import('@inquirer/prompts');
@@ -1250,22 +1343,27 @@ function registerDevicesCommands(program) {
1250
1343
  agents devices config win-mini ssh.auth password # password auth…
1251
1344
  agents devices config win-mini ssh.bundle muqsit # …from this secrets bundle
1252
1345
  agents devices config worker ssh.identity-file ~/.ssh/worker_ed25519
1346
+ agents devices config mac-mini role worker # same as \`agents devices role mac-mini worker\`
1253
1347
  agents devices config mac-mini auto-launch.enabled off # exclude from AGI EXT auto-launch
1254
1348
  agents devices config mac-mini auto-launch.preferred on # boost in auto-launch ranking
1255
1349
  agents devices config zion interactive.host zion # user scope: where agents show YOU artifacts
1256
1350
  agents devices config mac-mini --json # machine-readable
1257
1351
  `,
1258
1352
  notes: `
1259
- Keys: agents.max-concurrent, scheduler.enabled, daemon.enabled,
1260
- watchdog.enabled, browser.remote-control, browser.profile, notes,
1261
- ssh.user, ssh.auth (key|password), ssh.bundle, ssh.bundle-key,
1353
+ Keys: role (worker|personal), see 'agents devices role',
1354
+ agents.max-concurrent, scheduler.enabled, daemon.enabled,
1355
+ watchdog.enabled, tmux.enabled, browser.remote-control, browser.profile,
1356
+ notes, ssh.user, ssh.auth (key|password), ssh.bundle, ssh.bundle-key,
1262
1357
  ssh.identity-file, platform (windows|linux|macos|unknown),
1263
1358
  auto-launch.enabled, auto-launch.preferred — plus the user-scope
1264
1359
  interactive.host (stored centrally; the device name is syntax only).
1265
1360
 
1266
1361
  Booleans take on/off (or true/false). 'notes' appends one entry per
1267
- invocation. Values land in ~/.agents/agents.yaml under
1268
- fleet.devices.<name>.config and sync with 'agents repo push/pull'.
1362
+ invocation. Values a PEER reads land in ~/.agents/agents.yaml under
1363
+ fleet.devices.<name>.config and sync with 'agents repo push/pull'. The
1364
+ keys only the owning box reads — scheduler.enabled, daemon.enabled,
1365
+ tmux.enabled, browser.remote-control, browser.profile — stay in that
1366
+ machine's own doc, never sync, and can only be set on the device itself.
1269
1367
  ssh.* / platform / user overlay the discovered registry profile at dial
1270
1368
  time. scheduler.enabled / daemon.enabled take effect when the daemon
1271
1369
  reloads or restarts on that device.
@@ -1274,6 +1372,49 @@ function registerDevicesCommands(program) {
1274
1372
  set, set-interactive, enable, disable, prefer, unprefer.
1275
1373
  `,
1276
1374
  });
1375
+ const roleCmd = devicesCmd
1376
+ .command('role [name] [role]')
1377
+ .description('Show or set what a device is for: worker (agents run here) or personal (you sit here — never picked automatically). ' +
1378
+ 'Marking any device worker makes `--device auto` an allowlist over the marked workers.')
1379
+ .option('--clear', 'remove the mark, returning the device to unmarked')
1380
+ .option('--json', 'output machine-readable JSON')
1381
+ .action(async (name, role, opts) => {
1382
+ try {
1383
+ await runDevicesRole(name, role, opts);
1384
+ }
1385
+ catch (err) {
1386
+ console.error(chalk.red(err.message));
1387
+ process.exit(1);
1388
+ }
1389
+ });
1390
+ setHelpSections(roleCmd, {
1391
+ examples: `
1392
+ agents devices role # who is marked what, and what --device auto would pick
1393
+ agents devices role yosemite-s0 worker # agents spin up here
1394
+ agents devices role yosemite-s1 worker # …and here; auto now rotates over these two only
1395
+ agents devices role zion personal # your laptop — keep automatic placement off it
1396
+ agents devices role yosemite-s0 --clear # unmark
1397
+ agents devices role --json # machine-readable
1398
+ `,
1399
+ notes: `
1400
+ Roles are stored fleet-wide in ~/.agents/agents.yaml under
1401
+ fleet.devices.<name>.config.role and travel with 'agents repo push/pull',
1402
+ so a mark set on one box is the whole fleet's answer.
1403
+
1404
+ Effect on '--device auto' (agents run, teams, agents ssh auto, and the AGI
1405
+ EXT launch commands, which all resolve placement through the CLI):
1406
+ no device marked -> every online device, as before
1407
+ any worker marked -> ONLY the marked workers
1408
+ personal -> never picked, under either state
1409
+
1410
+ Turn the allowlist off with 'agents config set auto.pool all'; a personal
1411
+ box stays excluded, since that is what the mark is for.
1412
+
1413
+ A paired iPhone/iPad cockpit is a separate role: 'agents devices pair-ios'
1414
+ marks it control in that box's device registry, and the fleet never dials
1415
+ it — including for placement. This command does not set that role.
1416
+ `,
1417
+ });
1277
1418
  /** Deprecation notice for a retired subcommand — STDERR only, so a --json consumer's stdout stays parseable. */
1278
1419
  const configTombstoneNotice = (retired, replacement) => {
1279
1420
  console.error(chalk.yellow(`Deprecated: "agents devices ${retired}" is now "agents devices ${replacement}". Running that for you.\n`));
@@ -1493,12 +1634,19 @@ function registerDevicesCommands(program) {
1493
1634
  await writeReachability(collectReachabilityWriteBacks(reg, statsMap)).catch(() => { });
1494
1635
  }
1495
1636
  if (opts.json) {
1637
+ const jsonRoles = listConfiguredDeviceRoles();
1638
+ const autoPool = new Set(filterAutoPool(names, { roles: jsonRoles }));
1496
1639
  process.stdout.write(JSON.stringify(names.map((name) => {
1497
1640
  const config = deviceConfigJson(name);
1498
1641
  const health = statsMap?.get(name);
1499
1642
  return {
1500
1643
  ...resolveDeviceProfile(reg[name]),
1501
1644
  interactive: name === interactiveHost,
1645
+ // Roles as machine-readable fields: `role` is what the operator
1646
+ // marked (absent when unmarked), `autoPool` is the answer that
1647
+ // matters to a caller — may `--device auto` pick this box.
1648
+ ...(jsonRoles[name] ? { role: jsonRoles[name] } : {}),
1649
+ autoPool: autoPool.has(name),
1502
1650
  ...(config ? { config } : {}),
1503
1651
  ...(health ? { health: { ...health, headroom: headroom(health) } } : {}),
1504
1652
  };