@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
@@ -58,6 +58,42 @@ export declare function foldBrowserSessionsIntoProfiles(browserDir?: string): vo
58
58
  * can drive a fixture tree without touching the user's ~/.agents.
59
59
  */
60
60
  export declare function repairSelfReferentialBinShims(versionsRoot?: string, shimsDir?: string, historyDir?: string): void;
61
+ /**
62
+ * Move the auto-detected `default` browser profile OUT of the committed central
63
+ * agents.yaml and into this machine's per-device file.
64
+ *
65
+ * `browser` is a CENTRAL key because named profiles a user creates are real fleet
66
+ * config, but the ONE `default` entry inside it is machine-local: its `binary` is
67
+ * an OS-specific path and its endpoint is a locally-chosen free port.
68
+ * `createProfile`/`updateProfile` already route that entry to `deviceBrowser`
69
+ * (`isMachineLocalProfile`, browser/profiles.ts) — but nothing ever removed the
70
+ * copy older versions had already written into the shared file, and
71
+ * `serializeCentral` cannot: it deletes whole KEYS that are device-scoped, and
72
+ * `browser` is not one.
73
+ *
74
+ * So the entry sat in the committed file and every box rewrote it with its own
75
+ * browser. Measured 2026-08-13, all three boxes on 1.22.38 (which HAS the writer
76
+ * fix) with an empty `deviceBrowser` and a machine-specific `default` in central:
77
+ *
78
+ * zion browser: chrome binary: /Applications/Google Chrome.app/...
79
+ * yosemite-s1 browser: brave binary: /opt/brave.com/brave/brave
80
+ * mark-1 (same shape)
81
+ *
82
+ * `agents repos pull user` refuses when an incoming change touches a locally
83
+ * modified path, so agents.yaml being permanently dirty wedged the fleet config
84
+ * sync outright — those boxes sat 5, 8 and 79 commits behind. (RUSH-2161)
85
+ *
86
+ * Idempotent: no-op once central carries no `default` entry. The device file is
87
+ * written FIRST so a crash between the two writes can never lose the profile,
88
+ * and an entry already in the device file wins (this machine's live value is
89
+ * newer than the stale central copy by construction).
90
+ *
91
+ * Central is edited through a `yaml.Document` rather than re-stringified, so the
92
+ * hand-written comments in the committed agents.yaml survive — a plain
93
+ * `yaml.stringify` would drop every one of them and rewrite the whole file,
94
+ * which is the same churn this migration exists to stop (see `serializeCentral`).
95
+ */
96
+ export declare function migrateMachineLocalBrowserProfileOutOfCentral(userDir?: string, machine?: string): void;
61
97
  /**
62
98
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
63
99
  * inside every installed agent version-home, and rewrite cross-references in
@@ -21,6 +21,10 @@ import { setConfigValue } from './device-config.js';
21
21
  import { enabledRoutineNames, replaceEnabledRoutines } from './routine-activation.js';
22
22
  import { evaluateActivationReadiness } from './routine-readiness.js';
23
23
  import { migrateDeviceConfigToCentral } from './devices/config-migration.js';
24
+ // Two constants only, never the read/write API — migrations still operate on raw
25
+ // YAML so they never take the meta lock or prime the meta cache mid-migration.
26
+ import { DEFAULT_BROWSER_PROFILE_NAME } from './browser/profiles.js';
27
+ import { META_HEADER as DEVICE_META_HEADER } from './state.js';
24
28
  const HOME = process.env.HOME ?? os.homedir();
25
29
  const USER_DIR = path.join(HOME, '.agents');
26
30
  /** Canonical system-repo location (post-fold). */
@@ -1688,6 +1692,103 @@ function migrateSplitDeviceLocalMeta() {
1688
1692
  console.error('Split agents.yaml: agents: -> devices/, versions: -> .history/version-resources.json');
1689
1693
  }
1690
1694
  }
