@phnx-labs/agents-cli 1.22.56 → 1.22.57

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 (61) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/README.md +4 -4
  3. package/dist/bootstrap.js +3 -1
  4. package/dist/cli/command-registry.d.ts +0 -1
  5. package/dist/cli/command-registry.js +0 -3
  6. package/dist/commands/exec.js +1 -1
  7. package/dist/commands/hooks.js +4 -4
  8. package/dist/commands/insights.d.ts +7 -5
  9. package/dist/commands/insights.js +16 -9
  10. package/dist/commands/perf.d.ts +16 -7
  11. package/dist/commands/perf.js +29 -20
  12. package/dist/commands/rules.js +1 -1
  13. package/dist/commands/ssh.js +24 -14
  14. package/dist/commands/trash.d.ts +2 -2
  15. package/dist/commands/trash.js +2 -6
  16. package/dist/commands/versions.d.ts +2 -2
  17. package/dist/commands/versions.js +1 -10
  18. package/dist/commands/view.d.ts +2 -2
  19. package/dist/commands/view.js +7 -6
  20. package/dist/index.d.ts +1 -0
  21. package/dist/index.js +9 -0
  22. package/dist/lib/accounting/usage-ingest.d.ts +1 -0
  23. package/dist/lib/accounting/usage-ingest.js +75 -0
  24. package/dist/lib/accounting/usage-sync.d.ts +69 -0
  25. package/dist/lib/accounting/usage-sync.js +129 -0
  26. package/dist/lib/accounting/usage.d.ts +48 -2
  27. package/dist/lib/accounting/usage.js +72 -1
  28. package/dist/lib/agent-spec/agents.js +1 -1
  29. package/dist/lib/analytics/mix-commands.d.ts +8 -7
  30. package/dist/lib/analytics/mix-commands.js +50 -73
  31. package/dist/lib/daemon/daemon.js +5 -0
  32. package/dist/lib/daemon/runner.js +9 -8
  33. package/dist/lib/daemon/usage-sync-service.d.ts +21 -0
  34. package/dist/lib/daemon/usage-sync-service.js +36 -0
  35. package/dist/lib/daemon-services.d.ts +1 -1
  36. package/dist/lib/daemon-services.js +5 -0
  37. package/dist/lib/device-config.d.ts +17 -6
  38. package/dist/lib/device-config.js +25 -11
  39. package/dist/lib/devices/pool.d.ts +4 -3
  40. package/dist/lib/devices/pool.js +13 -5
  41. package/dist/lib/exec.d.ts +6 -41
  42. package/dist/lib/exec.js +6 -41
  43. package/dist/lib/git.d.ts +13 -1
  44. package/dist/lib/git.js +36 -7
  45. package/dist/lib/harness/adapter.d.ts +7 -7
  46. package/dist/lib/harness/adapters/claude.js +3 -2
  47. package/dist/lib/hosts/remote-cmd.d.ts +9 -0
  48. package/dist/lib/hosts/remote-cmd.js +22 -0
  49. package/dist/lib/perf/db.d.ts +1 -1
  50. package/dist/lib/perf/db.js +1 -1
  51. package/dist/lib/session/active.d.ts +3 -31
  52. package/dist/lib/session/active.js +8 -68
  53. package/dist/lib/session/db.d.ts +4 -35
  54. package/dist/lib/session/db.js +4 -35
  55. package/dist/lib/session/discover.d.ts +6 -58
  56. package/dist/lib/session/discover.js +5 -43
  57. package/dist/lib/session/parse.d.ts +1 -19
  58. package/dist/lib/session/parse.js +2 -15
  59. package/dist/lib/startup/command-registry.d.ts +8 -2
  60. package/dist/lib/startup/command-registry.js +12 -4
  61. package/package.json +1 -1
@@ -4,11 +4,13 @@
4
4
  * These used to live as the top-level `agents trends` tree. That name was a
5
5
  * peer of `agents insights` with overlapping "analytics" meaning, so agents and
6
6
  * humans kept picking the wrong verb. The cheap counter path (sessions index +
7
- * usage.db) still exists — it is now `agents insights mix` and the recipe
8
- * subcommands below. Latency stays on `agents perf`; quota on `agents view`.
7
+ * usage.db) still exists — it is now `agents insights mix`. Latency stays on
8
+ * `agents insights perf`; quota on `agents view`.
9
9
  *
