@phnx-labs/agents-cli 1.22.56 → 1.22.58

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 (146) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +11 -2
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/accounts.js +7 -3
  7. package/dist/commands/apply.js +10 -2
  8. package/dist/commands/exec.js +1 -1
  9. package/dist/commands/fork.d.ts +23 -10
  10. package/dist/commands/fork.js +115 -58
  11. package/dist/commands/hooks.js +4 -4
  12. package/dist/commands/insights.d.ts +7 -5
  13. package/dist/commands/insights.js +16 -9
  14. package/dist/commands/monitors.js +11 -0
  15. package/dist/commands/perf.d.ts +16 -7
  16. package/dist/commands/perf.js +29 -20
  17. package/dist/commands/prune.js +5 -3
  18. package/dist/commands/routines.d.ts +8 -0
  19. package/dist/commands/routines.js +57 -3
  20. package/dist/commands/rules.js +1 -1
  21. package/dist/commands/sessions-picker.d.ts +11 -0
  22. package/dist/commands/sessions-picker.js +16 -0
  23. package/dist/commands/sessions.js +1 -0
  24. package/dist/commands/share.d.ts +14 -0
  25. package/dist/commands/share.js +43 -2
  26. package/dist/commands/ssh.js +24 -14
  27. package/dist/commands/status.js +1 -1
  28. package/dist/commands/sync.js +83 -7
  29. package/dist/commands/traces.js +7 -0
  30. package/dist/commands/trash.d.ts +2 -2
  31. package/dist/commands/trash.js +2 -6
  32. package/dist/commands/versions.d.ts +2 -2
  33. package/dist/commands/versions.js +1 -10
  34. package/dist/commands/view.d.ts +2 -2
  35. package/dist/commands/view.js +7 -6
  36. package/dist/index.d.ts +1 -0
  37. package/dist/index.js +14 -0
  38. package/dist/lib/account-registry.d.ts +5 -1
  39. package/dist/lib/account-registry.js +47 -14
  40. package/dist/lib/accounting/capacity.d.ts +18 -7
  41. package/dist/lib/accounting/capacity.js +19 -8
  42. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  43. package/dist/lib/accounting/usage-ingest.js +75 -0
  44. package/dist/lib/accounting/usage-sync.d.ts +97 -0
  45. package/dist/lib/accounting/usage-sync.js +203 -0
  46. package/dist/lib/accounting/usage.d.ts +48 -2
  47. package/dist/lib/accounting/usage.js +79 -2
  48. package/dist/lib/agent-spec/agents.js +1 -1
  49. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  50. package/dist/lib/analytics/mix-commands.js +50 -73
  51. package/dist/lib/auth-mint.d.ts +11 -1
  52. package/dist/lib/auth-mint.js +21 -6
  53. package/dist/lib/browser/ipc.d.ts +8 -0
  54. package/dist/lib/browser/ipc.js +87 -0
  55. package/dist/lib/browser/service.d.ts +19 -0
  56. package/dist/lib/browser/service.js +96 -11
  57. package/dist/lib/browser/sessions-list.js +10 -1
  58. package/dist/lib/daemon/daemon.js +5 -0
  59. package/dist/lib/daemon/runner.d.ts +3 -0
  60. package/dist/lib/daemon/runner.js +95 -53
  61. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  62. package/dist/lib/daemon/usage-sync-service.js +42 -0
  63. package/dist/lib/daemon-services.d.ts +1 -1
  64. package/dist/lib/daemon-services.js +5 -0
  65. package/dist/lib/device-config.d.ts +17 -6
  66. package/dist/lib/device-config.js +25 -11
  67. package/dist/lib/devices/connect.d.ts +17 -8
  68. package/dist/lib/devices/connect.js +31 -14
  69. package/dist/lib/devices/pool.d.ts +4 -3
  70. package/dist/lib/devices/pool.js +13 -5
  71. package/dist/lib/doctor-diff.js +77 -7
  72. package/dist/lib/exec.d.ts +6 -41
  73. package/dist/lib/exec.js +6 -41
  74. package/dist/lib/fleet/manifest.d.ts +17 -0
  75. package/dist/lib/fleet/manifest.js +26 -0
  76. package/dist/lib/git.d.ts +13 -1
  77. package/dist/lib/git.js +36 -7
  78. package/dist/lib/harness/adapter.d.ts +7 -7
  79. package/dist/lib/harness/adapters/claude.js +3 -2
  80. package/dist/lib/hooks/install.d.ts +27 -11
  81. package/dist/lib/hooks/install.js +42 -17
  82. package/dist/lib/hosts/reconnect.d.ts +52 -203
  83. package/dist/lib/hosts/reconnect.js +64 -284
  84. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  85. package/dist/lib/hosts/remote-cmd.js +22 -0
  86. package/dist/lib/installations/migrate.d.ts +6 -120
  87. package/dist/lib/installations/migrate.js +27 -259
  88. package/dist/lib/installations/shims.d.ts +13 -95
  89. package/dist/lib/installations/shims.js +22 -139
  90. package/dist/lib/installations/store.js +1 -1
  91. package/dist/lib/installations/versions.d.ts +26 -133
  92. package/dist/lib/installations/versions.js +41 -204
  93. package/dist/lib/perf/db.d.ts +1 -1
  94. package/dist/lib/perf/db.js +1 -1
  95. package/dist/lib/plugins/skills.d.ts +8 -1
  96. package/dist/lib/plugins/skills.js +18 -2
  97. package/dist/lib/refresh.d.ts +9 -0
  98. package/dist/lib/refresh.js +3 -1
  99. package/dist/lib/routine-readiness.d.ts +15 -1
  100. package/dist/lib/routine-readiness.js +41 -0
  101. package/dist/lib/sandbox.d.ts +4 -1
  102. package/dist/lib/sandbox.js +30 -1
  103. package/dist/lib/secrets/agent.d.ts +80 -225
  104. package/dist/lib/secrets/agent.js +139 -401
  105. package/dist/lib/secrets/bundles.d.ts +73 -222
  106. package/dist/lib/secrets/bundles.js +168 -467
  107. package/dist/lib/secrets/reaper.d.ts +28 -70
  108. package/dist/lib/secrets/reaper.js +30 -85
  109. package/dist/lib/secrets/remote.d.ts +42 -129
  110. package/dist/lib/secrets/remote.js +55 -173
  111. package/dist/lib/self-heal/checks/install-staging.d.ts +4 -0
  112. package/dist/lib/self-heal/checks/install-staging.js +96 -0
  113. package/dist/lib/self-heal/registry.js +2 -0
  114. package/dist/lib/self-heal/types.d.ts +1 -1
  115. package/dist/lib/self-update.d.ts +23 -0
  116. package/dist/lib/self-update.js +50 -0
  117. package/dist/lib/session/active.d.ts +16 -32
  118. package/dist/lib/session/active.js +10 -68
  119. package/dist/lib/session/db.d.ts +24 -36
  120. package/dist/lib/session/db.js +143 -44
  121. package/dist/lib/session/discover.d.ts +6 -58
  122. package/dist/lib/session/discover.js +5 -43
  123. package/dist/lib/session/fork.d.ts +45 -26
  124. package/dist/lib/session/fork.js +32 -95
  125. package/dist/lib/session/parse.d.ts +1 -19
  126. package/dist/lib/session/parse.js +2 -15
  127. package/dist/lib/session/tool-calls.d.ts +43 -1
  128. package/dist/lib/session/tool-calls.js +74 -44
  129. package/dist/lib/session/tool-store.d.ts +33 -2
  130. package/dist/lib/session/tool-store.js +56 -3
  131. package/dist/lib/staleness/writers/sources.d.ts +5 -0
  132. package/dist/lib/staleness/writers/sources.js +2 -1
  133. package/dist/lib/startup/command-registry.d.ts +8 -2
  134. package/dist/lib/startup/command-registry.js +12 -4
  135. package/dist/lib/sync-status.d.ts +22 -0
  136. package/dist/lib/sync-status.js +27 -0
  137. package/dist/lib/sync-umbrella.d.ts +9 -0
  138. package/dist/lib/sync-umbrella.js +21 -2
  139. package/dist/lib/traces/insights.d.ts +47 -14
  140. package/dist/lib/traces/insights.js +92 -21
  141. package/dist/lib/traces/phenotype.d.ts +23 -3
  142. package/dist/lib/traces/phenotype.js +72 -24
  143. package/dist/lib/traces/sync.d.ts +15 -0
  144. package/dist/lib/traces/sync.js +104 -19
  145. package/dist/lib/traces/worker-template.js +154 -1
  146. package/package.json +1 -1