1695
+ /**
1696
+ * Move the auto-detected `default` browser profile OUT of the committed central
1697
+ * agents.yaml and into this machine's per-device file.
1698
+ *
1699
+ * `browser` is a CENTRAL key because named profiles a user creates are real fleet
1700
+ * config, but the ONE `default` entry inside it is machine-local: its `binary` is
1701
+ * an OS-specific path and its endpoint is a locally-chosen free port.
1702
+ * `createProfile`/`updateProfile` already route that entry to `deviceBrowser`
1703
+ * (`isMachineLocalProfile`, browser/profiles.ts) — but nothing ever removed the
1704
+ * copy older versions had already written into the shared file, and
1705
+ * `serializeCentral` cannot: it deletes whole KEYS that are device-scoped, and
1706
+ * `browser` is not one.
1707
+ *
1708
+ * So the entry sat in the committed file and every box rewrote it with its own
1709
+ * browser. Measured 2026-08-13, all three boxes on 1.22.38 (which HAS the writer
1710
+ * fix) with an empty `deviceBrowser` and a machine-specific `default` in central:
1711
+ *
1712
+ * zion browser: chrome binary: /Applications/Google Chrome.app/...
1713
+ * yosemite-s1 browser: brave binary: /opt/brave.com/brave/brave
1714
+ * mark-1 (same shape)
1715
+ *
1716
+ * `agents repos pull user` refuses when an incoming change touches a locally
1717
+ * modified path, so agents.yaml being permanently dirty wedged the fleet config
1718
+ * sync outright — those boxes sat 5, 8 and 79 commits behind. (RUSH-2161)
1719
+ *
1720
+ * Idempotent: no-op once central carries no `default` entry. The device file is
1721
+ * written FIRST so a crash between the two writes can never lose the profile,
1722
+ * and an entry already in the device file wins (this machine's live value is
1723
+ * newer than the stale central copy by construction).
1724
+ *
1725
+ * Central is edited through a `yaml.Document` rather than re-stringified, so the
1726
+ * hand-written comments in the committed agents.yaml survive — a plain
1727
+ * `yaml.stringify` would drop every one of them and rewrite the whole file,
1728
+ * which is the same churn this migration exists to stop (see `serializeCentral`).
1729
+ */
1730
+ export function migrateMachineLocalBrowserProfileOutOfCentral(userDir = USER_DIR, machine = machineId()) {
1731
+ const metaFile = path.join(userDir, 'agents.yaml');
1732
+ if (!fs.existsSync(metaFile))
1733
+ return;
1734
+ let doc;
1735
+ try {
1736
+ doc = yaml.parseDocument(fs.readFileSync(metaFile, 'utf-8'));
1737
+ }
1738
+ catch {
1739
+ return;
1740
+ }
1741
+ if (doc.errors.length > 0)
1742
+ return;
1743
+ const central = doc.toJSON() ?? {};
1744
+ const browser = central.browser;
1745
+ if (!browser || typeof browser !== 'object' || Array.isArray(browser))
1746
+ return;
1747
+ const entry = browser[DEFAULT_BROWSER_PROFILE_NAME];
1748
+ if (entry === undefined)
1749
+ return;
1750
+ // Device file first — a crash before central is rewritten leaves a harmless
1751
+ // duplicate, while the reverse order would drop the profile entirely.
1752
+ const devicePath = path.join(userDir, 'devices', machine, 'agents.yaml');
1753
+ let deviceDoc = {};
1754
+ try {
1755
+ deviceDoc = yaml.parse(fs.readFileSync(devicePath, 'utf-8')) || {};
1756
+ }
1757
+ catch { /* absent — first write */ }
1758
+ const deviceBrowser = (deviceDoc.browser && typeof deviceDoc.browser === 'object' && !Array.isArray(deviceDoc.browser))
1759
+ ? deviceDoc.browser
1760
+ : {};
1761
+ if (deviceBrowser[DEFAULT_BROWSER_PROFILE_NAME] === undefined) {
1762
+ deviceBrowser[DEFAULT_BROWSER_PROFILE_NAME] = entry;
1763
+ deviceDoc.browser = deviceBrowser;
1764
+ fs.mkdirSync(path.dirname(devicePath), { recursive: true });
1765
+ atomicWriteFileSync(devicePath, DEVICE_META_HEADER + yaml.stringify(deviceDoc));
1766
+ }
1767
+ // Then strip it from the synced file, dropping `browser:` entirely when the
1768
+ // machine-local entry was its only member.
1769
+ doc.deleteIn(['browser', DEFAULT_BROWSER_PROFILE_NAME]);
1770
+ if (Object.keys(browser).length === 1) {
1771
+ const itemsOf = () => (doc.contents?.items) ?? [];
1772
+ const idx = itemsOf().findIndex((pair) => pair.key?.value === 'browser');
1773
+ const orphaned = idx >= 0 ? itemsOf()[idx]?.key?.commentBefore ?? undefined : undefined;
1774
+ doc.delete('browser');
1775
+ if (orphaned) {
1776
+ // Deleting shifted the following pair down into `idx`.
1777
+ const next = itemsOf()[idx]?.key;
1778
+ if (next)
1779
+ next.commentBefore = next.commentBefore ? `${orphaned}\n${next.commentBefore}` : orphaned;
1780
+ else
1781
+ doc.commentBefore = orphaned;
1782
+ }
1783
+ }
1784
+ // Everything cleared -> header only, never a bare `{}`. stringifyDoc emits a
1785
+ // FLOW empty map for an empty root, and a later parseDocument inherits that
1786
+ // flow and renders the whole rewritten file inline. serializeCentral guards
1787
+ // the identical case (state.ts, `isEmpty ? META_HEADER : stringifyDoc(doc)`).
1788
+ const remaining = Object.keys(doc.toJSON() ?? {}).length;
1789
+ atomicWriteFileSync(metaFile, remaining === 0 ? DEVICE_META_HEADER : stringifyDoc(doc));
1790
+ console.error(`Migrated agents.yaml: browser '${DEFAULT_BROWSER_PROFILE_NAME}' profile -> devices/${machine}/agents.yaml`);
1791
+ }
1691
1792
  /**
1692
1793
  * Rename the legacy `extras-extras/` plugin-marketplace dir to `agents-extras/`
1693
1794
  * inside every installed agent version-home, and rewrite cross-references in
@@ -2299,6 +2400,12 @@ export async function runMigration() {
2299
2400
  // agents.yaml. After migrateVersionResourcesToPatterns so versions: is already
2300
2401
  // in pattern form when it moves to the history file.
2301
2402
  migrateSplitDeviceLocalMeta();
2403
+ // Same split, one level deeper: `browser` stays central (named profiles are
2404
+ // fleet config) but its auto-detected `default` entry is machine-local and was
2405
+ // left behind in the synced file, keeping every box dirty. After
2406
+ // migrateSplitDeviceLocalMeta so the device file is already in its canonical
2407
+ // location before this merges an entry into it.
2408
+ migrateMachineLocalBrowserProfileOutOfCentral();
2302
2409
  // Fold per-device operator config (device-doc config:/defaultBrowserProfile
2303
2410
  // and .history/devices/auto-launch.json) into the central
2304
2411
  // fleet.devices.<name>.config block. After migrateSplitDeviceLocalMeta so the
@@ -79,7 +79,9 @@ export interface LinearJobTrigger {
79
79
  teamKey?: string;
80
80
  /** Required issue label name. */