10
- * Former top-level `agents trends` is gone. The nested spelling
11
- * `agents insights trends` is an alias of `agents insights mix`.
10
+ * One surface, not five: the board is `agents insights mix`, one section is
11
+ * `agents insights mix <recipe>`, and `--list` names the recipe ids. The former
12
+ * per-recipe shortcut commands (`harness-mix`, `model-mix`, …), the `recipes`
13
+ * lister, and the `trends` alias were removed — `mix` already did all three.
12
14
  */
13
15
  import chalk from 'chalk';
14
16
  import { setHelpSections } from '../help.js';
@@ -60,23 +62,53 @@ export function renderMixDashboard(days, asJson, bannerLabel = 'agents insights
60
62
  *
61
63
  * Layout:
62
64
  * <parent> mix multi-recipe board
63
- * <parent> trends alias of mix (former top-level `agents trends`)
64
- * <parent> recipes list recipe ids
65
+ * <parent> mix <recipe> one baked recipe (harness-mix, model-mix, …)
66
+ * <parent> mix --list list recipe ids
65
67
  * <parent> query raw usage.db rows
66
- * <parent> harness-mix|… one baked recipe
67
68
  */
68
69
  export function registerMixCommands(parent) {
69
70
  const banner = 'agents insights mix';
70
71
  const mix = parent
71
- .command('mix')
72
- .description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db)')
72
+ .command('mix [recipe]')
73
+ .description('Counter recipes — harness/model mix, token ratios, resource frequency (sessions index + usage.db). Bare shows the board; pass a recipe id for one section.')
73
74
  .option('--days <n>', 'Days of history to include', '7')
75
+ .option('--list', 'List baked recipe ids and exit')
74
76
  .option('--json', 'Emit JSON instead of tables')
75
- .action(function summary() {
77
+ .action(function summary(recipe) {
76
78
  // optsWithGlobals(): --json collides by name with the `insights` parent, so
77
79
  // commander binds it to the parent and this.opts() never sees it. Merging
78
- // ancestor opts is what the per-recipe leaves below already do.
80
+ // ancestor opts is what the per-recipe path below already does.
79
81
  const o = this.optsWithGlobals();
82
+ if (o.list) {
83
+ const list = listRecipes();
84
+ if (o.json) {
85
+ console.log(JSON.stringify(list, null, 2));
86
+ return;
87
+ }
88
+ for (const r of list) {
89
+ console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
90
+ }
91
+ return;
92
+ }
93
+ if (recipe) {
94
+ if (!RECIPE_IDS.includes(recipe)) {
95
+ console.error(`Unknown recipe '${recipe}'. Known: ${RECIPE_IDS.join(', ')} (or 'agents insights mix --list').`);
96
+ process.exitCode = 1;
97
+ return;
98
+ }
99
+ const win = analyticsWindow(parseMixDays(o.days));
100
+ const section = runRecipe(recipe, win);
101
+ if (o.json) {
102
+ console.log(JSON.stringify({ window: win, section }, null, 2));
103
+ return;
104
+ }
105
+ if (section.empty) {
106
+ console.log(chalk.gray(`No data for recipe '${recipe}' in the last ${win.days} days.`));
107
+ return;
108
+ }
109
+ printMixSection(section);
110
+ return;
111
+ }
80
112
  renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
81
113
  });
82
114
  setHelpSections(mix, {
@@ -88,36 +120,23 @@ export function registerMixCommands(parent) {
88
120
  agents insights mix --days 30
89
121
 
90
122
  # One recipe as JSON
91
- agents insights harness-mix --json
123
+ agents insights mix harness-mix --json
124
+
125
+ # List baked recipe ids
126
+ agents insights mix --list
92
127
 
93
128
  # Raw usage events
94
129
  agents insights query --kind secret --days 7
95
-
96
- # List baked recipe ids
97
- agents insights recipes
98
130
  `,
99
131
  notes: `
100
132
  Session recipes read sessions.db; resource recipes read ~/.agents/.history/analytics/usage.db.
101
- Empty recipes are skipped on the default mix board.
133
+ Empty recipes are skipped on the default mix board. Pass one recipe id
134
+ (\`agents insights mix harness-mix\`) for just that section; \`--list\` names them.
102
135
  This is the cheap counter path. Behavioural report (transcript content, account split)
103
- is bare \`agents insights\`. Latency is \`agents perf\`; quota is \`agents view\`.
136
+ is bare \`agents insights\`. Latency is \`agents insights perf\`; quota is \`agents view\`.
104
137
  Skill/slash-command popularity is \`agents sessions stats\`.
105
138
  `,
106
139
  });
107
- parent.command('recipes')
108
- .description('List baked mix-recipe ids')
109
- .option('--json', 'Emit JSON')
110
- .action(function recipes() {
111
- const o = this.optsWithGlobals();
112
- const list = listRecipes();
113
- if (o.json) {
114
- console.log(JSON.stringify(list, null, 2));
115
- return;
116
- }
117
- for (const r of list) {
118
- console.log(`${r.id.padEnd(22)} ${r.store.padEnd(10)} ${r.title}`);
119
- }
120
- });
121
140
  parent.command('query')
122
141
  .description('Raw usage-event query (usage.db)')
123
142
  .option('--kind <kind>', `One of: ${USAGE_KINDS.join(', ')}`)
@@ -164,46 +183,4 @@ export function registerMixCommands(parent) {
164
183
  })),
165
184
  });
166
185
  });
