@phnx-labs/agents-cli 1.22.58 → 1.22.60

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 (83) hide show
  1. package/CHANGELOG.md +260 -0
  2. package/README.md +29 -0
  3. package/dist/bootstrap.js +32 -1
  4. package/dist/commands/monitors.js +187 -23
  5. package/dist/commands/perf.js +10 -0
  6. package/dist/commands/routines.test-fixture.js +5 -0
  7. package/dist/commands/send.d.ts +2 -1
  8. package/dist/commands/send.js +7 -5
  9. package/dist/commands/sessions-picker.d.ts +13 -0
  10. package/dist/commands/sessions-picker.js +17 -8
  11. package/dist/commands/sessions-stats.js +37 -5
  12. package/dist/commands/sessions.js +52 -16
  13. package/dist/commands/ssh.js +12 -1
  14. package/dist/commands/teams-picker.js +20 -6
  15. package/dist/commands/teams.d.ts +2 -2
  16. package/dist/commands/teams.js +77 -21
  17. package/dist/commands/versions.js +12 -4
  18. package/dist/commands/view.js +7 -2
  19. package/dist/lib/accounting/rotate.js +12 -4
  20. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  21. package/dist/lib/accounting/usage-sync.js +34 -6
  22. package/dist/lib/auto-pull-worker.js +7 -2
  23. package/dist/lib/cloud/rush.d.ts +7 -0
  24. package/dist/lib/cloud/rush.js +29 -1
  25. package/dist/lib/daemon/daemon.d.ts +22 -0
  26. package/dist/lib/daemon/daemon.js +39 -0
  27. package/dist/lib/daemon/session-index-service.js +9 -1
  28. package/dist/lib/daemon-ticks.d.ts +15 -0
  29. package/dist/lib/daemon-ticks.js +26 -0
  30. package/dist/lib/device-config.d.ts +5 -1
  31. package/dist/lib/device-config.js +2 -2
  32. package/dist/lib/devices/health.js +5 -1
  33. package/dist/lib/devices/pool.d.ts +25 -2
  34. package/dist/lib/devices/pool.js +32 -2
  35. package/dist/lib/devices/stats-cache.d.ts +0 -6
  36. package/dist/lib/devices/stats-cache.js +2 -9
  37. package/dist/lib/doctor-diff.d.ts +14 -0
  38. package/dist/lib/doctor-diff.js +43 -2
  39. package/dist/lib/feed/events.js +4 -0
  40. package/dist/lib/git.d.ts +38 -0
  41. package/dist/lib/git.js +58 -0
  42. package/dist/lib/hosts/ready.d.ts +8 -0
  43. package/dist/lib/hosts/ready.js +13 -2
  44. package/dist/lib/installations/versions.d.ts +17 -0
  45. package/dist/lib/installations/versions.js +53 -2
  46. package/dist/lib/monitors/config.d.ts +71 -3
  47. package/dist/lib/monitors/config.js +100 -12
  48. package/dist/lib/monitors/pid-watch.d.ts +35 -0
  49. package/dist/lib/monitors/pid-watch.js +45 -0
  50. package/dist/lib/monitors/remote.d.ts +18 -0
  51. package/dist/lib/monitors/remote.js +11 -0
  52. package/dist/lib/perf/db.d.ts +1 -1
  53. package/dist/lib/perf/db.js +53 -2
  54. package/dist/lib/perf/types.d.ts +14 -0
  55. package/dist/lib/permissions.js +7 -2
  56. package/dist/lib/plugins/plugins.d.ts +17 -3
  57. package/dist/lib/plugins/plugins.js +84 -9
  58. package/dist/lib/pty-server.d.ts +14 -0
  59. package/dist/lib/pty-server.js +49 -5
  60. package/dist/lib/secrets/drivers/rush.js +5 -0
  61. package/dist/lib/self-update.d.ts +42 -0
  62. package/dist/lib/self-update.js +88 -0
  63. package/dist/lib/session/cloud.js +5 -0
  64. package/dist/lib/session/db.d.ts +32 -6
  65. package/dist/lib/session/db.js +128 -12
  66. package/dist/lib/session/live-metadata.js +3 -3
  67. package/dist/lib/smart-launch.d.ts +6 -0
  68. package/dist/lib/smart-launch.js +5 -2
  69. package/dist/lib/staleness/writers/plugins.js +5 -2
  70. package/dist/lib/staleness/writers/subagents.js +13 -3
  71. package/dist/lib/state.d.ts +7 -4
  72. package/dist/lib/state.js +7 -4
  73. package/dist/lib/subagents.js +8 -2
  74. package/dist/lib/teams/api.d.ts +8 -0
  75. package/dist/lib/teams/api.js +50 -6
  76. package/dist/lib/teams/delivery.d.ts +14 -4
  77. package/dist/lib/teams/delivery.js +15 -5
  78. package/dist/lib/teams/scheduler.d.ts +10 -0
  79. package/dist/lib/teams/scheduler.js +8 -0
  80. package/dist/lib/traces/sync.d.ts +113 -6
  81. package/dist/lib/traces/sync.js +193 -19
  82. package/dist/lib/view-types.d.ts +12 -0
  83. package/package.json +2 -2