@@ -21,7 +21,7 @@ import { getCliLaunch, getAgentsBinDir } from '../cli-entry.js';
21
21
  import { resolveJobPrompt, parseTimeout, writeRunMeta, listRuns, getJobRunsDir, getRunDir, checkJobDeviceEligibility, finalizeRunMeta, resolveJobExecutionContext, slotRunId, claimRunSlot, readRunMeta, resolveHostStrategy, } from '../scheduling/routines.js';
22
22
  import { getRunsDir, getUserAgentsDir, readMeta, getDaemonDir } from '../state.js';
23
23
  import { shortCodexHome } from '../codex-home.js';
24
- import { prepareJobHome, buildSpawnEnv, getJobHomePath, assertSandboxForwardsHostGhAuth } from '../sandbox.js';
24
+ import { prepareJobHome, buildSpawnEnv, getJobHomePath, assertSandboxForwardsHostGhAuth, linkVersionAuth } from '../sandbox.js';
25
25
  import { resolveModel, buildReasoningFlags } from '../models.js';
26
26
  import { createTimer, redactPrompt, emitRoutineEnd } from '../feed/events.js';
27
27
  import { resolveHarnessAdapter } from '../harness/index.js';
@@ -37,9 +37,7 @@ import { walkForFiles } from '../fs-walk.js';
37
37
  import { getBinaryPath, isVersionInstalled, resolveVersion, getVersionHomePath } from '../installations/versions.js';
38
38
  import { resolveClaudeSetupToken } from '../claude-account-token.js';