167
- for (const id of RECIPE_IDS) {
168
- parent.command(id)
169
- .description(`Mix recipe: ${id}`)
170
- .option('--days <n>', 'Days of history', '7')
171
- .option('--json', 'Emit JSON')
172
- .action(function recipeAction() {
173
- // optsWithGlobals() merges the `insights` parent opts, so the name-colliding
174
- // --json/--since reach this leaf (see the sibling commands above).
175
- const o = this.optsWithGlobals();
176
- const win = analyticsWindow(parseMixDays(o.days));
177
- const section = runRecipe(id, win);
178
- if (o.json) {
179
- console.log(JSON.stringify({ window: win, section }, null, 2));
180
- return;
181
- }
182
- if (section.empty) {
183
- console.log(chalk.gray(`No data for recipe '${id}' in the last ${win.days} days.`));
184
- return;
185
- }
186
- printMixSection(section);
187
- });
188
- }
189
- // Nested home for the former top-level `agents trends` spelling.
190
- const trends = parent
191
- .command('trends')
192
- .description('Alias of `mix` — former top-level `agents trends`')
193
- .option('--days <n>', 'Days of history to include', '7')
194
- .option('--json', 'Emit JSON instead of tables')
195
- .action(function summary() {
196
- const o = this.optsWithGlobals();
197
- renderMixDashboard(parseMixDays(o.days), Boolean(o.json), banner);
198
- });
199
- setHelpSections(trends, {
200
- examples: `
201
- agents insights trends
202
- agents insights mix
203
- `,
204
- notes: `
205
- \`agents insights trends\` is the nested spelling of the former top-level
206
- \`agents trends\`. Prefer \`agents insights mix\`.
207
- `,
208
- });
209
186
  }
@@ -41,6 +41,7 @@ import { DeviceProbeService } from './device-probe-service.js';
41
41
  import { SelfHealService } from './self-heal-service.js';
42
42
  import { KeychainReapService } from './keychain-reap-service.js';
43
43
  import { AuthSyncService } from './auth-sync-service.js';
44
+ import { UsageSyncService } from './usage-sync-service.js';
44
45
  import { StateDirCheckService } from './state-dir-check-service.js';
45
46
  import { SessionStateService } from './session-state-service.js';
46
47
  import { WebhookReceiverService } from './webhook-receiver-service.js';