81
81
  label?: string;
82
- /** Current Linear state name that must match (e.g. `Plan`). */
82
+ /** Fire on a transition INTO this Linear state (e.g. `Plan`): the current state
83
+ * must match AND the delivery's `updatedFrom` must record a state change, so a
84
+ * later edit that leaves the issue in this state does not re-fire (RUSH-2539). */
83
85
  stateTo?: string;
84
86
  /** Previous Linear state name that must match (e.g. `Triage`). */
85
87
  stateFrom?: string;
@@ -1,4 +1,4 @@
1
- // Cloudflare Web Analytics injection for `agents share`.
1
+ // Cloudflare Web Analytics injection for `agents artifacts share`.
2
2
  //
3
3
  // The beacon is cookieless and privacy-first, which matters because a lot of shared
4
4
  // content (games, kid-facing pages) should avoid GA4-style tracking. The token is
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
3
3
  *
4
- * When `agents share plan.html` runs, we screenshot the plan's own hero and use it
4
+ * When `agents artifacts share plan.html` runs, we screenshot the plan's own hero and use it
5
5
  * as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
6
6
  * Discord. No AI, no central render service: it's a headless screenshot on the
7
7
  * publisher's machine, so it works identically for us and for any user, and costs
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Render an HTML file to a 1200×630 PNG — the Open Graph cover for a shared plan.
3
3
  *
4
- * When `agents share plan.html` runs, we screenshot the plan's own hero and use it
4
+ * When `agents artifacts share plan.html` runs, we screenshot the plan's own hero and use it
5
5
  * as the `og:image`, so the link unfurls into a card in Slack / iMessage / Twitter /
6
6
  * Discord. No AI, no central render service: it's a headless screenshot on the
7
7
  * publisher's machine, so it works identically for us and for any user, and costs