@@ -32,10 +32,26 @@ import { sshTargetFor } from '../devices/connect.js';
32
32
  import { isHostPinned } from '../devices/known-hosts.js';
33
33
  import { machineId, normalizeHost } from '../session/sync/config.js';
34
34
  import { isHeadedDeviceRole, listConfiguredDeviceRoles, selfConfiguredDeviceRole, } from '../device-config.js';
35
- import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, readClaudeUsageCache, } from './usage.js';
35
+ import { exportClaudeUsageCacheRows, ingestPeerClaudeUsageRows, } from './usage.js';
36
36
  /** How long a single peer push may take before it is abandoned for this tick. */
37
37
  export const USAGE_PUSH_DEADLINE_MS = 20_000;
38
38
  export const USAGE_PULL_DEADLINE_MS = 20_000;
39
+ /**
40
+ * A worker's newest local usage row older than this triggers a pull from the
41
+ * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
42
+ * RUSH-2392), so its only source of a fresh row is the daemon push every
43
+ * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
44
+ * cycle is tolerated before the worker actively pulls. Bounds worker staleness
45
+ * to ~30m instead of the days-old caches this replaces.
46
+ */
47
+ export const USAGE_SYNC_MAX_AGE_MS = 30 * 60_000;
48
+ /** Epoch ms of a cache row's capture time, or null when unparseable/absent. */
49
+ function capturedAtMs(capturedAt) {
50
+ if (!capturedAt)
51
+ return null;
52
+ const ms = Date.parse(capturedAt);
53
+ return Number.isFinite(ms) ? ms : null;
54
+ }
39
55
  /**
40
56
  * Decide, per peer, whether to push the local usage snapshot. Pure.
41
57
  *
@@ -109,12 +125,24 @@ export function pullUsageFromPrimary(deps = {}) {
109
125
  return result;
110
126
  }
111
127
  const localRows = (deps.exportRows ?? exportClaudeUsageCacheRows)();
112
- const readRow = deps.readRow ?? readClaudeUsageCache;
113
128
  const localEntries = Object.entries(localRows);
114
- if (localEntries.length > 0 && localEntries.every(([key, row]) => {
115
- const fresh = readRow(key);
116
- return fresh !== null && fresh.windows.length === row.windows.length;
117
- })) {
129
+ // Fresh means RECENT, not "the cache equals itself". The prior check read the
130
+ // SAME on-disk cache twice (exportRows() and readRow()) and compared window
131
+ // COUNT, so `fresh.windows.length === row.windows.length` was always true and
132
+ // any non-empty worker cache skipped the pull forever — which is how a worker
133
+ // served a days-old snapshot indefinitely and balanced launched into an
134
+ // account already at its weekly cap. A worker cannot self-read usage, so any
135
+ // row older than the sync cadence is stale and must trigger a pull; the merge
136
+ // is newest-wins + idempotent, so pulling when the primary is no fresher is a
137
+ // harmless no-op.
138
+ const now = (deps.now ?? Date.now)();
139
+ const staleCutoff = now - USAGE_SYNC_MAX_AGE_MS;
140
+ const allFresh = localEntries.length > 0 &&
141
+ localEntries.every(([, row]) => {
142
+ const ms = capturedAtMs(row.capturedAt);
143
+ return ms !== null && ms >= staleCutoff;
144
+ });
145
+ if (allFresh) {
118
146
  result.skipped = 'local usage cache is fresh';
119
147
  return result;
120
148
  }
@@ -10,7 +10,7 @@
10
10
  */
11
11
  import * as fs from 'fs';
12
12
  import { simpleGit } from 'simple-git';
13
- import { tryAutoPull, isGitRepo } from './git.js';
13
+ import { tryAutoPullSystemRepo, isGitRepo } from './git.js';
14
14
  import { getSystemAgentsDir, getUserAgentsDir, getEnabledExtraRepos, getFetchCacheDir, } from './state.js';
15
15
  import { lockFilePath, statusFilePath, markDetachedSyncComplete, SYNC_LOCK_TTL_MS, } from './auto-pull.js';
16
16
  /**
@@ -99,7 +99,12 @@ async function processTarget(target) {
99
99
  await notifyRepo(target);
100
100
  }
101
101
  else {
102
- await tryAutoPull(target.dir);
102
+ // Verify origin is the EXPECTED system remote before fast-forwarding —
103
+ // the system repo ships hooks that run as shell, so a pull from a
104
+ // repointed origin is RCE. An unexpected origin is refused, not pulled
105
+ // (PHNX-2957). The detached worker has no terminal to warn on; the
106
+ // foreground `agents use` path surfaces the refusal to the operator.
107
+ await tryAutoPullSystemRepo(target.dir);
103
108
  }
104
109
  }
105
110
  else {
@@ -5,6 +5,13 @@
5
5
  * Requires the Rush GitHub App installed on the target repo.
6
6
  */
7
7
  import type { CloudProvider, CloudTask, CloudTaskStatus, CloudEvent, DispatchOptions, ProviderCapabilities, ImageAttachment, SkillRef } from './types.js';