@@ -858,6 +859,10 @@ export async function runDaemon() {
858
859
  supervisor.register(new AuthSyncService());
859
860
  else
860
861
  log('INFO', 'Auth-sync service disabled');
862
+ if (isEnabled('usage-sync'))
863
+ supervisor.register(new UsageSyncService());
864
+ else
865
+ log('INFO', 'Usage-sync service disabled');
861
866
  if (isEnabled('webhook-receiver'))
862
867
  supervisor.register(new WebhookReceiverService());
863
868
  else
@@ -39,7 +39,7 @@ import { resolveClaudeSetupToken } from '../claude-account-token.js';
39
39
  import { getConfiguredRunStrategy, resolveRunVersion, resolveAccountVersion, rotationFailoverChain, readinessFromCandidate, formatNoHealthyAccountError, } from '../accounting/rotate.js';
40
40
  import { readAuthHealth, isDeadVerdict } from '../auth-health.js';
41
41
  import { machineId } from '../machine-id.js';
42
- import { selfConfiguredDeviceRole } from '../device-config.js';
42
+ import { isHeadedDeviceRole, selfConfiguredDeviceRole } from '../device-config.js';
43
43
  import { isSelfUpdatingAgent, ROUTINE_AGENT_IDS, isAgentHardDeprecated, hardDeprecationError } from '../agents.js';
44
44
  import { isCustomHarnessName, readProfile } from '../profiles.js';
45
45
  export class RoutineAlreadyRunningError extends Error {
@@ -1121,13 +1121,14 @@ export function buildRoutineSpawnEnv(baseEnv, agent, version, timezone, overlayH
1121
1121
  const setupToken = agent === 'claude' && version
1122
1122
  ? resolveClaudeSetupToken(getVersionHomePath('claude', version))
1123
1123
  : 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') {
1124
+ // A headed device (personal or desktop — the user's own interactive box or a
1125
+ // headed always-on box) uses the per-version login for EVERY run, routines
1126
+ // included — the setup-token is a worker-only credential (RUSH-2395, mirrors
1127
+ // the claude adapter's headed-device branch). On a headed box, only strip an
1128
+ // inherited copy of the account's OWN setup-token by value so a leaked ambient
1129
+ // value can't override the login; a token the user set deliberately is a
1130
+ // different string and survives.
1131
+ if (isHeadedDeviceRole(selfConfiguredDeviceRole())) {
1131
1132
  if (setupToken && out.CLAUDE_CODE_OAUTH_TOKEN === setupToken)
1132
1133
  delete out.CLAUDE_CODE_OAUTH_TOKEN;
1133
1134
  }
@@ -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. A no-op on
6
+ * a worker/unmarked box or when the local cache is empty — the driver
7
+ * ({@link syncFleetUsageSnapshots}) gates 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,36 @@
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. A no-op on
6
+ * a worker/unmarked box or when the local cache is empty — the driver
7
+ * ({@link syncFleetUsageSnapshots}) gates 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 { syncFleetUsageSnapshots } = await import('../accounting/usage-sync.js');
28
+ const result = syncFleetUsageSnapshots();
29
+ if (result.pushed.length > 0) {
30
+ ctx.log('INFO', `usage-sync: pushed usage to ${result.pushed.join(', ')}`);
31
+ }
32
+ for (const err of result.errors) {
33
+ ctx.log('WARN', `usage-sync: ${err.device}: ${err.message}`);
34
+ }
35
+ }
36
+ }
@@ -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: 'From a personal/desktop box, pushes the per-account usage snapshot to worker peers that cannot read it themselves.',
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);
@@ -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
  *
@@ -502,49 +502,10 @@ export type TmuxWrapDecision =
502
502
  kind: 'undurable';
503
503
  };
504
504
  /**
505
- * Decide whether to run an interactive agent INSIDE a detached tmux session on
506
- * the shared socket (then attach the current TTY) instead of a bare spawn.
507
- *
508
- * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
509
- * unique `%pane` handle so `agents sessions --active` can tell co-located
510
- * agents apart and `agents focus` re-attaches without forking, plus scrollback
511
- * and mouse at the operator's keyboard. Off means OFF, for local and remote
512
- * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
513
- * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
514
- * when one exists and resumes the harness session from disk when it does not.
515
- * The RUSH-3125 forced remote wrap conflated durability with that preference
516
- * and surprised every operator who had explicitly left tmux off.
517
- *
518
- * One case still wraps regardless: a followed remote run whose launcher has
519
- * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
520
- * the detached pane is the run's only interface, not an ergonomics choice.
521
- *
522
- * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
523
- * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
524
- * escape hatch that silently stopped applying over `--device` would be worse
525
- * than the bare run the user asked for.
526
- *
527
- * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
505
+ * Tmux is opt-in except when a TTY-less remote launch would otherwise have no
506
+ * interface. Explicit per-run opt-outs always win.
528
507
  */