@@ -112,7 +112,7 @@ export async function captureCover(htmlPath, timeoutMs = 15_000) {
112
112
  // A silently-dropped cover reads as "the CLI decided this page needs none",
113
113
  // when in fact no headless browser was found. Say so and point at the escape
114
114
  // hatch instead of leaving the publish coverless with no explanation.
115
- process.stderr.write('[agents share] no headless browser found for the OG cover — install Chrome/Chromium ' +
115
+ process.stderr.write('[agents artifacts share] no headless browser found for the OG cover — install Chrome/Chromium ' +
116
116
  'or set AGENTS_SHARE_BROWSER=/path/to/chrome. Publishing without a preview image.\n');
117
117
  return null;
118
118
  }
@@ -159,7 +159,7 @@ export async function captureCover(htmlPath, timeoutMs = 15_000) {
159
159
  }
160
160
  // Every candidate ran but none yielded a cover — surface the last reason so a
161
161
  // missing preview card is diagnosable (timeout, crash, bad binary) rather than silent.
162
- process.stderr.write(`[agents share] OG cover capture failed (${lastFailure || 'unknown error'}) — ` +
162
+ process.stderr.write(`[agents artifacts share] OG cover capture failed (${lastFailure || 'unknown error'}) — ` +
163
163
  'publishing without a preview image. Set AGENTS_SHARE_BROWSER to override.\n');
164
164
  return null;
165
165
  }
@@ -22,7 +22,7 @@ export declare const DEFAULT_BUCKET_NAME = "agents-share";
22
22
  export declare const DEFAULT_SHARE_DOMAIN = "share.agents-cli.sh";
23
23
  /** The write token may be injected ephemerally into fleet/cloud agents. */
24
24
  export declare function readWriteTokenEnv(env?: NodeJS.ProcessEnv): string | null;
25
- /** Read the persisted endpoint config, or null if `agents share setup`/`join` never ran. */
25
+ /** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
26
26
  export declare function readShareConfig(): ShareConfig | null;
27
27
  /** Persist the endpoint config to `agents.yaml` (syncs across the fleet). */
28
28
  export declare function writeShareConfig(cfg: ShareConfig): void;
@@ -46,7 +46,7 @@ export declare function readWriteToken(): string;
46
46
  * launch. The read is now always `agentOnly` — it resolves the token only from the
47
47
  * injected env or an already-held / no-ACL bundle, and silently returns undefined
48
48
  * otherwise (the caller runs without auto-share; the agent can still publish via
49
- * its own `agents share`). To get zero-friction auto-share with no prompt: unlock
49
+ * its own `agents artifacts share`). To get zero-friction auto-share with no prompt: unlock
50
50
  * once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
51
51
  * share never`). */
52
52
  export declare function shareRuntimeEnv(): Record<string, string> | undefined;
@@ -1,4 +1,4 @@
1
- // Config + credential glue for `agents share`.
1
+ // Config + credential glue for `agents artifacts share`.
2
2
  //
3
3
  // - The endpoint config (base URL, account, worker/bucket names) lives in
4
4
  // `agents.yaml` under `share:` (Meta.share) so it syncs fleet-wide via
@@ -23,7 +23,7 @@ export function readWriteTokenEnv(env = process.env) {
23
23
  const token = env[SHARE_TOKEN_ENV_KEY]?.trim();
24
24
  return token ? token : null;
25
25
  }
26
- /** Read the persisted endpoint config, or null if `agents share setup`/`join` never ran. */
26
+ /** Read the persisted endpoint config, or null if `agents artifacts setup` / `agents artifacts share join` never ran. */
27
27
  export function readShareConfig() {
28
28
  const s = readMeta().share;
29
29
  if (!s?.baseUrl || !s.accountId || !s.workerName || !s.bucketName)
@@ -56,7 +56,7 @@ export function storeWriteToken(token) {
56
56
  catch {
57
57
  bundle = {
58
58
  name: SHARE_BUNDLE,
59
- description: 'agents share — write token for the R2 share endpoint',
59
+ description: 'agents artifacts share — write token for the R2 share endpoint',
60
60
  // A NEW share bundle defaults to the `never` tier (no biometry ACL). The R2
61
61
  // write token is low-sensitivity automation infra that is auto-read on EVERY
62
62
  // `agents run` (shareRuntimeEnv) — a biometry ACL there is what produced the
@@ -83,7 +83,7 @@ export function readWriteTokenFromBundle() {
83
83
  const token = env[SHARE_TOKEN_KEY];
84
84
  if (!token) {
85
85
  throw new Error(`No ${SHARE_TOKEN_KEY} in the '${SHARE_BUNDLE}' secrets bundle. ` +
86
- `Run 'agents share setup' (to provision your own endpoint) or 'agents share join' (to use an existing one).`);
86
+ `Run 'agents artifacts setup' (to provision your own endpoint) or 'agents artifacts share join' (to use an existing one).`);
87
87
  }
88
88
  return token;
89
89
  }
@@ -102,7 +102,7 @@ export function readWriteToken() {
102
102
  * launch. The read is now always `agentOnly` — it resolves the token only from the
103
103
  * injected env or an already-held / no-ACL bundle, and silently returns undefined
104
104
  * otherwise (the caller runs without auto-share; the agent can still publish via
105
- * its own `agents share`). To get zero-friction auto-share with no prompt: unlock
105
+ * its own `agents artifacts share`). To get zero-friction auto-share with no prompt: unlock
106
106
  * once (`agents secrets unlock share`) or make it no-ACL (`agents secrets policy