39
39
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from '../accounting/rotate.js';
40
- import { readAuthHealth, isDeadVerdict } from '../auth-health.js';
41
- import { machineId } from '../machine-id.js';
42
- import { selfConfiguredDeviceRole } from '../device-config.js';
40
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
43
41
  import { isSelfUpdatingAgent, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
44
42
  import { isCustomHarnessName, readProfile } from '../profiles.js';
45
43
  export class RoutineAlreadyRunningError extends Error {
@@ -860,6 +858,27 @@ function readCommandExitCode(runDir) {
860
858
  return null;
861
859
  }
862
860
  }
861
+ /** Claude's own local auth check; unlike account metadata it cannot mistake identity for a usable login. */
862
+ export function claudeVersionIsAuthenticated(version) {
863
+ if (!isVersionInstalled('claude', version))
864
+ return true; // synthetic/unit plans are validated at spawn
865
+ const binary = getBinaryPath('claude', version);
866
+ if (!binary)
867
+ return false;
868
+ const home = getVersionHomePath('claude', version);
869
+ try {
870
+ const raw = execFileSync(binary, ['auth', 'status', '--json'], {
871
+ encoding: 'utf8',
872
+ timeout: 5_000,
873
+ env: { ...process.env, HOME: process.env.AGENTS_REAL_HOME || os.homedir(), CLAUDE_CONFIG_DIR: path.join(home, '.claude') },
874
+ stdio: ['ignore', 'pipe', 'ignore'],
875
+ });
876
+ return JSON.parse(raw).loggedIn === true;
877
+ }
878
+ catch {
879
+ return false;
880
+ }
881
+ }
863
882
  /**
864
883
  * Resolve the version/account chain for a routine the same way `agents run`
865
884
  * does: honor an explicit `version:` pin; otherwise use the configured run
@@ -890,7 +909,7 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
890
909
  : false;
891
910
  const selectedCredential = config.account
892
911
  ? (explicitCredential ? config.account : undefined)
893
- : resolveAccountSelection(undefined, agent, meta);
912
+ : resolveAccountSelection(undefined, agent, meta)?.id;
894
913
  if (selectedCredential) {
895
914
  // A default/binding may resolve to a native account — never route that
896
915
  // through the provider credential path (it has no bundle to resolve).
@@ -980,6 +999,24 @@ export async function resolveRoutineLaunch(config, cwd = process.cwd(), deps = {
980
999
  if (!version) {
981
1000
  version = resolveVersion(agent, cwd) ?? undefined;
982
1001
  }
1002
+ if (agent === 'claude' && version) {
1003
+ const checkClaudeAuth = deps.claudeVersionIsAuthenticated ?? claudeVersionIsAuthenticated;
1004
+ const authenticatedHealthy = rotation?.healthy.filter((candidate) => checkClaudeAuth(candidate.version));
1005
+ const authenticated = authenticatedHealthy?.find((candidate) => candidate.version === version) ?? authenticatedHealthy?.[0];
1006
+ if (rotation && (!authenticated || authenticatedHealthy.length === 0)) {
1007
+ throw new Error(`Routine '${config.name}' found no authenticated Claude account; run \`claude /login\` for an installed version or pin a provider profile.`);
1008
+ }
1009
+ if (!rotation && !checkClaudeAuth(version)) {
1010
+ throw new Error(`Routine '${config.name}' found no authenticated Claude account at claude@${version}; run \`claude /login\` for that version or remove the pin.`);
1011
+ }
1012
+ if (rotation && authenticated) {
1013
+ const rejected = rotation.healthy.filter((candidate) => !authenticatedHealthy.includes(candidate));
1014
+ if (rejected.length > 0 || rotation.picked !== authenticated) {
1015
+ rotation = { ...rotation, picked: authenticated, healthy: authenticatedHealthy, excluded: [...rotation.excluded, ...rejected] };
1016
+ }
1017
+ version = authenticated.version;
1018
+ }
1019
+ }
983
1020
  if (!version) {
984
1021
  process.stderr.write(`[agents] routine ${config.name}: no version of ${agent} configured — ` +
985
1022
  `run: agents add ${agent}@<version> && agents use ${agent} <version>\n`);
@@ -1121,13 +1158,14 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
1121
1158
  const setupToken = agent === 'claude' && version
1122
1159
  ? resolveClaudeSetupToken(getVersionHomePath('claude', version))
1123
1160
  : null;
1124
- // A `personal` device (the user's own interactive box) uses the per-version
1125
- // login for EVERY run, routines included — the setup-token is a worker-only
1126
- // credential (RUSH-2395, mirrors the claude adapter's personal-device branch).
1127
- // On a personal box, only strip an inherited copy of the account's OWN
1128
- // setup-token by value so a leaked ambient value can't override the login; a
1129
- // token the user set deliberately is a different string and survives.
1130
- if (selfConfiguredDeviceRole() === 'personal') {
1161
+ // A headed device (personal or desktop — the user's own interactive box or a
1162
+ // headed always-on box) uses the per-version login for EVERY run, routines
1163
+ // included — the setup-token is a worker-only credential (RUSH-2395, mirrors
1164
+ // the claude adapter's headed-device branch). On a headed box, only strip an
1165
+ // inherited copy of the account's OWN setup-token by value so a leaked ambient
1166
+ // value can't override the login; a token the user set deliberately is a
1167
+ // different string and survives.
1168
+ if (isHeadedDeviceRole(selfConfiguredDeviceRole())) {
1131
1169
  if (setupToken && out.CLAUDE_CODE_OAUTH_TOKEN === setupToken)
1132
1170
  delete out.CLAUDE_CODE_OAUTH_TOKEN;
1133
1171
  }
@@ -1142,6 +1180,10 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
1142
1180
  // to the overlay so an ambient value cannot bypass the routine sandbox.
1143
1181
  out.XDG_CONFIG_HOME = path.join(overlayHome, '.config');
1144
1182
  }
1183
+ if (overlayHome && agent === 'claude')
1184
+ out.HOME = process.env.AGENTS_REAL_HOME || os.homedir();
1185
+ if (overlayHome && agent === 'codex')
1186
+ out.CODEX_HOME = path.join(overlayHome, '.codex');
1145
1187
  if (timezone)
1146
1188
  out.TZ = timezone;
1147
1189
  return out;
@@ -1303,7 +1345,7 @@ async function executeJobPlaced(config, deps, attempt) {
1303
1345
  // sentinel, version homes) from HOME, which the overlay would hide. Exec
1304
1346
  // still isolates the run in the host version home it swaps HOME into.
1305
1347
  const useSandbox = config.sandbox !== false && !config.resume && !(config.agent && isCustomHarnessName(config.agent));
1306
- const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
1348
+ const overlayHome = useSandbox ? prepareJobHome(config, primaryVersion) : undefined;
1307
1349
  const runId = attempt.runId;
1308
1350
  const runDir = getRunDir(config.name, runId);
1309
1351
  fs.mkdirSync(runDir, { recursive: true });
@@ -1328,7 +1370,7 @@ async function executeJobPlaced(config, deps, attempt) {
1328
1370
  if (!dispatchesViaAgentsRun(config)) {
1329
1371
  const { findUnifiedAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
1330
1372
  const meta = readMeta();
1331
- const selectedAccount = resolveAccountSelection(config.account, effectiveAgent, meta);
1373
+ const selectedAccount = resolveAccountSelection(config.account, effectiveAgent, meta)?.id;
1332
1374
  if (selectedAccount) {
1333
1375
  // Only a provider account injects env; a native account (explicit or via a
1334
1376
  // device-scoped binding) is read from the harness home and forwards nothing.
@@ -1359,24 +1401,24 @@ async function executeJobPlaced(config, deps, attempt) {
1359
1401
  // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
1360
1402
  // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
1361
1403
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1362
- // Auth preflight: if the last live probe rejected this (agent, version)'s
1363
- // token (verdict `revoked`), the run is guaranteed to fail auth — fail fast
1364
- // before spawning instead of producing a doomed run + poisoned report. Cache-
1365
- // only (the daemon refreshes it periodically); fail OPEN on any non-revoked or
1366
- // missing verdict so a stale/absent probe or a network blip never blocks a
1367
- // run, and agents with no live probe (codex/gemini/grok) are never blocked.
1404
+ // Auth preflight: if the resolved account is provably signed out (revoked or
1405
+ // unconfigured), record a terminal `blocked`/`agent_auth_failed` run with the
1406
+ // re-login repair instead of spawning a doomed run that 401s and burns a
1407
+ // session (PHNX-3415). `blocked` (readiness rejection, no body ran) is kept
1408
+ // distinct from `failed` (a body ran and errored) per RT-7. Cache-only + fails
1409
+ // OPEN — see fireTimeAuthReadiness.
1368
1410
  const preflightVersion = launch.chain[0]?.version;
1369
- if (preflightVersion) {
1370
- const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1371
- if (health && isDeadVerdict(health.verdict)) {
1372
- const reason = `auth_preflight: ${health.verdict}`;
1373
- process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1374
- finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1375
- writeRunMeta(meta);
1376
- timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
1377
- archiveRoutineTranscripts(meta, runDir, overlayHome);
1378
- return { meta, reportPath: null };
1379
- }
1411
+ // Dynamic import breaks the routine-readiness -> scheduling/routines -> runner cycle.
1412
+ const { fireTimeAuthReadiness } = await import('../routine-readiness.js');
1413
+ const authBlocker = preflightVersion ? fireTimeAuthReadiness(effectiveAgent, preflightVersion) : null;
1414
+ if (authBlocker) {
1415
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} ${authBlocker.code} — skipping run (${authBlocker.repair})\n`);
1416
+ meta.readiness = authBlocker;
1417
+ finalizeRunMeta(meta, 'blocked', null, { errorMessage: `blocked: ${authBlocker.code} — ${authBlocker.message}` });
1418
+ writeRunMeta(meta);
1419
+ timer.end({ status: 'blocked', runId, error: authBlocker.code });
1420
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1421
+ return { meta, reportPath: null };
1380
1422
  }
1381
1423
  const timeoutMs = parseTimeout(config.timeout) || 10 * 60 * 1000;
1382
1424
  // Loop path: delegate to runLoop (same driver as `agents run --loop` / workflow loop:).
@@ -1443,6 +1485,8 @@ async function executeJobPlaced(config, deps, attempt) {
1443
1485
  meta.version = attemptVersion;
1444
1486
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1445
1487
  const viaAgentsRun = dispatchesViaAgentsRun(config);
1488
+ if (overlayHome)
1489
+ linkVersionAuth(overlayHome, attemptAgent, attemptVersion);
1446
1490
  const cmd = viaAgentsRun
1447
1491
  ? baseCmd
1448
1492
  : pinJobBinary(baseCmd, attemptAgent, attemptVersion);
@@ -1836,7 +1880,7 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1836
1880
  // sentinel, version homes) from HOME, which the overlay would hide. Exec
1837
1881
  // still isolates the run in the host version home it swaps HOME into.
1838
1882
  const useSandbox = config.sandbox !== false && !config.resume && !(config.agent && isCustomHarnessName(config.agent));
1839
- const overlayHome = useSandbox ? prepareJobHome(config) : undefined;
1883
+ const overlayHome = useSandbox ? prepareJobHome(config, version) : undefined;
1840
1884
  const runId = attempt.runId;
1841
1885
  const runDir = getRunDir(config.name, runId);
1842
1886
  fs.mkdirSync(runDir, { recursive: true });
@@ -1849,7 +1893,7 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1849
1893
  const { findAccount, resolveAccountSelection, resolveCredentialAccount } = await import('../account-registry.js');
1850
1894
  const selectedAccount = config.account && !findAccount(config.account)
1851
1895
  ? undefined
1852
- : resolveAccountSelection(config.account, config.agent, readMeta());
1896
+ : resolveAccountSelection(config.account, config.agent, readMeta())?.id;
1853
1897
  if (selectedAccount)
1854
1898
  Object.assign(baseEnv, resolveCredentialAccount(selectedAccount, config.agent).env);
1855
1899
  }
@@ -1890,28 +1934,26 @@ async function executeJobDetachedClaimed(config, attempt, hooks) {
1890
1934
  // Baseline the child's transcript dirs BEFORE spawning so archiveRoutineTranscripts
1891
1935
  // copies only THIS run's transcript out of a shared per-version home (RUSH-2271).
1892
1936
  snapshotRoutineTranscriptBase(meta, runDir, overlayHome);
1893
- // Auth preflight (mirrors executeJob): with no injected token, a daemon-fired
1894
- // Claude routine authenticates via the pinned account's own CLAUDE_CONFIG_DIR
1895
- // login. If the last live probe rejected that (agent, version)'s token, the run
1896
- // is guaranteed to 401 — fail fast with a re-login hint instead of spawning a
1897
- // doomed run + poisoned report. Cache-only; fails OPEN on any non-dead/missing
1898
- // verdict, and agents with no live probe (codex/gemini/grok) are never blocked.
1937
+ // Auth preflight (mirrors executeJob): a provably signed-out resolved account
1938
+ // records a terminal `blocked`/`agent_auth_failed` run with the re-login repair
1939
+ // instead of spawning a doomed run that 401s and burns a session (PHNX-3415).
1940
+ // Cache-only + fails OPEN — see fireTimeAuthReadiness.
1899
1941
  const preflightVersion = launch.chain[0]?.version;
1900
- if (preflightVersion) {
1901
- const health = readAuthHealth(machineId(), effectiveAgent, preflightVersion);
1902
- if (health && isDeadVerdict(health.verdict)) {
1903
- const reason = `auth_preflight: ${health.verdict}`;
1904
- process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} token ${health.verdict} — skipping run (re-login required)\n`);
1905
- try {
1906
- fs.closeSync(stdoutFd);
1907
- }
1908
- catch { /* already closed */ }
1909
- finalizeRunMeta(meta, 'failed', 1, { errorMessage: reason });
1910
- writeRunMeta(meta);
1911
- archiveRoutineTranscripts(meta, runDir, overlayHome);
1912
- timer.end({ status: 'failed', exitCode: 1, runId, error: reason });
1913
- return meta;
1942
+ // Dynamic import breaks the routine-readiness -> scheduling/routines -> runner cycle.
1943
+ const { fireTimeAuthReadiness } = await import('../routine-readiness.js');
1944
+ const authBlocker = preflightVersion ? fireTimeAuthReadiness(effectiveAgent, preflightVersion) : null;
1945
+ if (authBlocker) {
1946
+ process.stderr.write(`[agents] routine ${config.name}: ${effectiveAgent}@${preflightVersion} ${authBlocker.code} — skipping run (${authBlocker.repair})\n`);
1947
+ try {
1948
+ fs.closeSync(stdoutFd);
1914
1949
  }
1950
+ catch { /* already closed */ }
1951
+ meta.readiness = authBlocker;
1952
+ finalizeRunMeta(meta, 'blocked', null, { errorMessage: `blocked: ${authBlocker.code} — ${authBlocker.message}` });
1953
+ writeRunMeta(meta);
1954
+ archiveRoutineTranscripts(meta, runDir, overlayHome);
1955
+ timer.end({ status: 'blocked', runId, error: authBlocker.code });
1956
+ return meta;
1915
1957
  }