529
508
  export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
530
- /**
531
- * Build the shell command that runs an agent inside a tmux pane with the exact
532
- * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
533
- * K=V … <agent> <args…>` so:
534
- * - `env` materializes the full agent env INTO the pane, independent of the
535
- * (possibly stale, shared) tmux server environment — additive, so tmux's own
536
- * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
537
- * - `exec` replaces the shell so the agent is the pane's leaf process (clean
538
- * `#{pane_pid}`, clean signal delivery on detach/kill).
539
- * Keys are filtered to valid identifiers so exported shell functions
540
- * (`BASH_FUNC_*%%`) can't make `env` choke.
541
- *
542
- * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
543
- * the KEY names. The env map here carries resolved secrets bundles (options.env),
544
- * so the real string would embed secret VALUES — which get persisted verbatim
545
- * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
546
- * real values; the stored/informational copy uses the redacted form (RUSH-1758).
547
- */
548
509
  /**
549
510
  * True when `options.sessionId` is an id the HARNESS actually received, and so a
550
511
  * real, resumable handle — rather than one the launcher generated for its own
@@ -562,6 +523,10 @@ export declare function resolveTmuxWrap(ctx: TmuxWrapContext): TmuxWrapDecision;
562
523
  * or this reports a genuine id as fabricated.
563
524
  */
564
525
  export declare function isHarnessKnownSessionId(agent: AgentId, sessionId: string | undefined, resume: boolean | undefined): boolean;