107
107
  * share never`). */
108
108
  export function shareRuntimeEnv() {
@@ -1,4 +1,4 @@
1
- // The delete path for `agents share delete` / `agents unshare` — an authed DELETE
1
+ // The delete path for `agents artifacts share delete` / `agents unshare` — an authed DELETE
2
2
  // to the Worker, which already implements it (worker-template.ts). Mirrors
3
3
  // publish.ts: pure target-resolution logic is exported for tests, the network
4
4
  // calls (a status check + a delete) sit behind an injectable DI seam.
@@ -76,7 +76,7 @@ export async function deleteObject(endpoint, key, opts = {}) {
76
76
  const existedBefore = before.status !== 404;
77
77
  const r = await del(url, { authorization: `Bearer ${endpoint.token}` });
78
78
  if (!r.ok) {
79
- throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents share setup' completed.`);
79
+ throw new Error(`Delete failed (${r.status}) for ${url}. Check the write token, or that 'agents artifacts setup' completed.`);
80
80
  }
81
81
  const after = await check(url);
82
82
  const verified404 = after.status === 404;
@@ -88,7 +88,7 @@ export async function deleteObject(endpoint, key, opts = {}) {
88
88
  export async function deleteShare(target, opts = {}) {
89
89
  const cfg = opts.config ?? readShareConfig();
90
90
  if (!cfg) {
91
- throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
91
+ throw new Error("Not set up yet. Run 'agents artifacts setup' (provision your own endpoint) or 'agents artifacts share join' (use an existing one).");
92
92
  }
93
93
  const token = opts.writeToken ?? readWriteToken();
94
94
  const resolved = await resolveDeleteTarget(target, { githubUser: opts.githubUser });
@@ -1,4 +1,4 @@
1
- // Cloudflare provisioning for `agents share setup` — plain `fetch` against the CF
1
+ // Cloudflare provisioning for `agents artifacts setup` — plain `fetch` against the CF
2
2
  // REST API (the repo has no CF wrapper). Creates the R2 bucket, configures its
3
3
  // lifecycle, uploads the Worker (with an R2 binding), sets the WRITE_TOKEN secret,
4
4
  // enables the free `*.workers.dev` subdomain, and — when the token owns the zone —
@@ -134,14 +134,14 @@ export async function updateWorker(apiToken, accountId, workerName, bucketName,
134
134
  await deployWorker(apiToken, accountId, workerName, script, bucketName, opts);
135
135
  // Script upload clears bindings/secrets (see JSDoc above). If re-applying
136
136
  // WRITE_TOKEN fails here, the live Worker has no write token — every
137
- // `agents share` publish/delete 401s until a re-run of `agents share update`
137
+ // `agents artifacts share` publish/delete 401s until a re-run of `agents artifacts share update`
138
138
  // completes both steps. Surface that explicitly instead of the raw CF error.
139
139
  try {
140
140
  await setWorkerSecret(apiToken, accountId, workerName, writeToken, opts);
141
141
  }
142
142
  catch (e) {
143
143
  const detail = e instanceof Error ? e.message : String(e);
144
- throw new Error(`Worker deployed but the write token failed to re-apply — re-run \`agents share update\` to fix this before publishing/deleting anything. (${detail})`);
144
+ throw new Error(`Worker deployed but the write token failed to re-apply — re-run \`agents artifacts share update\` to fix this before publishing/deleting anything. (${detail})`);
145
145
  }
146
146
  return { templateHash, skipped: false };
147
147
  }
@@ -19,7 +19,7 @@ export interface PublishOptions {
19
19
  expire?: string;
20
20
  contentType?: string;
21
21
  /**
22
- * Hide this page from the public `/<user>` gallery and `agents share list`
22
+ * Hide this page from the public `/<user>` gallery and `agents artifacts share list`
23
23
  * (metadata `visibility=unlisted`). The direct URL is still world-readable —
24
24
  * unlisted, not secret (RUSH-2443). Alias of `--private` on the CLI.
25
25
  */
@@ -1,4 +1,4 @@
1
- // The publish path for `agents share <file>` — an authed PUT to the Worker.
1
+ // The publish path for `agents artifacts share <file>` — an authed PUT to the Worker.
2
2
  // Pure logic (slug, expiry) is exported for tests; the network call is behind a DI seam.
3
3
  //
4
4
  // For HTML publishes it also captures a 1200×630 cover (the page's own hero) and
@@ -255,7 +255,7 @@ export function buildShareKey(username, slugPart) {
255
255
  export async function publishFile(filePath, opts = {}) {
256
256
  const cfg = opts.config ?? readShareConfig();
257
257
  if (!cfg) {
258
- throw new Error("Not set up yet. Run 'agents share setup' (provision your own endpoint) or 'agents share join' (use an existing one).");
258
+ throw new Error("Not set up yet. Run 'agents artifacts setup' (provision your own endpoint) or 'agents artifacts share join' (use an existing one).");
259
259
  }
260
260
  const token = opts.writeToken ?? readWriteToken();
261
261
  const username = await resolveShareUsername(opts);
@@ -318,7 +318,7 @@ export async function publishToEndpoint(filePath, endpoint, opts = {}) {
318
318
  }
319
319
  const r = await put(pageUrl, body, authHeaders(opts.contentType ?? guessContentType(filePath)));
320
320
  if (!r.ok) {
321
- throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents share setup' completed.`);
321
+ throw new Error(`Publish failed (${r.status}) for ${pageUrl}. Check the write token, or that 'agents artifacts setup' completed.`);
322
322
  }
323
323
  return {
324
324
  url: r.url ?? pageUrl,
@@ -1,2 +1,13 @@
1
- /** Render the Worker source. Pure — the R2 binding + token are wired at deploy time. */
1
+ /**
2
+ * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
3
+ *
4
+ * The literal below still spells the CLI `agents share` in its provenance comment,
5
+ * its root response, and its gallery title, even though the command is now
6
+ * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
7
+ * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
8
+ * recorded `templateHash` against, so editing ANY byte here marks every already-
9
+ * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
10
+ * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
11
+ * worth that; change this text only alongside a real Worker behavior change.
12
+ */
2
13
  export declare function renderWorkerScript(): string;
@@ -9,7 +9,7 @@
9
9
  // sweeper; this is the immediate gate.
10
10
  // - GET /<username> — public gallery of that user's shares (HTML).
11
11
  // - GET /<username>?format=json — public machine-readable listing of that user's
12
- // ACTIVE shares (`agents share list`). Same single-segment path as the HTML
12
+ // ACTIVE shares (`agents artifacts share list`). Same single-segment path as the HTML
13
13
  // gallery and gated on the SAME "does <username>/ hold any object" check, so it
14
14
  // only intercepts a genuine namespace — a legacy flat slug with ?format=json
15
15
  // still serves its real content, never a fake empty listing.
@@ -19,7 +19,18 @@
19
19
  // Emitted as a string (mirrors src/lib/serve/page.ts `renderPage()`), so it compiles
20
20
  // into `dist/**` and ships with no package.json#files change. `provision.ts` uploads
21
21
  // this verbatim as an ES-module Worker with a BUCKET (R2) binding + a WRITE_TOKEN secret.
22
- /** Render the Worker source. Pure — the R2 binding + token are wired at deploy time. */
22
+ /**
23
+ * Render the Worker source. Pure — the R2 binding + token are wired at deploy time.
24
+ *
25
+ * The literal below still spells the CLI `agents share` in its provenance comment,
26
+ * its root response, and its gallery title, even though the command is now
27
+ * `agents artifacts share` (RUSH-2580). That is deliberate: `hashWorkerScript` of
28
+ * this exact text is what `shareTemplateStatus` compares a provisioned endpoint's
29
+ * recorded `templateHash` against, so editing ANY byte here marks every already-
30
+ * deployed endpoint `outdated` — which makes `agents artifacts share list` refuse
31
+ * until its owner re-runs `agents artifacts share update`. Cosmetic renames are not
32
+ * worth that; change this text only alongside a real Worker behavior change.
33
+ */
23
34
  export function renderWorkerScript() {
24
35
  return `// GENERATED by agents-cli agents share setup — do not edit here; edit
25
36
  // src/lib/share/worker-template.ts and re-run setup.
@@ -25,14 +25,32 @@ export declare function affinityWeights(rows: AffinityRow[], alpha?: number): We
25
25
  * Pure — inject `rng` for tests.
26
26
  */
27
27
  export declare function sampleWeighted(candidates: WeightedCandidate[], rng?: () => number): string | null;
28
- /** Online device names from the local registry (+ always include local). */
28
+ /**
29
+ * Online device names from the local registry (+ local), narrowed to the
30
+ * automatic-placement pool.
31
+ *
32
+ * The pool rule lives in `devices/pool.ts` and is an allowlist once any device
33
+ * is marked `role=worker`: this is the single place both automatic-placement
34
+ * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
35
+ * marking workers moves every `--device auto` at once instead of one surface.
36
+ *
37
+ * Paired cockpits (`role: control` in the device registry) are dropped here,
38
+ * where the registry is already being read — they are control surfaces, not
39
+ * compute. It CAN return an empty list — a fleet where every marked worker is
40
+ * offline, or where this box is the only candidate and is marked `personal`. That is a
41
+ * real answer, and both callers fail loud on it rather than falling back to the
42
+ * local machine (which would be the exact box the operator marked personal to
43
+ * keep agents off).
44
+ */
29
45
  export declare function listOnlineDeviceNames(localName?: string): string[];
30
46
  export interface DeviceAffinityOptions {
31
47
  sinceDays?: number;
32
48
  alpha?: number;
33
49
  /**
34
- * Eligible hosts (normalized). Defaults to online devices + local.
35
- * Empty after filter fall back to local.
50
+ * Eligible hosts (normalized). Defaults to the automatic-placement pool
51
+ * (online devices + local, narrowed by device roles). An explicitly empty
52
+ * list falls back to local — the caller supplied it; an empty DEFAULT pool
53
+ * throws, because roles emptied it on purpose.
36
54
  */
37
55
  eligibleHosts?: string[];
38
56
  localMachine?: string;
@@ -59,6 +77,12 @@ export interface DeviceAutoPlan {
59
77
  }>;
60
78
  pickedDeviceKey: string;
61
79
  }
80
+ /**
81
+ * The error both automatic-placement resolvers raise when device roles leave no
82
+ * candidate at all. Fail loud: the alternative — quietly running on the local
83
+ * machine — puts the agent on the box the operator marked `personal`.
84
+ */
85
+ export declare function formatEmptyAutoPoolError(): string;
62
86
  export declare function formatNoHealthyDeviceError(pool: string[], signals: Map<string, DevicePlacementSignal>, agent?: string): string;
63
87
  /**
64
88
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -74,6 +98,12 @@ export declare function resolveDeviceAuto(agent?: string, opts?: {
74
98
  }): Promise<DeviceAutoPlan>;
75
99
  /**
76
100
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
101
+ *
102
+ * Draws from the same automatic-placement pool as {@link resolveDeviceAuto}, so
103
+ * `agents ssh auto`, the generic `--host auto` passthrough, and `matchHost`'s
104
+ * `auto` sentinel honour device roles too. Throws when roles leave the pool
105
+ * empty — a `null` host here means "run locally", which for a box marked
106
+ * `personal` is the outcome the mark exists to prevent.
77
107
  */
78
108
  export declare function resolveDeviceAffinity(opts?: DeviceAffinityOptions): DeviceAffinityPlan;
79
109
  /** True when a host flag value means affinity pick. */
@@ -7,7 +7,8 @@
7
7
  */
8
8
  import { queryAffinityRollup } from './session/db.js';
9
9
  import { localMachineId } from './session/origin-machine.js';
10
- import { loadDevicesSync } from './devices/registry.js';
10
+ import { isControlDevice, loadDevicesSync } from './devices/registry.js';
11
+ import { describeAutoPool, filterAutoPool, isAutoPoolMember } from './devices/pool.js';
11
12
  import { normalizeHost } from './machine-id.js';
12
13
  import { probePoolSignals } from './teams/placement-probe.js';
13
14
  import { pickBestDevice } from './teams/scheduler.js';
@@ -45,7 +46,23 @@ export function sampleWeighted(candidates, rng = Math.random) {
45
46
  }
46
47
  return candidates[candidates.length - 1].key;
47
48
  }
48
- /** Online device names from the local registry (+ always include local). */
49
+ /**
50
+ * Online device names from the local registry (+ local), narrowed to the
51
+ * automatic-placement pool.
52
+ *
53
+ * The pool rule lives in `devices/pool.ts` and is an allowlist once any device
54
+ * is marked `role=worker`: this is the single place both automatic-placement
55
+ * paths (`resolveDeviceAuto`, `resolveDeviceAffinity`) get their candidates, so
56
+ * marking workers moves every `--device auto` at once instead of one surface.
57
+ *
58
+ * Paired cockpits (`role: control` in the device registry) are dropped here,
59
+ * where the registry is already being read — they are control surfaces, not
60
+ * compute. It CAN return an empty list — a fleet where every marked worker is
61
+ * offline, or where this box is the only candidate and is marked `personal`. That is a
62
+ * real answer, and both callers fail loud on it rather than falling back to the
63
+ * local machine (which would be the exact box the operator marked personal to
64
+ * keep agents off).
65
+ */
49
66
  export function listOnlineDeviceNames(localName = localMachineId()) {
50
67
  const names = new Set([normalizeHost(localName)]);
51
68
  try {
@@ -57,13 +74,27 @@ export function listOnlineDeviceNames(localName = localMachineId()) {
57
74
  // No tailscale snapshot → treat as candidate (registry-only box).
58
75
  if (online === false)
59
76
  continue;
77
+ // A paired cockpit (iPhone/iPad) is a control surface, not compute — it
78
+ // is never dialed for a session, so it is never a placement candidate.
79
+ if (isControlDevice(d))
80
+ continue;
60
81
  names.add(normalizeHost(name));
61
82
  }
62
83
  }
63
84
  catch {
64
85
  /* registry missing — local only */
65
86
  }
66
- return [...names];
87
+ return filterAutoPool([...names]);
88
+ }
89
+ /**
90
+ * The error both automatic-placement resolvers raise when device roles leave no
91
+ * candidate at all. Fail loud: the alternative — quietly running on the local
92
+ * machine — puts the agent on the box the operator marked `personal`.
93
+ */
94
+ export function formatEmptyAutoPoolError() {
95
+ const marked = describeAutoPool();
96
+ return (`agents: no device is eligible for automatic placement${marked ? ` (${marked})` : ''} — ` +
97
+ 'mark one with `agents devices role <name> worker`, or widen the pool with `agents config set auto.pool all`.');
67
98
  }
68
99
  export function formatNoHealthyDeviceError(pool, signals, agent) {
69
100
  const excluded = pool.map((key) => {
@@ -78,7 +109,11 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
78
109
  return `${key} (${reason})`;
79
110
  }).join(', ');
80
111
  const target = agent ? `can run ${agent}` : "for 'run auto'";
81
- return `agents: no healthy device ${target} excluded: ${excluded}; earliest window resets unknown`;
112
+ // Name the role narrowing when there is one: a fleet where every box but two
113
+ // is filtered out by a worker mark reads as "the fleet is down" without it.
114
+ const marked = describeAutoPool();
115
+ const poolNote = marked ? ` [pool: ${marked}]` : '';
116
+ return `agents: no healthy device ${target}${poolNote} — excluded: ${excluded}; earliest window resets unknown`;
82
117
  }
83
118
  /**
84
119
  * Pick the least-loaded healthy device that can run `agent` when the harness is
@@ -90,8 +125,13 @@ export function formatNoHealthyDeviceError(pool, signals, agent) {
90
125
  export async function resolveDeviceAuto(agent, opts = {}) {
91
126
  const local = normalizeHost(opts.localMachine ?? localMachineId());
92
127
  const pool = [...new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost))];
93
- if (!pool.includes(local))
128
+ // The local machine participates in the same probe as every peer — unless a
129
+ // role excludes it. Adding it unconditionally would put agents back on the box
130
+ // the operator marked `personal` precisely to keep them off it.
131
+ if (!pool.includes(local) && isAutoPoolMember(local))
94
132
  pool.push(local);
133
+ if (pool.length === 0)
134
+ throw new Error(formatEmptyAutoPoolError());
95
135
  const signals = await (opts.probe ?? probePoolSignals)(pool, agent);
96
136
  if (!agent && !opts.probe) {
97
137
  const { collectFleetHarnesses } = await import('../commands/ssh.js');
@@ -135,6 +175,12 @@ export async function resolveDeviceAuto(agent, opts = {}) {
135
175
  }
136
176
  /**
137
177
  * Resolve host for `--device auto`. Does NOT pick harness or accounts.
178
+ *
179
+ * Draws from the same automatic-placement pool as {@link resolveDeviceAuto}, so
180
+ * `agents ssh auto`, the generic `--host auto` passthrough, and `matchHost`'s
181
+ * `auto` sentinel honour device roles too. Throws when roles leave the pool
182
+ * empty — a `null` host here means "run locally", which for a box marked
183
+ * `personal` is the outcome the mark exists to prevent.
138
184
  */
139
185
  export function resolveDeviceAffinity(opts = {}) {
140
186
  const local = normalizeHost(opts.localMachine ?? localMachineId());
@@ -142,9 +188,18 @@ export function resolveDeviceAffinity(opts = {}) {
142
188
  const rng = opts.rng ?? Math.random;
143
189
  const sinceDays = opts.sinceDays ?? 14;
144
190
  const sinceMs = Date.now() - sinceDays * 24 * 60 * 60 * 1000;
191
+ // `listOnlineDeviceNames` always contained the local machine before device
192
+ // roles existed, so an empty default list can only mean roles excluded
193
+ // everything — fail loud, exactly as resolveDeviceAuto does. An explicitly
194
+ // empty `eligibleHosts` is the caller's own list and keeps the historical
195
+ // degrade-to-local behavior.
196
+ const usingDefaultPool = opts.eligibleHosts === undefined;
145
197
  const eligible = new Set((opts.eligibleHosts ?? listOnlineDeviceNames(local)).map(normalizeHost));
146
- if (eligible.size === 0)
198
+ if (eligible.size === 0) {
199
+ if (usingDefaultPool)
200
+ throw new Error(formatEmptyAutoPoolError());
147
201
  eligible.add(local);
202
+ }
148
203
  const deviceRows = opts.deviceAffinity ??
149
204
  queryAffinityRollup({
150
205
  groupBy: 'machine',