1916
1958
  const child = spawn(cmd[0], cmd.slice(1), {
1917
1959
  stdio: ['ignore', stdoutFd, stdoutFd],
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
3
+ *
4
+ * On a headed box (personal/desktop) each tick pushes the local identity-keyed
5
+ * Claude usage rows to worker peers that cannot read usage themselves. On a
6
+ * worker whose cache is empty or stale, the same tick pulls those rows from the
7
+ * primary headed device. Both drivers gate on the self role, so this service is
8
+ * safe to register everywhere. Single-executor per destination: each daemon only
9
+ * writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
10
+ */
11
+ import { BasePeriodicService, type DaemonContext } from './service.js';
12
+ import type { DaemonServiceId } from '../daemon-services.js';
13
+ export declare class UsageSyncService extends BasePeriodicService {
14
+ readonly id: DaemonServiceId;
15
+ readonly intervalMs: number;
16
+ readonly deadlineMs: number;
17
+ readonly startupDelayMs = 90000;
18
+ protected onStart(_ctx: DaemonContext): Promise<void>;
19
+ protected onStop(): Promise<void>;
20
+ protected onTick(ctx: DaemonContext): Promise<void>;
21
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Fleet usage-snapshot sync as a `PeriodicService` (PHNX-3392 usage-sync).
3
+ *
4
+ * On a headed box (personal/desktop) each tick pushes the local identity-keyed
5
+ * Claude usage rows to worker peers that cannot read usage themselves. On a
6
+ * worker whose cache is empty or stale, the same tick pulls those rows from the
7
+ * primary headed device. Both drivers gate on the self role, so this service is
8
+ * safe to register everywhere. Single-executor per destination: each daemon only
9
+ * writes the DESTINATION's own cache, and the merge is newest-wins + idempotent.
10
+ */
11
+ import { BasePeriodicService } from './service.js';
12
+ const USAGE_SYNC_TICK_MS = 15 * 60_000;
13
+ const USAGE_SYNC_DEADLINE_MS = 2 * 60_000;
14
+ const USAGE_SYNC_KICKOFF_MS = 90_000;
15
+ export class UsageSyncService extends BasePeriodicService {
16
+ id = 'usage-sync';
17
+ intervalMs = USAGE_SYNC_TICK_MS;
18
+ deadlineMs = USAGE_SYNC_DEADLINE_MS;
19
+ startupDelayMs = USAGE_SYNC_KICKOFF_MS;
20
+ async onStart(_ctx) {
21
+ // No connections to open — each tick re-reads the local cache + registry.
22
+ }
23
+ async onStop() {
24
+ // Nothing to release — the supervisor's timer teardown is the only cleanup.
25
+ }
26
+ async onTick(ctx) {
27
+ const { pullUsageFromPrimary, syncFleetUsageSnapshots } = await import('../accounting/usage-sync.js');
28
+ const pushResult = syncFleetUsageSnapshots();
29
+ if (pushResult.pushed.length > 0) {
30
+ ctx.log('INFO', `usage-sync: pushed usage to ${pushResult.pushed.join(', ')}`);
31
+ }
32
+ for (const err of pushResult.errors) {
33
+ ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
34
+ }
35
+ const pullResult = pullUsageFromPrimary();
36
+ if (pullResult.pulledFrom) {
37
+ ctx.log('INFO', `usage-sync: pulled usage from ${pullResult.pulledFrom}; merged ${pullResult.merged} row(s)`);
38
+ }
39
+ if (pullResult.error)
40
+ ctx.log('WARN', `usage-sync: pull: ${pullResult.error}`);
41
+ }
42
+ }
@@ -7,7 +7,7 @@
7
7
  * enabled without pulling in the whole daemon lifecycle.
8
8
  */
9
9
  /** Every service the daemon can host. IDs are kebab-case and stable. */
10
- export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
10
+ export type DaemonServiceId = 'secrets-broker' | 'scheduler' | 'monitors' | 'browser-ipc' | 'webhook-receiver' | 'self-heal' | 'keychain-reap' | 'account-state' | 'watchdog' | 'device-probe' | 'state-dir-check' | 'session-index' | 'auth-sync' | 'usage-sync' | 'daemon-heartbeat' | 'tmux-reap' | 'browser-task-reap' | 'session-state';
11
11
  /** Human-readable metadata for each service. */
12
12
  export interface DaemonServiceDef {
13
13
  id: DaemonServiceId;
@@ -97,6 +97,11 @@ export const DAEMON_SERVICES = [
97
97
  title: 'Auth bundle sync',
98
98
  description: 'Pushes the reserved file-backed auth bundle (setup-tokens) to pinned fleet devices that do not yet have it.',
99
99
  },
100
+ {
101
+ id: 'usage-sync',
102
+ title: 'Usage snapshot sync',
103
+ description: 'Bidirectional per-account usage sync: a headed personal/desktop box pushes its snapshot to worker peers, and a worker with a stale cache pulls from the primary — so workers that cannot read the endpoint themselves still route by real capacity.',
104
+ },
100
105
  ];
101
106
  /** Stable order of service IDs. */
102
107
  export const DAEMON_SERVICE_IDS = DAEMON_SERVICES.map((s) => s.id);
@@ -87,7 +87,7 @@ export interface ConfigTarget {
87
87
  fleet?: boolean;
88
88
  }
89
89
  /** Roles a device can be marked with — see the `role` key below. */
90
- declare const DEVICE_ROLES: readonly ["worker", "personal"];
90
+ declare const DEVICE_ROLES: readonly ["worker", "personal", "desktop"];
91
91
  /** Which devices automatic placement may pick — see the `auto.pool` key below. */
92
92
  declare const AUTO_POOL_MODES: readonly ["workers", "all"];
93
93
  export declare const CONFIG_KEYS: readonly ConfigKeySpec[];
@@ -150,13 +150,24 @@ export declare function configuredDeviceRole(name: string): ConfiguredDeviceRole
150
150
  * it was never marked. Keyed off {@link machineId} (overridable via
151
151
  * AGENTS_SYNC_MACHINE_ID), so it matches the device's own config-folder key.
152
152
  *
153
- * The auth strategy reads this: a `personal` device (the user's own interactive
154
- * box) holds a real per-version login and MUST authenticate from it for EVERY
155
- * run, interactive or headless; only a `worker` uses the file-based setup-token
156
- * (RUSH-2395). `undefined` is treated as non-personal (worker-equivalent) by
157
- * that gate — an unmarked box has no login to defer to.
153
+ * The auth strategy reads this: a headed device (`personal` or `desktop`, see
154
+ * {@link isHeadedDeviceRole}) holds a real per-version login and MUST
155
+ * authenticate from it for EVERY run, interactive or headless; only a `worker`
156
+ * uses the file-based setup-token (RUSH-2395). `undefined` is treated as
157
+ * non-headed (worker-equivalent) by that gate — an unmarked box has no login to
158
+ * defer to.
158
159
  */
159
160
  export declare function selfConfiguredDeviceRole(): ConfiguredDeviceRole | undefined;
161
+ /**
162
+ * A "headed" device — one with an interactive desktop login, so it authenticates
163
+ * from its own per-version Claude login (which carries the `user:profile` scope
164
+ * the usage endpoint needs) rather than the headless file setup-token. Both
165
+ * `personal` (the box you sit at) and `desktop` (a headed always-on box like a
166
+ * Mac mini) qualify; a `worker` and an unmarked box do NOT. This is the single
167
+ * predicate the auth-bucket sites read, so `personal` and `desktop` never drift
168
+ * apart (claude adapter, routine spawn env, `agents view` usage login).
169
+ */
170
+ export declare function isHeadedDeviceRole(role: ConfiguredDeviceRole | undefined): boolean;
160
171
  /** Mark a device's role fleet-wide; `undefined` clears the mark. */
161
172
  export declare function setConfiguredDeviceRole(name: string, role: ConfiguredDeviceRole | undefined): void;
162
173
  /**
@@ -41,7 +41,7 @@ import { migrateDeviceConfigStores } from './devices/config-migration.js';
41
41
  const DEVICE_PLATFORMS = ['windows', 'linux', 'macos', 'unknown'];
42
42
  const SSH_AUTH_METHODS = ['key', 'password'];
43
43
  /** Roles a device can be marked with — see the `role` key below. */
44
- const DEVICE_ROLES = ['worker', 'personal'];
44
+ const DEVICE_ROLES = ['worker', 'personal', 'desktop'];
45
45
  /** Which devices automatic placement may pick — see the `auto.pool` key below. */
46
46
  const AUTO_POOL_MODES = ['workers', 'all'];
47
47
  export const CONFIG_KEYS = [
@@ -67,8 +67,8 @@ export const CONFIG_KEYS = [
67
67
  scope: 'user',
68
68
  type: 'string',
69
69
  description: "Which devices automatic placement (`--device auto`) may pick: 'workers' (default — only devices marked role=worker, " +
70
- "once at least one is marked) or 'all' (every online device, ignoring worker marks). A device marked personal is " +
71
- 'never picked automatically under either mode.',
70
+ "once at least one is marked) or 'all' (every online device, ignoring worker marks). A device marked personal or " +
71
+ 'desktop is never picked automatically under either mode.',
72
72
  defaultValue: 'workers',
73
73
  validate: (v) => AUTO_POOL_MODES.includes(v)
74
74
  ? null
@@ -279,9 +279,10 @@ export const CONFIG_KEYS = [
279
279
  scope: 'device',
280
280
  visibility: 'shared',
281
281
  type: 'string',
282
- description: "What this device is for, fleet-wide: 'worker' (a box agents run on) or 'personal' (a machine you sit at — never " +
283
- 'picked automatically). Marking ANY device worker turns automatic placement into an allowlist: `--device auto` then ' +
284
- 'picks only from the marked workers.',
282
+ description: "What this device is for, fleet-wide: 'worker' (a box agents run on), 'personal' (a machine you sit at — your " +
283
+ "interactive seat), or 'desktop' (a headed always-on box like a Mac mini — the release/credential home). Personal " +
284
+ 'and desktop are never picked automatically. Marking ANY device worker turns automatic placement into an allowlist: ' +
285
+ '`--device auto` then picks only from the marked workers.',
285
286
  validate: (v) => DEVICE_ROLES.includes(v)
286
287
  ? null
287
288
  : `role must be one of ${DEVICE_ROLES.join(' | ')}.`,
@@ -630,15 +631,28 @@ export function configuredDeviceRole(name) {
630
631
  * it was never marked. Keyed off {@link machineId} (overridable via
631
632
  * AGENTS_SYNC_MACHINE_ID), so it matches the device's own config-folder key.
632
633
  *
633
- * The auth strategy reads this: a `personal` device (the user's own interactive
634
- * box) holds a real per-version login and MUST authenticate from it for EVERY
635
- * run, interactive or headless; only a `worker` uses the file-based setup-token
636
- * (RUSH-2395). `undefined` is treated as non-personal (worker-equivalent) by
637
- * that gate — an unmarked box has no login to defer to.
634
+ * The auth strategy reads this: a headed device (`personal` or `desktop`, see
635
+ * {@link isHeadedDeviceRole}) holds a real per-version login and MUST
636
+ * authenticate from it for EVERY run, interactive or headless; only a `worker`
637
+ * uses the file-based setup-token (RUSH-2395). `undefined` is treated as
638
+ * non-headed (worker-equivalent) by that gate — an unmarked box has no login to
639
+ * defer to.
638
640
  */
639
641
  export function selfConfiguredDeviceRole() {
640
642
  return configuredDeviceRole(machineId());
641
643
  }
644
+ /**
645
+ * A "headed" device — one with an interactive desktop login, so it authenticates
646
+ * from its own per-version Claude login (which carries the `user:profile` scope
647
+ * the usage endpoint needs) rather than the headless file setup-token. Both
648
+ * `personal` (the box you sit at) and `desktop` (a headed always-on box like a
649
+ * Mac mini) qualify; a `worker` and an unmarked box do NOT. This is the single
650
+ * predicate the auth-bucket sites read, so `personal` and `desktop` never drift
651
+ * apart (claude adapter, routine spawn env, `agents view` usage login).
652
+ */
653
+ export function isHeadedDeviceRole(role) {
654
+ return role === 'personal' || role === 'desktop';
655
+ }
642
656
  /** Mark a device's role fleet-wide; `undefined` clears the mark. */
643
657
  export function setConfiguredDeviceRole(name, role) {
644
658
  assertValidDeviceName(name);
@@ -47,16 +47,25 @@ export declare function wrapRemoteCommand(device: DeviceProfile, cmd: string[]):
47
47
  */
48
48
  export declare function isAgentsBrowserDrive(cmd: string[]): boolean;
49
49
  /**
50
- * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
51
- * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
52
- * with spaces (POSIX) or base64-encodes it for PowerShell, so a
53
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
54
- * `$env:VAR='1'; …` on PowerShell.
50
+ * Prefix a remote command with the provenance the far-side browser needs: the
51
+ * `AGENTS_FLEET_REMOTE=1` consent marker AND the caller's resolved actor
52
+ * (`AGENTS_ACTOR*`/`GIT_*`). Without the actor, a task created over
53
+ * `agents ssh <host> 'agents browser …'` is stamped `UNRESOLVED@<host>` because
54
+ * the peer re-resolves identity from its own (empty) env — the same RUSH-2028
55
+ * gap the `--device` dispatch already closes via `withActorEnv`, applied here to
56
+ * the `agents ssh` browser-drive seam so ownership is honest across BOTH paths.
55
57
  *
56
- * Already-marked argv (the `--device` fan-out stamps this before
57
- * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
58
+ * `wrapRemoteCommand` joins the argv with spaces (POSIX) or base64-encodes it for
59
+ * PowerShell, so a shell-appropriate prelude rides through both: `env VAR=v …` on
60
+ * POSIX, `$env:VAR='v'; …` on PowerShell. The marker stays the FIRST token so the
61
+ * consent gate and the idempotency guard below both still key on it.
62
+ *
63
+ * `provenanceEnv` is injectable so tests can pin a deterministic actor; it
64
+ * defaults to the live resolved actor. Already-marked argv (the `--device`
65
+ * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
66
+ * so nothing is doubled.
58
67
  */
59
- export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>): string[];
68
+ export declare function markFleetRemote(cmd: string[], device: Pick<DeviceProfile, 'shell'>, provenanceEnv?: Record<string, string>): string[];
60
69
  /**
61
70
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
62
71
  * mirrored project directory, falling back to the remote home when that
@@ -16,6 +16,7 @@
16
16
  import * as fs from 'fs';
17
17
  import * as path from 'path';
18
18
  import { assertValidSshTarget, shellQuote } from '../ssh-exec.js';
19
+ import { resolveActor, actorEnv } from '../actor.js';
19
20
  import { getCliLaunch } from '../cli-entry.js';
20
21
  import { encodePwshBase64 } from '../pwsh.js';
21
22
  import { homeRemainder, remoteCdPrefix } from '../project-root.js';
@@ -104,24 +105,40 @@ export function isAgentsBrowserDrive(cmd) {
104
105
  return (bin === 'agents' || bin === 'ag') && tokens[1] === 'browser';
105
106
  }
106
107
  /**
107
- * Prefix a remote command so the far side sees `AGENTS_FLEET_REMOTE=1` — the
108
- * marker the browser consent gate reads. `wrapRemoteCommand` joins the argv
109
- * with spaces (POSIX) or base64-encodes it for PowerShell, so a
110
- * shell-appropriate leading token rides through both: `env VAR=1 …` on POSIX,
111
- * `$env:VAR='1'; …` on PowerShell.
108
+ * Prefix a remote command with the provenance the far-side browser needs: the
109
+ * `AGENTS_FLEET_REMOTE=1` consent marker AND the caller's resolved actor
110
+ * (`AGENTS_ACTOR*`/`GIT_*`). Without the actor, a task created over
111
+ * `agents ssh <host> 'agents browser …'` is stamped `UNRESOLVED@<host>` because
112
+ * the peer re-resolves identity from its own (empty) env — the same RUSH-2028
113
+ * gap the `--device` dispatch already closes via `withActorEnv`, applied here to
114
+ * the `agents ssh` browser-drive seam so ownership is honest across BOTH paths.
112
115
  *
113
- * Already-marked argv (the `--device` fan-out stamps this before
114
- * {@link buildSshInvocation}) is left unchanged so the prefix is not doubled.
116
+ * `wrapRemoteCommand` joins the argv with spaces (POSIX) or base64-encodes it for
117
+ * PowerShell, so a shell-appropriate prelude rides through both: `env VAR=v …` on
118
+ * POSIX, `$env:VAR='v'; …` on PowerShell. The marker stays the FIRST token so the
119
+ * consent gate and the idempotency guard below both still key on it.
120
+ *
121
+ * `provenanceEnv` is injectable so tests can pin a deterministic actor; it
122
+ * defaults to the live resolved actor. Already-marked argv (the `--device`
123
+ * fan-out stamps the marker before {@link buildSshInvocation}) is left unchanged
124
+ * so nothing is doubled.
115
125
  */
116
- export function markFleetRemote(cmd, device) {
126
+ export function markFleetRemote(cmd, device, provenanceEnv = actorEnv(resolveActor())) {
117
127
  if (device.shell === 'powershell') {
118
- return cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`
119
- ? cmd
120
- : [`$env:AGENTS_FLEET_REMOTE='1';`, ...cmd];
128
+ // Exact-match guard, symmetric with the POSIX branch below: the marker token
129
+ // is always this literal, so `startsWith` would only loosen it for no gain.
130
+ if (cmd[0] === `$env:AGENTS_FLEET_REMOTE='1';`)
131
+ return cmd;
132
+ const prelude = [
133
+ `$env:AGENTS_FLEET_REMOTE='1';`,
134
+ ...Object.entries(provenanceEnv).map(([k, v]) => `$env:${k}='${v.replace(/'/g, "''")}';`),
135
+ ];
136
+ return [...prelude, ...cmd];
121
137
  }
122
- return cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1'
123
- ? cmd
124
- : ['env', 'AGENTS_FLEET_REMOTE=1', ...cmd];
138
+ if (cmd[0] === 'env' && cmd[1] === 'AGENTS_FLEET_REMOTE=1')
139
+ return cmd;
140
+ const actorTokens = Object.entries(provenanceEnv).map(([k, v]) => shellQuote(`${k}=${v}`));
141
+ return ['env', 'AGENTS_FLEET_REMOTE=1', ...actorTokens, ...cmd];
125
142
  }
126
143
  /**
127
144
  * Build the remote command that starts an INTERACTIVE LOGIN shell inside a
@@ -15,10 +15,11 @@
15
15
  * |---|---|
16
16
  * | no device marked | every online device (unchanged behavior) |
17
17
  * | some marked `worker` | ONLY those workers |
18
- * | marked `personal` | never, under either state |
18
+ * | marked `personal` / `desktop` | never, under either state |
19
19
  *
20
- * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
21
- * machine the user sits at is marked precisely so agents stay off it.
20
+ * `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
21
+ * excluded, because a box the user sits at (personal) or a headed always-on
22
+ * release/credential box (desktop) is marked precisely so agents stay off it.
22
23
  */
23
24
  import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
24
25
  export interface AutoPoolOptions {
@@ -15,15 +15,23 @@
15
15
  * |---|---|
16
16
  * | no device marked | every online device (unchanged behavior) |
17
17
  * | some marked `worker` | ONLY those workers |
18
- * | marked `personal` | never, under either state |
18
+ * | marked `personal` / `desktop` | never, under either state |
19
19
  *
20
- * `auto.pool all` turns the allowlist off; `personal` stays excluded, because a
21
- * machine the user sits at is marked precisely so agents stay off it.
20
+ * `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
21
+ * excluded, because a box the user sits at (personal) or a headed always-on
22
+ * release/credential box (desktop) is marked precisely so agents stay off it.
22
23
  */
23
24
  import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
24
25
  import { normalizeHost } from '../machine-id.js';
25
- /** Roles that automatic placement never picks, whatever the pool mode. */
26
- const NEVER_AUTO = new Set(['personal']);
26
+ /**
27
+ * Roles that automatic placement never picks, whatever the pool mode.
28
+ *
29
+ * `personal` (a box you sit at) and `desktop` (a headed always-on box — the
30
+ * release/credential home, e.g. a Mac mini) are both off-limits to `--device
31
+ * auto`: neither is headless fan-out capacity, and landing agent work on them
32
+ * is the outcome the mark exists to prevent. Only `worker` is auto-eligible.
33
+ */
34
+ const NEVER_AUTO = new Set(['personal', 'desktop']);
27
35
  /**
28
36
  * Narrow a candidate host list to the devices automatic placement may pick.
29
37
  *