526
+ /**
527
+ * Build the pane command without inheriting stale tmux-server env. Redacted
528
+ * copies keep secret values out of persisted SessionMeta commands.
529
+ */
565
530
  export declare function buildTmuxAgentCommand(executable: string, args: string[], env: NodeJS.ProcessEnv, opts?: {
566
531
  redactEnvValues?: boolean;
567
532
  envFile?: string;
package/dist/lib/exec.js CHANGED
@@ -1241,29 +1241,8 @@ export function isPaneKnownAliveFromQueryResult(code, stdout) {
1241
1241
  return code === 0 && stdout.trim() === '0';
1242
1242
  }
1243
1243
  /**
1244
- * Decide whether to run an interactive agent INSIDE a detached tmux session on
1245
- * the shared socket (then attach the current TTY) instead of a bare spawn.
1246
- *
1247
- * The wrap is opt-in via this device's `tmux.enabled` (`configEnabled`): a
1248
- * unique `%pane` handle so `agents sessions --active` can tell co-located
1249
- * agents apart and `agents focus` re-attaches without forking, plus scrollback
1250
- * and mouse at the operator's keyboard. Off means OFF, for local and remote
1251
- * runs alike (PHNX-3316) — a followed `--device` run left bare is protected by
1252
- * reconnect-and-resume (lib/hosts/reconnect.ts), which rejoins the live pane
1253
- * when one exists and resumes the harness session from disk when it does not.
1254
- * The RUSH-3125 forced remote wrap conflated durability with that preference
1255
- * and surprised every operator who had explicitly left tmux off.
1256
- *
1257
- * One case still wraps regardless: a followed remote run whose launcher has
1258
- * no TTY (CI, scripts, another agent) gives the peer nothing to attach to —
1259
- * the detached pane is the run's only interface, not an ergonomics choice.
1260
- *
1261
- * The per-run opt-outs bind everything: `--raw` / `--no-tmux` /
1262
- * `AGENTS_NO_TMUX=1` are explicit "I want the bare process" requests, and an
1263
- * escape hatch that silently stopped applying over `--device` would be worse
1264
- * than the bare run the user asked for.
1265
- *
1266
- * Pure, so the gate is unit-tested independently of the (side-effecting) spawn.
1244
+ * Tmux is opt-in except when a TTY-less remote launch would otherwise have no
1245
+ * interface. Explicit per-run opt-outs always win.
1267
1246
  */
1268
1247
  export function resolveTmuxWrap(ctx) {
1269
1248
  // A headless `-p` run has no TTY to attach, Windows has no tmux path, and
@@ -1297,24 +1276,6 @@ export function resolveTmuxWrap(ctx) {
1297
1276
  return ctx.remoteDispatch ? { kind: 'undurable' } : { kind: 'bare' };
1298
1277
  return { kind: 'wrap' };
1299
1278
  }
1300
- /**
1301
- * Build the shell command that runs an agent inside a tmux pane with the exact
1302
- * env the bare spawn would use. tmux runs it via `sh -c <cmd>`; we `exec env
1303
- * K=V … <agent> <args…>` so:
1304
- * - `env` materializes the full agent env INTO the pane, independent of the
1305
- * (possibly stale, shared) tmux server environment — additive, so tmux's own
1306
- * $TMUX / $TMUX_PANE still reach the agent for provenance detection;
1307
- * - `exec` replaces the shell so the agent is the pane's leaf process (clean
1308
- * `#{pane_pid}`, clean signal delivery on detach/kill).
1309
- * Keys are filtered to valid identifiers so exported shell functions
1310
- * (`BASH_FUNC_*%%`) can't make `env` choke.
1311
- *
1312
- * `redactEnvValues` replaces every value with a `<redacted>` marker while keeping
1313
- * the KEY names. The env map here carries resolved secrets bundles (options.env),
1314
- * so the real string would embed secret VALUES — which get persisted verbatim
1315
- * into SessionMeta.cmd on disk (tmux/session.ts). The launched command uses the
1316
- * real values; the stored/informational copy uses the redacted form (RUSH-1758).
1317
- */
1318
1279
  /**
1319
1280
  * True when `options.sessionId` is an id the HARNESS actually received, and so a
1320
1281
  * real, resumable handle — rather than one the launcher generated for its own
@@ -1338,6 +1299,10 @@ export function isHarnessKnownSessionId(agent, sessionId, resume) {
1338
1299
  return true;
1339
1300
  return agent === 'claude';
1340
1301
  }
1302
+ /**
1303
+ * Build the pane command without inheriting stale tmux-server env. Redacted
1304
+ * copies keep secret values out of persisted SessionMeta commands.
1305
+ */
1341
1306
  export function buildTmuxAgentCommand(executable, args, env, opts = {}) {
1342
1307
  const agentCmd = [executable, ...args].map(shellQuote).join(' ');
1343
1308
  // envFile: source the values instead of inlining them, so no VALUE ever lands
package/dist/lib/git.d.ts CHANGED
@@ -152,6 +152,15 @@ export declare function getCurrentBranch(repoPath: string): Promise<string>;
152
152
  * repo" as a requested source before adopting it.
153
153
  */
154
154
  export declare function canonicalGitRemote(url: string): string;
155
+ /**
156
+ * True when a git remote URL (any transport form: ssh, https, scp-style) points
157
+ * at the system DotAgents repo — {@link DEFAULT_SYSTEM_REPO}'s current slug OR
158
+ * its `phnx-labs/.agents` rename target (PHNX-3394), which
159
+ * {@link canonicalGitRemote} folds onto it via {@link RENAMED_REMOTE_ALIASES}.
160
+ * Pure string check with no git spawn, so it is unit-testable off a live
161
+ * checkout; {@link isSystemRepoOrigin} reads a dir's origin and delegates here.
162
+ */
163
+ export declare function isSystemRepoRemote(remote: string | null | undefined): boolean;
155
164
  /** True when two git remote URLs point at the same repo across transport forms. */
156
165
  export declare function sameGitRemote(a: string | null | undefined, b: string | null | undefined): boolean;
157
166
  /**
@@ -354,7 +363,10 @@ export declare function adoptUserRepoIfNeeded(dir: string, opts?: {
354
363
  needsUrl?: boolean;
355
364
  }) | null>;
356
365
  /**
357
- * Check if the repo's origin points to the system repo.
366
+ * Check if the repo's origin points to the system repo — `phnx-labs/.agents-system`
367
+ * or its GitHub rename target `phnx-labs/.agents` (PHNX-3394), across any
368
+ * transport form. Reads the dir's origin and delegates the match to the pure
369
+ * {@link isSystemRepoRemote}.
358
370
  */
359
371
  export declare function isSystemRepoOrigin(dir: string): Promise<boolean>;
360
372
  /**