8
+ /**
9
+ * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
10
+ * the token has not passed its expires_at timestamp (Unix seconds). A missing
11
+ * expires_at is treated as non-expired so tokens written without an expiry
12
+ * still work. Pass yamlPath to override the default path in tests.
13
+ */
14
+ export declare function isRushSessionValid(yamlPath?: string): boolean;
8
15
  /** One version's entry in the account manifest sent on every dispatch. */
9
16
  export interface AccountManifestEntry {
10
17
  version: string;
@@ -16,6 +16,29 @@ import { getAccountInfo } from '../agents.js';
16
16
  import { selectBalancedVersion } from '../accounting/rotate.js';
17
17
  const PROXY_BASE = process.env.RUSH_PROXY_BASE ?? 'https://api.prix.dev';
18
18
  const USER_YAML = path.join(os.homedir(), '.rush', 'user.yaml');
19
+ /**
20
+ * Returns true when ~/.rush/user.yaml exists, carries an access_token, and
21
+ * the token has not passed its expires_at timestamp (Unix seconds). A missing
22
+ * expires_at is treated as non-expired so tokens written without an expiry
23
+ * still work. Pass yamlPath to override the default path in tests.
24
+ */
25
+ export function isRushSessionValid(yamlPath = USER_YAML) {
26
+ try {
27
+ if (!fs.existsSync(yamlPath))
28
+ return false;
29
+ const raw = fs.readFileSync(yamlPath, 'utf-8');
30
+ const data = yaml.parse(raw);
31
+ if (!data?.session?.access_token)
32
+ return false;
33
+ const expiresAt = data.session.expires_at;
34
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000)
35
+ return false;
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
19
42
  /** Read the Rush session access token from ~/.rush/user.yaml. */
20
43
  function readToken() {
21
44
  if (!fs.existsSync(USER_YAML)) {
@@ -27,6 +50,11 @@ function readToken() {
27
50
  if (!token) {
28
51
  throw new Error('No session token in ~/.rush/user.yaml. Run `rush login` first.');
29
52
  }
53
+ const expiresAt = data.session?.expires_at;
54
+ if (typeof expiresAt === 'number' && expiresAt <= Date.now() / 1000) {
55
+ const expiredAt = new Date(expiresAt * 1000).toISOString();
56
+ throw new Error(`Rush session expired at ${expiredAt}. Run \`rush login\` to refresh.`);
57
+ }
30
58
  return token;
31
59
  }
32
60
  /** Read the user's email from the Rush session config, if available. */
@@ -203,7 +231,7 @@ export class RushCloudProvider {
203
231
  name = 'Rush Cloud';
204
232
  capabilities() {
205
233
  return {
206
- available: fs.existsSync(USER_YAML),
234
+ available: isRushSessionValid(),
207
235
  dispatch: true,
208
236
  status: true,
209
237
  list: true,
@@ -215,6 +215,28 @@ export declare function anchorDaemonCwd(): string | null;
215
215
  * root is stable (or could not be resolved).
216
216
  */
217
217
  export declare function warnEphemeralDaemonRoot(resolveBin?: () => string): string | null;
218
+ /**
219
+ * Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
220
+ * `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
221
+ * override fails to reach the child — an `env: {...process.env}` spawn that
222
+ * forgot to set it, a login shell that reset HOME — the daemon resolves its
223
+ * state dir under the operator's REAL home and its scheduler/watchdog then tick
224
+ * against shared production state. That is the exact leak the ticket reports:
225
+ * real test daemons found alive on a fleet box, each a second live scheduler
226
+ * racing the legitimate one, in violation of the execution-singularity spec.
227
+ *
228
+ * A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
229
+ * it provisioned. When that marker is present, this daemon's resolved state dir
230
+ * MUST sit under it; otherwise the daemon refuses to boot — failing loud before
231
+ * it claims an instance, writes a pid, or fires a single tick (the throw is
232
+ * caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
233
+ * rather than running in the wrong directory against the real host. In
234
+ * production the marker is never set, so this is a no-op there.
235
+ *
236
+ * `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
237
+ * without spawning a process; the defaults read the live daemon dir and env.
238
+ */
239
+ export declare function assertTestDaemonHome(daemonDir?: string, testHome?: string | undefined): void;
218
240
  export declare function runDaemon(): Promise<void>;
219
241
  /**
220
242
  * Write a launchd plist or systemd unit with owner-only permissions atomically.
@@ -705,6 +705,40 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
705
705
  return null;
706
706
  }
707
707
  }
708
+ /**
709
+ * Test-home tripwire (PHNX-2545). The routines/daemon test suite spawns real
710
+ * `agents __daemon-run` processes against an isolated /tmp HOME. If that HOME
711
+ * override fails to reach the child — an `env: {...process.env}` spawn that
712
+ * forgot to set it, a login shell that reset HOME — the daemon resolves its
713
+ * state dir under the operator's REAL home and its scheduler/watchdog then tick
714
+ * against shared production state. That is the exact leak the ticket reports:
715
+ * real test daemons found alive on a fleet box, each a second live scheduler
716
+ * racing the legitimate one, in violation of the execution-singularity spec.
717
+ *
718
+ * A test that spawns a daemon sets AGENTS_DAEMON_TEST_HOME to the isolated home
719
+ * it provisioned. When that marker is present, this daemon's resolved state dir
720
+ * MUST sit under it; otherwise the daemon refuses to boot — failing loud before
721
+ * it claims an instance, writes a pid, or fires a single tick (the throw is
722
+ * caught in index.ts's `__daemon-run` handler, logged, and exits non-zero) —
723
+ * rather than running in the wrong directory against the real host. In
724
+ * production the marker is never set, so this is a no-op there.
725
+ *
726
+ * `daemonDir`/`testHome` are injectable so the pure guard is unit-testable
727
+ * without spawning a process; the defaults read the live daemon dir and env.
728
+ */
729
+ export function assertTestDaemonHome(daemonDir = getDaemonDir(), testHome = process.env.AGENTS_DAEMON_TEST_HOME) {
730
+ if (!testHome)
731
+ return;
732
+ const root = path.resolve(testHome);
733
+ const dir = path.resolve(daemonDir);
734
+ if (dir !== root && !dir.startsWith(root + path.sep)) {
735
+ const msg = `Daemon test-home tripwire (PHNX-2545): AGENTS_DAEMON_TEST_HOME is ${root}, but this ` +
736
+ `daemon's state dir resolved to ${dir} — the isolated HOME override did not reach this ` +
737
+ `__daemon-run child, so it would schedule against the real host. Refusing to start.`;
738
+ log('ERROR', msg);
739
+ throw new Error(msg);
740
+ }
741
+ }
708
742
  // ---------------------------------------------------------------------------
709
743
  // Module-level periodic maintenance helpers (RUSH-2422)
710
744
  //
@@ -720,6 +754,11 @@ export function warnEphemeralDaemonRoot(resolveBin = getAgentsBinPath) {
720
754
  // below. runBrokerSelfHeal was moved into SecretsBrokerService (RUSH-3193 P2).
721
755
  // ---------------------------------------------------------------------------
722
756
  export async function runDaemon() {
757
+ // PHNX-2545 test-home tripwire — FIRST, before this daemon claims an instance,
758
+ // writes a pid, or fires any tick. A test-spawned daemon that lost its isolated
759
+ // HOME override must refuse to run against the operator's real state rather than
760
+ // schedule against the real host. No-op in production (the marker is never set).
761
+ assertTestDaemonHome();
723
762
  // Single-instance guard (last-wins, SING-11): a direct `agents __daemon-run`
724
763
  // (manual, or a service-manager restart racing a live predecessor) EVICTS the
725
764
  // incumbent and becomes the survivor. claimDaemonInstance returns false only
@@ -11,7 +11,7 @@
11
11
  * and deadline instead.
12
12
  */
13
13
  import { BasePeriodicService } from './service.js';
14
- import { runSessionIndexWarmTick } from '../daemon-ticks.js';
14
+ import { runDeferredToolIndex, runSessionIndexWarmTick } from '../daemon-ticks.js';
15
15
  /** Matches the historical inline interval (daemon.ts SESSION_INDEX_WARM_TICK_MS). */
16
16
  const SESSION_INDEX_WARM_TICK_MS = 20_000;
17
17
  /** Hard cap per tick — well above a healthy incremental scan, short enough that a hang never freezes the service for long. */
@@ -36,5 +36,13 @@ export class SessionIndexService extends BasePeriodicService {
36
36
  ctx.log('INFO', 'session-index warm: skipped, another process holds the scan claim');
37
37
  else if (indexed > 0)
38
38
  ctx.log('INFO', `session-index warm: indexed ${indexed} transcript(s)`);
39
+ // Deferred tool-index pass (PHNX-3411): fill tool_scan_ledger rows for
40
+ // harnesses whose scanner produces no events. Runs only when the warm tick
41
+ // claimed the scan lock (i.e. we are the active indexer this tick).
42
+ if (claimed) {
43
+ const { indexed: toolIndexed } = await runDeferredToolIndex();
44
+ if (toolIndexed > 0)
45
+ ctx.log('INFO', `session-index deferred: tool-indexed ${toolIndexed} transcript(s)`);
46
+ }
39
47
  }
40
48
  }
@@ -121,3 +121,18 @@ export declare function runSessionIndexWarmTick(): Promise<{
121
121
  indexed: number;
122
122
  claimed: boolean;
123
123
  }>;
124
+ /**
125
+ * Deferred tool-index pass for large-transcript harnesses (PHNX-3411).
126
+ *
127
+ * Kimi (wire.jsonl) and Grok (chat_history.jsonl) scanners produce only
128
+ * metadata — no events. Calling parseSession for those on the warm tick wedges
129
+ * the Node event loop when the transcript is large and active (observed: several
130
+ * seconds per tick on zion, causing browser IPC ECONNREFUSED). This pass fills
131
+ * the gap: it queries recently-active kimi/grok sessions and calls
132
+ * ensureToolIndex, which uses tool_scan_ledger stamps to skip already-current
133
+ * sessions and applies byte/file budget caps so no large transcript monopolises
134
+ * the tick.
135
+ */
136
+ export declare function runDeferredToolIndex(): Promise<{
137
+ indexed: number;
138
+ }>;
@@ -185,3 +185,29 @@ export async function runSessionIndexWarmTick() {
185
185
  return { indexed: 0, claimed: false };
186
186
  return { indexed: scanned, claimed: true };
187
187
  }
188
+ /**
189
+ * Deferred tool-index pass for large-transcript harnesses (PHNX-3411).
190
+ *
191
+ * Kimi (wire.jsonl) and Grok (chat_history.jsonl) scanners produce only
192
+ * metadata — no events. Calling parseSession for those on the warm tick wedges
193
+ * the Node event loop when the transcript is large and active (observed: several
194
+ * seconds per tick on zion, causing browser IPC ECONNREFUSED). This pass fills
195
+ * the gap: it queries recently-active kimi/grok sessions and calls
196
+ * ensureToolIndex, which uses tool_scan_ledger stamps to skip already-current
197
+ * sessions and applies byte/file budget caps so no large transcript monopolises
198
+ * the tick.
199
+ */
200
+ export async function runDeferredToolIndex() {
201
+ const { querySessionsForDeferredToolIndex } = await import('./session/db.js');
202
+ const { ensureToolIndex } = await import('./session/tool-index.js');
203
+ // Feed the 200 most-recently-active kimi/grok sessions; ensureToolIndex skips
204
+ // any whose tool_scan_ledger stamp is current.
205
+ const sessions = querySessionsForDeferredToolIndex(200);
206
+ if (sessions.length === 0)
207
+ return { indexed: 0 };
208
+ const coverage = await ensureToolIndex(sessions, {
209
+ maxFiles: 20,
210
+ maxBytes: 20 * 1024 * 1024, // 20 MB — bounds one tick even on large transcripts
211
+ });
212
+ return { indexed: coverage.indexedFiles };
213
+ }
@@ -194,7 +194,11 @@ export declare function devicesPinningBrowserProfile(profile: string): Array<{
194
194
  export declare function listConfiguredDeviceRoles(roster?: string[]): Record<string, ConfiguredDeviceRole>;
195
195
  /** The configured automatic-placement pool mode. Unset means `workers`. */
196
196
  export declare function autoPoolMode(): AutoPoolMode;
197
- /** A device's auto-launch flags, as read by Factory's launch ranking. */
197
+ /**
198
+ * A device's auto-launch flags, read by the automatic-placement pool
199
+ * (`filterAutoPool` drops `enabled: false`; `pickBestDevice` boosts
200
+ * `preferred: true` — see `lib/devices/pool.ts`) and by the menu-bar snapshot.
201
+ */
198
202
  export interface AutoLaunchPreference {
199
203
  enabled?: boolean;
200
204
  preferred?: boolean;
@@ -294,7 +294,7 @@ export const CONFIG_KEYS = [
294
294
  visibility: 'shared',
295
295
  type: 'bool',
296
296
  defaultValue: true,
297
- description: 'Whether AGI EXT auto-launch may pick this device (default on).',
297
+ description: 'Whether `--device auto` (run, teams, AGI EXT) may pick this device (default on). Off drops it from every automatic-placement path.',
298
298
  },
299
299
  {
300
300
  name: 'auto-launch.preferred',
@@ -303,7 +303,7 @@ export const CONFIG_KEYS = [
303
303
  visibility: 'shared',
304
304
  type: 'bool',
305
305
  defaultValue: false,
306
- description: 'Boost this device in AGI EXT auto-launch ranking (default off).',
306
+ description: 'Boost this device in `--device auto` ranking (default off) — picked ahead of load-equal peers when eligible.',
307
307
  },
308
308
  ];
309
309
  /** Look up a key spec by CLI dotted name, or throw listing the known keys. */
@@ -145,8 +145,12 @@ export function parseProbeOutput(host, stdout, fetchedAt) {
145
145
  * numbers, mirroring how garbage POSIX output degrades. */
146
146
  export function parseWinProbeOutput(host, stdout, fetchedAt) {
147
147
  const m = stdout.match(/AGWINSTAT load=([0-9.]*) freeKb=([0-9]+) totalKb=([0-9]+) ncpu=([0-9]+)(?: diskFreeKb=([0-9.]+) diskTotalKb=([0-9.]+))?/);
148
+ // Unparseable output still means the probe RAN — the box answered, we just
149
+ // could not read it. Stamp specsFetchedAt anyway so a hardware-fact carry
150
+ // forward (retainHardwareFacts, RUSH-3096) has a real observation moment
151
+ // instead of undefined.
148
152
  if (!m)
149
- return { host, reachable: true, fetchedAt };
153
+ return { host, reachable: true, fetchedAt, specsFetchedAt: fetchedAt };
150
154
  const loadPercent = m[1] === '' ? undefined : parseFloat(m[1]);
151
155
  const freeKb = parseInt(m[2], 10);
152
156
  const totalKb = parseInt(m[3], 10);
@@ -16,12 +16,20 @@
16
16
  * | no device marked | every online device (unchanged behavior) |
17
17
  * | some marked `worker` | ONLY those workers |
18
18
  * | marked `personal` / `desktop` | never, under either state |
19
+ * | `auto-launch.enabled` off | never, whatever its role or the mode |
19
20
  *
20
- * `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
21
+ * `auto.pool all` turns the WORKER allowlist off; `personal` and `desktop` stay
21
22
  * excluded, because a box the user sits at (personal) or a headed always-on
22
23
  * release/credential box (desktop) is marked precisely so agents stay off it.
24
+ *
25
+ * A device the operator turned off with `agents devices disable <name>`
26
+ * (`auto-launch.enabled` = false) is dropped here too — one operator switch,
27
+ * one place, so `disable` removes a box from EVERY automatic-placement path
28
+ * (run/teams/ssh auto), not just one surface. Its sibling
29
+ * `auto-launch.preferred` does not narrow the pool; it BOOSTS a member in the
30
+ * ranker ({@link autoLaunchPreferredSet}, applied by `pickBestDevice`).
23
31
  */
24
- import { type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
32
+ import { type AutoLaunchPreference, type AutoPoolMode, type ConfiguredDeviceRole } from '../device-config.js';
25
33
  export interface AutoPoolOptions {
26
34
  /** Pool mode; defaults to the configured `auto.pool`. */
27
35
  mode?: AutoPoolMode;
@@ -34,6 +42,14 @@ export interface AutoPoolOptions {
34
42
  * {@link listConfiguredDeviceRoles}.
35
43
  */
36
44
  roster?: string[];
45
+ /**
46
+ * Auto-launch flags by device name; defaults to the fleet-shared block for
47
+ * the roster (or the pool). A device whose `enabled` is `false` is dropped
48
+ * from the pool. Inject `{}` in a pure unit test to keep the rule off disk,
49
+ * exactly as `roles: {}` does for the role rule. See
50
+ * {@link loadAutoLaunchPreferences}.
51
+ */
52
+ autoLaunch?: Record<string, AutoLaunchPreference>;
37
53
  }
38
54
  /**
39
55
  * Narrow a candidate host list to the devices automatic placement may pick.
@@ -44,6 +60,13 @@ export interface AutoPoolOptions {
44
60
  * quietly widening back to the full fleet.
45
61
  */
46
62
  export declare function filterAutoPool(pool: string[], opts?: AutoPoolOptions): string[];
63
+ /**
64
+ * Normalized hosts the operator boosted with `auto-launch.preferred` = true —
65
+ * the set `pickBestDevice` ranks ahead of its peers. Unlike the disable drop,
66
+ * a preference never removes a device: an eligible non-preferred box is still
67
+ * picked when no preferred one is available.
68
+ */
69
+ export declare function autoLaunchPreferredSet(pool: string[], opts?: AutoPoolOptions): Set<string>;
47
70
  /** True when this host is one automatic placement may pick. */
48
71
  export declare function isAutoPoolMember(host: string, opts?: AutoPoolOptions): boolean;
49
72
  /** Device names explicitly marked `worker`, in registry order. */
@@ -16,12 +16,20 @@
16
16
  * | no device marked | every online device (unchanged behavior) |
17
17
  * | some marked `worker` | ONLY those workers |
18
18
  * | marked `personal` / `desktop` | never, under either state |
19
+ * | `auto-launch.enabled` off | never, whatever its role or the mode |
19
20
  *
20
- * `auto.pool all` turns the allowlist off; `personal` and `desktop` stay
21
+ * `auto.pool all` turns the WORKER allowlist off; `personal` and `desktop` stay
21
22
  * excluded, because a box the user sits at (personal) or a headed always-on
22
23
  * release/credential box (desktop) is marked precisely so agents stay off it.
24
+ *
25
+ * A device the operator turned off with `agents devices disable <name>`
26
+ * (`auto-launch.enabled` = false) is dropped here too — one operator switch,
27
+ * one place, so `disable` removes a box from EVERY automatic-placement path
28
+ * (run/teams/ssh auto), not just one surface. Its sibling
29
+ * `auto-launch.preferred` does not narrow the pool; it BOOSTS a member in the
30
+ * ranker ({@link autoLaunchPreferredSet}, applied by `pickBestDevice`).
23
31
  */
24
- import { autoPoolMode, listConfiguredDeviceRoles } from '../device-config.js';
32
+ import { autoPoolMode, listConfiguredDeviceRoles, loadAutoLaunchPreferences, } from '../device-config.js';
25
33
  import { normalizeHost } from '../machine-id.js';
26
34
  /**
27
35
  * Roles that automatic placement never picks, whatever the pool mode.
@@ -44,7 +52,10 @@ export function filterAutoPool(pool, opts = {}) {
44
52
  const roles = opts.roles ?? listConfiguredDeviceRoles(opts.roster ?? pool);
45
53
  const byHost = new Map(Object.entries(roles).map(([name, role]) => [normalizeHost(name), role]));
46
54
  const roleOf = (host) => byHost.get(normalizeHost(host));
55
+ const disabled = disabledAutoLaunchSet(pool, opts);
47
56
  const eligible = pool.filter((host) => {
57
+ if (disabled.has(normalizeHost(host)))
58
+ return false;
48
59
  const role = roleOf(host);
49
60
  return role === undefined || !NEVER_AUTO.has(role);
50
61
  });
@@ -56,6 +67,25 @@ export function filterAutoPool(pool, opts = {}) {
56
67
  return eligible;
57
68
  return eligible.filter((host) => roleOf(host) === 'worker');
58
69
  }
70
+ /** Normalized hosts the operator turned off with `auto-launch.enabled` = false. */
71
+ function disabledAutoLaunchSet(pool, opts) {
72
+ const prefs = opts.autoLaunch ?? loadAutoLaunchPreferences(opts.roster ?? pool);
73
+ return new Set(Object.entries(prefs)
74
+ .filter(([, pref]) => pref.enabled === false)
75
+ .map(([name]) => normalizeHost(name)));
76
+ }
77
+ /**
78
+ * Normalized hosts the operator boosted with `auto-launch.preferred` = true —
79
+ * the set `pickBestDevice` ranks ahead of its peers. Unlike the disable drop,
80
+ * a preference never removes a device: an eligible non-preferred box is still
81
+ * picked when no preferred one is available.
82
+ */
83
+ export function autoLaunchPreferredSet(pool, opts = {}) {
84
+ const prefs = opts.autoLaunch ?? loadAutoLaunchPreferences(opts.roster ?? pool);
85
+ return new Set(Object.entries(prefs)
86
+ .filter(([, pref]) => pref.preferred === true)
87
+ .map(([name]) => normalizeHost(name)));
88
+ }
59
89
  /** True when this host is one automatic placement may pick. */
60
90
  export function isAutoPoolMember(host, opts = {}) {
61
91
  return filterAutoPool([host], opts).length > 0;
@@ -8,12 +8,8 @@ import type { DeviceProfile } from './registry.js';
8
8
  * rewrites the cache, unreachable boxes included).
9
9
  */
10
10
  export declare const STATS_STALE_MS: number;
11
- /** Static hardware totals are valid for seven days. */
12
- export declare const SPECS_STALE_MS: number;
13
11
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
14
12
  export declare function isFreshDeviceStats(stats: DeviceStats, now?: number): boolean;
15
- /** True when cached core, RAM-total, and disk-total facts remain current. */
16
- export declare function isFreshDeviceSpecs(stats: DeviceStats, now?: number): boolean;
17
13
  /**
18
14
  * Carry a device's last successfully-probed hardware facts onto a row whose
19
15
  * probe just came back unreachable (RUSH-3096).
@@ -54,8 +50,6 @@ export interface FleetStatsResult {
54
50
  export interface LoadFleetStatsOptions {
55
51
  /** Skip the cache and live-probe every device (the `--refresh`/`--live` path). */
56
52
  forceRefresh?: boolean;
57
- /** Read only static hardware facts, whose cache lifetime is seven days. */
58
- specsOnly?: boolean;
59
53
  /** Device name of THIS machine — always probed locally (no ssh), never cached-served. */
60
54
  selfName?: string;
61
55
  /** Injectable probes + cache IO for tests (default to the real ssh/local/disk ones). */
@@ -37,17 +37,10 @@ const CACHE_FILE = '.fleet-stats.json';
37
37
  * rewrites the cache, unreachable boxes included).
38
38
  */
39
39
  export const STATS_STALE_MS = 3 * 60_000;
40
- /** Static hardware totals are valid for seven days. */
41
- export const SPECS_STALE_MS = 7 * 24 * 60 * 60_000;
42
40
  /** True when a cached row is still within {@link STATS_STALE_MS}. */
43
41
  export function isFreshDeviceStats(stats, now = Date.now()) {
44
42
  return now - stats.fetchedAt <= STATS_STALE_MS;
45
43
  }
46
- /** True when cached core, RAM-total, and disk-total facts remain current. */
47
- export function isFreshDeviceSpecs(stats, now = Date.now()) {
48
- const fetchedAt = stats.specsFetchedAt ?? stats.fetchedAt;
49
- return now - fetchedAt <= SPECS_STALE_MS;
50
- }
51
44
  /**
52
45
  * Carry a device's last successfully-probed hardware facts onto a row whose
53
46
  * probe just came back unreachable (RUSH-3096).
@@ -141,13 +134,13 @@ export async function loadFleetStats(devices, opts = {}) {
141
134
  const toProbe = [];
142
135
  let servedFromCache = false;
143
136
  for (const d of devices) {
144
- if (d.name === self && !opts.specsOnly) {
137
+ if (d.name === self) {
145
138
  // This machine is always probed locally — cheap, no ssh, always live.
146
139
  toProbe.push(d);
147
140
  continue;
148
141
  }
149
142
  const cached = opts.forceRefresh ? undefined : cache[d.name];
150
- const cacheFresh = cached && (opts.specsOnly ? isFreshDeviceSpecs(cached, now) : isFreshDeviceStats(cached, now));
143
+ const cacheFresh = cached && isFreshDeviceStats(cached, now);
151
144
  if (cached && cacheFresh) {
152
145
  stats.set(d.name, cached);
153
146
  servedFromCache = true;
@@ -82,6 +82,20 @@ export interface VersionResourceReport {
82
82
  * probe), not the pure diff — undefined when uncomputed. */
83
83
  sourceBehind?: SourceLayerBehind[];
84
84
  }
85
+ /**
86
+ * True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
87
+ * FILE — the shape git produces on a client without symlink support (Windows
88
+ * without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
89
+ * link target (a relative path, no trailing newline) instead of the pointed-to
90
+ * content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
91
+ * skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
92
+ * GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
93
+ * an independent resource that no sync can ever reconcile (PHNX-3187). The
94
+ * signal is unambiguous: a whole file with no newline whose entire content
95
+ * resolves to an existing sibling path. Real markdown rule files always contain
96
+ * newlines, so this never false-positives on genuine content.
97
+ */
98
+ export declare function isCheckedOutSymlink(filePath: string): boolean;
85
99
  /**
86
100
  * Describe how a version's marketplace MIRROR of a plugin diverges from its
87
101
  * central source — the detail presence-only checks miss. Surfaces a stale mirror
@@ -63,6 +63,42 @@ function readSafe(file) {
63
63
  function fileExists(p) {
64
64
  return !!p && fs.existsSync(p) && !fs.lstatSync(p).isSymbolicLink();
65
65
  }
66
+ /**
67
+ * True when `filePath` is a git symlink that was CHECKED OUT AS A PLAIN TEXT
68
+ * FILE — the shape git produces on a client without symlink support (Windows
69
+ * without Developer Mode, `core.symlinks=false`). Such a file holds exactly the
70
+ * link target (a relative path, no trailing newline) instead of the pointed-to
71
+ * content. `lstat().isSymbolicLink()` is FALSE for it, so callers that only
72
+ * skip real symlinks (e.g. the rules/permissions alias files CLAUDE.md /
73
+ * GEMINI.md, which are symlinks to AGENTS.md in the repo) wrongly treat it as
74
+ * an independent resource that no sync can ever reconcile (PHNX-3187). The
75
+ * signal is unambiguous: a whole file with no newline whose entire content
76
+ * resolves to an existing sibling path. Real markdown rule files always contain
77
+ * newlines, so this never false-positives on genuine content.
78
+ */
79
+ export function isCheckedOutSymlink(filePath) {
80
+ let content;
81
+ try {
82
+ content = fs.readFileSync(filePath, 'utf-8');
83
+ }
84
+ catch {
85
+ return false;
86
+ }
87
+ // A git symlink blob is the bare target with no newline; any newline means
88
+ // this is real file content, not a link.
89
+ if (content.length === 0 || content.length > 255 || /[\r\n]/.test(content))
90
+ return false;
91
+ const target = content.trim();
92
+ if (!target)
93
+ return false;
94
+ const resolved = path.resolve(path.dirname(filePath), target);
95
+ try {
96
+ return fs.statSync(resolved).isFile();
97
+ }
98
+ catch {
99
+ return false;
100
+ }
101
+ }
66
102
  function findFirst(candidates) {
67
103
  for (const c of candidates) {
68
104
  if (fileExists(c.path) || (fs.existsSync(c.path) && fs.lstatSync(c.path).isDirectory())) {
@@ -419,8 +455,13 @@ function listRulesNames(cwd, excludeProject = false) {
419
455
  for (const file of entries) {
420
456
  if (!file.endsWith('.md') || file === RULES_DOC_FILENAME)
421
457
  continue;
422
- const stat = fs.lstatSync(path.join(base.path, file));
423
- if (stat.isSymbolicLink())
458
+ const filePath = path.join(base.path, file);
459
+ const stat = fs.lstatSync(filePath);
460
+ // Skip the CLAUDE.md / GEMINI.md alias symlinks — both when they are real
461
+ // symlinks (posix) and when git checked them out as plain text files
462
+ // (Windows), so they are never mistaken for independent rule sources the
463
+ // writer can't produce (PHNX-3187).
464
+ if (stat.isSymbolicLink() || isCheckedOutSymlink(filePath))
424
465
  continue;
425
466
  const name = file.replace(/\.md$/, '');
426
467
  if (out.has(name))
@@ -22,6 +22,8 @@ import { stampProvenance, resetEventProvenanceForTest } from '../event-provenanc
22
22
  /** Lazy perf warehouse write — avoids a hard cycle at module load. */
23
23
  function recordPerfTiming(payload) {
24
24
  try {
25
+ const phases = payload.phases && Object.keys(payload.phases).length > 0 ? payload.phases : undefined;
26
+ const metaJson = phases ? JSON.stringify({ phases }) : undefined;
25
27
  // Dynamic import keeps events.ts free of a load-time dependency on perf/db.
26
28
  void import('../perf/spool.js').then(({ recordSample }) => {
27
29
  recordSample({
@@ -33,6 +35,7 @@ function recordPerfTiming(payload) {
33
35
  agentVersion: payload.version,
34
36
  sessionId: payload.sessionId,
35
37
  cwd: payload.cwd,
38
+ metaJson,
36
39
  });
37
40
  }).catch(() => { });
38
41
  }
@@ -693,6 +696,7 @@ export function createTimer(label, payload = {}) {
693
696
  version: merged.version,
694
697
  sessionId: merged.sessionId,
695
698
  cwd: merged.cwd,
699
+ phases: marks,
696
700
  });
697
701
  },
698
702
  };