@phnx-labs/agents-cli 1.22.59 → 1.22.61

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 (100) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/command-registry.d.ts +1 -0
  3. package/dist/cli/command-registry.js +2 -0
  4. package/dist/commands/browser.js +9 -4
  5. package/dist/commands/doctor.js +1 -1
  6. package/dist/commands/exec.js +35 -1
  7. package/dist/commands/harness-hooks.d.ts +55 -0
  8. package/dist/commands/harness-hooks.js +104 -0
  9. package/dist/commands/harness-wizard.d.ts +33 -14
  10. package/dist/commands/harness-wizard.js +53 -23
  11. package/dist/commands/harness.d.ts +14 -0
  12. package/dist/commands/harness.js +86 -5
  13. package/dist/commands/perf.js +10 -0
  14. package/dist/commands/reminders.d.ts +9 -0
  15. package/dist/commands/reminders.js +49 -0
  16. package/dist/commands/run-account-picker.d.ts +14 -0
  17. package/dist/commands/run-account-picker.js +13 -0
  18. package/dist/commands/sessions-picker.d.ts +13 -0
  19. package/dist/commands/sessions-picker.js +17 -8
  20. package/dist/commands/sessions.js +13 -11
  21. package/dist/commands/teams-picker.js +20 -6
  22. package/dist/commands/teams.d.ts +3 -3
  23. package/dist/commands/teams.js +86 -24
  24. package/dist/index.js +9 -0
  25. package/dist/lib/accounting/rotate.d.ts +63 -0
  26. package/dist/lib/accounting/rotate.js +240 -16
  27. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  28. package/dist/lib/accounting/usage-sync.js +34 -6
  29. package/dist/lib/browser/drivers/local.d.ts +11 -0
  30. package/dist/lib/browser/drivers/local.js +26 -0
  31. package/dist/lib/browser/profiles.js +8 -6
  32. package/dist/lib/browser/service.d.ts +12 -8
  33. package/dist/lib/browser/service.js +38 -10
  34. package/dist/lib/claude-statusline.d.ts +14 -1
  35. package/dist/lib/claude-statusline.js +27 -2
  36. package/dist/lib/daemon/runner.js +17 -2
  37. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  38. package/dist/lib/devices/doctor-findings.js +22 -4
  39. package/dist/lib/doctor-diff.d.ts +21 -5
  40. package/dist/lib/doctor-diff.js +242 -76
  41. package/dist/lib/feed/events.d.ts +1 -1
  42. package/dist/lib/feed/events.js +28 -15
  43. package/dist/lib/github/gh-overload.d.ts +58 -0
  44. package/dist/lib/github/gh-overload.js +246 -0
  45. package/dist/lib/github/rest.d.ts +64 -0
  46. package/dist/lib/github/rest.js +111 -0
  47. package/dist/lib/harness-connection-test.d.ts +57 -0
  48. package/dist/lib/harness-connection-test.js +80 -0
  49. package/dist/lib/heal.js +8 -3
  50. package/dist/lib/installations/shims.d.ts +22 -0
  51. package/dist/lib/installations/shims.js +104 -0
  52. package/dist/lib/linear-project-counts.js +8 -0
  53. package/dist/lib/linear-rate-limit.d.ts +26 -0
  54. package/dist/lib/linear-rate-limit.js +163 -0
  55. package/dist/lib/mcp.d.ts +9 -0
  56. package/dist/lib/mcp.js +37 -1
  57. package/dist/lib/open-url.js +5 -3
  58. package/dist/lib/perf/db.d.ts +1 -1
  59. package/dist/lib/perf/db.js +53 -2
  60. package/dist/lib/perf/types.d.ts +14 -0
  61. package/dist/lib/permissions.d.ts +28 -0
  62. package/dist/lib/permissions.js +156 -1
  63. package/dist/lib/refresh.js +9 -1
  64. package/dist/lib/reminders.d.ts +29 -0
  65. package/dist/lib/reminders.js +88 -0
  66. package/dist/lib/resource-content-diff.d.ts +33 -0
  67. package/dist/lib/resource-content-diff.js +103 -0
  68. package/dist/lib/rules/compile.d.ts +7 -0
  69. package/dist/lib/rules/compile.js +7 -1
  70. package/dist/lib/session/active.d.ts +41 -4
  71. package/dist/lib/session/active.js +58 -7
  72. package/dist/lib/session/host-link.d.ts +22 -0
  73. package/dist/lib/session/host-link.js +40 -4
  74. package/dist/lib/session/live-metadata.js +3 -3
  75. package/dist/lib/session/trajectory.d.ts +42 -0
  76. package/dist/lib/session/trajectory.js +46 -27
  77. package/dist/lib/ssh-exec.d.ts +30 -0
  78. package/dist/lib/ssh-exec.js +37 -5
  79. package/dist/lib/startup/command-registry.js +1 -1
  80. package/dist/lib/subagents-registry.d.ts +18 -0
  81. package/dist/lib/subagents-registry.js +79 -0
  82. package/dist/lib/teams/agents.d.ts +12 -0
  83. package/dist/lib/teams/agents.js +51 -0
  84. package/dist/lib/teams/api.d.ts +8 -0
  85. package/dist/lib/teams/api.js +50 -6
  86. package/dist/lib/teams/delivery.d.ts +14 -4
  87. package/dist/lib/teams/delivery.js +15 -5
  88. package/dist/lib/traces/schema2-build.d.ts +85 -0
  89. package/dist/lib/traces/schema2-build.js +637 -0
  90. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  91. package/dist/lib/traces/schema2-danger.js +185 -0
  92. package/dist/lib/traces/schema2.d.ts +149 -0
  93. package/dist/lib/traces/schema2.js +20 -0
  94. package/dist/lib/traces/sync.d.ts +93 -0
  95. package/dist/lib/traces/sync.js +75 -22
  96. package/dist/lib/traces/worker-template.js +5 -0
  97. package/dist/lib/uninstall.js +10 -1
  98. package/dist/lib/workflows.d.ts +11 -0
  99. package/dist/lib/workflows.js +67 -8
  100. package/package.json +1 -1
@@ -131,6 +131,26 @@ export function isUsageVerified(candidate, nowMs = Date.now()) {
131
131
  return false;
132
132
  return nowMs - capturedAt.getTime() <= USAGE_DECISION_MAX_AGE_MS;
133
133
  }
134
+ /**
135
+ * Whether this candidate carries a STALE-but-present usage number: a snapshot
136
+ * with windows whose capture time is older than {@link USAGE_DECISION_MAX_AGE_MS}.
137
+ *
138
+ * This is the misleading case the initial route must refuse — the number reads
139
+ * "48% used" with the same confidence whether captured a minute or three days
140
+ * ago, and a box whose refresh is failing stays wrong indefinitely. It is
141
+ * deliberately NARROWER than "not verified": a BLIND candidate with no snapshot
142
+ * (or a plan-only meterless one with no windows) carries no number to be misled
143
+ * by — a worker box whose usage endpoint 403s (RUSH-2392), or a meterless Grok
144
+ * login — so it is not "stale", and an entirely-blind pool still draws a pick
145
+ * (PHNX-3392) rather than fail loud with NO_VERIFIED_USAGE.
146
+ */
147
+ export function hasStaleUsage(candidate, nowMs = Date.now()) {
148
+ const snapshot = candidate.usageSnapshot;
149
+ const capturedAt = snapshot?.capturedAt;
150
+ if (!capturedAt || !snapshot?.windows.length)
151
+ return false;
152
+ return nowMs - capturedAt.getTime() > USAGE_DECISION_MAX_AGE_MS;
153
+ }
134
154
  function hasUsageAvailable(candidate) {
135
155
  const snapshot = candidate.usageSnapshot;
136
156
  if (snapshot) {
@@ -313,8 +333,8 @@ export function pickBalancedCandidate(candidates, nowMs = Date.now()) {
313
333
  if (!deduped.has(c))
314
334
  excluded.push(c);
315
335
  }
316
- const { picked, usageUnverified } = preferVerified(sorted, nowMs, weightedRandomByCapacity, 'representative');
317
- return { picked, healthy: sorted, excluded, usageUnverified };
336
+ const { picked, usageUnverified, noVerifiedUsage } = preferVerified(sorted, nowMs, (from) => weightedRandomByCapacity(from, nowMs), 'representative');
337
+ return { picked, healthy: sorted, excluded, usageUnverified, noVerifiedUsage };
318
338
  }
319
339
  /**
320
340
  * Choose from the VERIFIED candidates when they are a representative share of
@@ -361,9 +381,17 @@ function preferVerified(pool, nowMs, choose, narrowing = 'any-verified') {
361
381
  const narrow = verified.length > 0 &&
362
382
  (narrowing === 'any-verified' || verified.length >= Math.ceil(pool.length / 2));
363
383
  const picked = choose(narrow ? verified : pool);
384
+ // Entirely-stale = zero verified AND at least one stale-but-present number. A
385
+ // BLIND pool (no snapshots at all) does NOT trip this — it carries no
386
+ // misleading figure, so it still draws a pick (see {@link hasStaleUsage}). The
387
+ // chosen `picked` is still returned so `healthy` (which includes it) stays
388
+ // whole for bounded post-rejection failover; the INITIAL selection acts on
389
+ // this flag instead of launching that stale pick.
390
+ const noVerifiedUsage = verified.length === 0 && pool.some((c) => hasStaleUsage(c, nowMs));
364
391
  return {
365
392
  picked,
366
393
  usageUnverified: !isUsageVerified(picked, nowMs),
394
+ noVerifiedUsage,
367
395
  };
368
396
  }
369
397
  // capacityWeight + PROJECTION_HORIZON_MIN moved to ./capacity.js (a pure,
@@ -376,8 +404,16 @@ export { PROJECTION_HORIZON_MIN, capacityWeight };
376
404
  * routing capacity (see {@link capacityWeight}). Floor each weight at 1 so a
377
405
  * near-exhausted-but-still-eligible candidate can still be picked occasionally.
378
406
  */
379
- function weightedRandomByCapacity(sorted) {
380
- const weights = sorted.map((c) => capacityWeight(getRoutingUsedPercent(c.usageSnapshot), c.usageMinutesToLimit));
407
+ function weightedRandomByCapacity(sorted, nowMs = Date.now()) {
408
+ const weights = sorted.map((c) => capacityWeight(
409
+ // An unverified (stale or absent) snapshot carries no trustworthy number,
410
+ // so it weights as UNVERIFIED_WEIGHT (the floor) instead of by its frozen
411
+ // usedPercent. Otherwise a day-old "45% used" competes as if it were live
412
+ // headroom and can win the draw over a verified-healthy account — which is
413
+ // exactly how balanced launched into an account already at its weekly cap
414
+ // on a worker whose usage refresh had stalled (PHNX-3479). A verified
415
+ // snapshot keeps its real remaining-headroom weight.
416
+ isUsageVerified(c, nowMs) ? getRoutingUsedPercent(c.usageSnapshot) : null, c.usageMinutesToLimit));
381
417
  const total = weights.reduce((sum, w) => sum + w, 0);
382
418
  if (total <= 0)
383
419
  return sorted[0];
@@ -416,13 +452,19 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
416
452
  // unconfirmed "48% used" outranks an accurate "90% used" — the same inversion
417
453
  // that put a launch on an exhausted account under `balanced`. It routes on the
418
454
  // same cache, so it gets the same rule: confirmed headroom first.
419
- const { picked: bestVerified, usageUnverified } = preferVerified(sorted, nowMs, (from) => from[0]);
455
+ const { picked: bestVerified, usageUnverified, noVerifiedUsage } = preferVerified(sorted, nowMs, (from) => from[0]);
420
456
  // An explicit version preference is an instruction, not a ranking signal, so it
421
457
  // still wins — but only while that version is actually eligible.
422
458
  const preferred = preferredVersion
423
459
  ? sorted.find((candidate) => candidate.version === preferredVersion)
424
460
  : undefined;
425
- return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified };
461
+ // `noVerifiedUsage` rides along even when a `preferred` default resolves: an
462
+ // all-stale pool can only make `preferred` a stale pick too (a verified
463
+ // preferred would make verified.length > 0), and auto-selecting the default
464
+ // pin on a stale number is the very thing PHNX-2526 refuses. The initial
465
+ // route (resolveRunVersion) acts on the flag; the failover chain keeps
466
+ // `healthy` regardless.
467
+ return { picked: preferred ?? bestVerified, healthy: sorted, excluded, usageUnverified, noVerifiedUsage };
426
468
  }
427
469
  /**
428
470
  * Classify every harness's candidates into healthy (with a representative
@@ -485,7 +527,7 @@ export function pickHarnessWeighted(byHarness, nowMs = Date.now()) {
485
527
  const excluded = summaries.filter((s) => s.best === null);
486
528
  if (healthy.length === 0)
487
529
  return null;
488
- const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best));
530
+ const pickedBest = weightedRandomByCapacity(healthy.map((s) => s.best), nowMs);
489
531
  const picked = healthy.find((s) => s.best === pickedBest);
490
532
  return { picked, healthy, excluded };
491
533
  }
@@ -539,6 +581,37 @@ export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = D
539
581
  const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
540
582
  return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
541
583
  }
584
+ /**
585
+ * How old this candidate's usage snapshot is, in whole minutes, or null when it
586
+ * carries no dated snapshot (a blind account). Used only to explain WHY a route
587
+ * was refused as unverified — never to route on.
588
+ */
589
+ function snapshotAgeMinutes(candidate, nowMs) {
590
+ const capturedAt = candidate.usageSnapshot?.capturedAt;
591
+ if (!capturedAt || !candidate.usageSnapshot?.windows.length)
592
+ return null;
593
+ return Math.max(0, Math.round((nowMs - capturedAt.getTime()) / 60_000));
594
+ }
595
+ /**
596
+ * The all-stale-usage error (PHNX-2526) an UNATTENDED `balanced`/`available`
597
+ * run fails loud with when no account's usage is fresh enough to route on. EXACT
598
+ * contract — it MUST contain the literal `NO_VERIFIED_USAGE` so a machine caller
599
+ * (and the Factory watchdog) can tail-detect it distinctly from the
600
+ * `no healthy` throttle error, which is a different condition (throttled vs
601
+ * merely stale). Names each candidate with how stale its snapshot is, so the
602
+ * operator can see the failing-refresh box rather than guess.
603
+ */
604
+ export function formatNoVerifiedUsageError(agent, strategy, candidates, nowMs = Date.now()) {
605
+ const detail = candidates.length === 0
606
+ ? 'no signed-in accounts'
607
+ : candidates.map((c) => {
608
+ const age = snapshotAgeMinutes(c, nowMs);
609
+ const staleness = age === null ? 'no usage snapshot' : `usage ${age}m old`;
610
+ return `${c.version} (${staleness})`;
611
+ }).join(', ');
612
+ const maxAgeMin = Math.round(USAGE_DECISION_MAX_AGE_MS / 60_000);
613
+ return `agents: NO_VERIFIED_USAGE — no signed-in ${agent} account has usage newer than ${maxAgeMin}m under strategy '${strategy}', so routing refuses to guess on a stale number: ${detail}. Refresh usage (agents view ${agent}) or pin the default with --strategy pinned.`;
614
+ }
542
615
  /**
543
616
  * The zero-healthy-harness error for `agents run auto` — names each harness's
544
617
  * exclusion reason plus the earliest reset across all snapshots.
@@ -716,6 +789,141 @@ function readRotationStamp(agent) {
716
789
  catch { /* missing or corrupt — treat as no stamp */ }
717
790
  return null;
718
791
  }
792
+ /** Cap on candidates serialized into a rotation decision event — a pathological
793
+ * backstop on the log line; a real fleet is ~19 accounts, well under it. The
794
+ * candidates are emitted as a keyed OBJECT (not an array) so the event sink's
795
+ * generic `sanitizeNested` does not truncate them to its 10-element array cap
796
+ * (feed/events.ts) — an object is recursed uncapped, so the whole pool up to
797
+ * this bound survives. */
798
+ const ROTATION_EVENT_CANDIDATE_CAP = 32;
799
+ /**
800
+ * Compact, queryable descriptor of ONE candidate exactly as the router saw it,
801
+ * for the `rotation.resolved`/`rotation.unresolved` event. These are the fields
802
+ * that disambiguate WHY a route landed on a bad account, so a post-mortem reads
803
+ * them from `agents events` instead of guessing:
804
+ *
805
+ * - `usageKey` is the per-org quota key — the ONLY identity that joins the same
806
+ * account across devices (version numbers are device-local and meaningless
807
+ * across the fleet).
808
+ * - `tier` is the freshness class the weighting actually used: `verified`
809
+ * (fresh windowed → routed at real headroom), `stale` (windowed but past the
810
+ * {@link USAGE_DECISION_MAX_AGE_MS} decision window → floored weight), `blind`
811
+ * (no snapshot at all — a worker whose usage endpoint 403s, or a never-synced
812
+ * network:false harness → still drawn at floor weight).
813
+ * - `source`/`ageMs`/`capturedAt` expose staleness and provenance: a `last_seen`
814
+ * snapshot showing `tier: verified` with a large `ageMs` is the cross-host
815
+ * clock-skew failure (capturedAt is stamped by the reading host's clock, this
816
+ * `ageMs` by the routing host's — they disagree under skew).
817
+ */
818
+ function describeRotationCandidate(c, nowMs) {
819
+ const snap = c.usageSnapshot;
820
+ const capturedAtMs = snap?.capturedAt ? snap.capturedAt.getTime() : null;
821
+ const tier = isUsageVerified(c, nowMs) ? 'verified' : hasStaleUsage(c, nowMs) ? 'stale' : 'blind';
822
+ const readiness = readinessFromCandidate(c);
823
+ return {
824
+ usageKey: c.usageKey,
825
+ // A RUSH-3182 provider/setup-token account carries a null usageKey and
826
+ // shares its `version` with the native login and every sibling provider
827
+ // account, so neither is a unique identity here. `accountKey` is the pool's
828
+ // own dedup boundary (foldRegistryCandidates), and `providerAccount` names
829
+ // the injected account — together they keep same-version rows distinct.
830
+ accountKey: c.accountKey,
831
+ providerAccount: c.providerAccount ?? null,
832
+ email: c.email,
833
+ version: c.version,
834
+ signedIn: c.signedIn,
835
+ // NOT `authVerdict`: the event sink's generic sanitizer redacts any payload
836
+ // key matching /auth/i to "[REDACTED]" (feed/events.ts SENSITIVE_PAYLOAD_KEY),
837
+ // which would silently blank this field on every row. `credentialVerdict`
838
+ // carries the same value ('revoked'/null/…) past the redaction.
839
+ credentialVerdict: c.authVerdict,
840
+ usageStatus: c.usageStatus,
841
+ tier,
842
+ source: snap?.source ?? null,
843
+ sourceLabel: snap?.sourceLabel ?? null,
844
+ capturedAt: snap?.capturedAt ? snap.capturedAt.toISOString() : null,
845
+ ageMs: capturedAtMs === null ? null : nowMs - capturedAtMs,
846
+ windows: (snap?.windows ?? []).map((w) => ({ key: w.key, usedPercent: Math.round(w.usedPercent) })),
847
+ unavailable: snap?.unavailable?.reason ?? null,
848
+ eligible: readiness.ready,
849
+ excludedReason: readiness.ready ? null : readiness.reason,
850
+ };
851
+ }
852
+ /**
853
+ * Build the enriched `rotation.resolved`/`rotation.unresolved` event payload:
854
+ * the full candidate pool as the router saw it, the pick and WHY, and a
855
+ * freshness tally. Replaces the old `{ version, healthy: <n>, excluded: <n> }`
856
+ * shape, which recorded only counts and a device-local version and so could not
857
+ * tell a blind-pool draw from a skewed-verified pick from a refused-stale route
858
+ * — the exact ambiguity that keeps a bad pick undebuggable from the log. All
859
+ * candidates share ONE `nowMs` so their `tier`/`ageMs` are mutually consistent.
860
+ */
861
+ export function buildRotationDecisionEvent(rotation, agent, strategy) {
862
+ const nowMs = Date.now();
863
+ const tally = { verified: 0, stale: 0, blind: 0 };
864
+ for (const c of rotation.healthy) {
865
+ const t = isUsageVerified(c, nowMs) ? 'verified' : hasStaleUsage(c, nowMs) ? 'stale' : 'blind';
866
+ tally[t] += 1;
867
+ }
868
+ const pickedTier = isUsageVerified(rotation.picked, nowMs)
869
+ ? 'verified'
870
+ : hasStaleUsage(rotation.picked, nowMs)
871
+ ? 'stale'
872
+ : 'blind';
873
+ // WHY the pick: a verified-weighted draw is the healthy path; an
874
+ // `unverified-*-draw` names the fallback the router was forced into (a blind
875
+ // worker pool, or a stale-but-plausible number); `refused-no-verified` is the
876
+ // fail-closed exit where no version launches. Read straight from the result's
877
+ // own `usageUnverified`/`noVerifiedUsage`, so the event can never disagree
878
+ // with what rotation actually did.
879
+ const pickReason = rotation.noVerifiedUsage
880
+ ? 'refused-no-verified'
881
+ : rotation.usageUnverified
882
+ ? `unverified-${pickedTier}-draw`
883
+ : 'verified-weighted';
884
+ const pool = [...rotation.healthy, ...rotation.excluded];
885
+ return {
886
+ module: 'rotate',
887
+ agent,
888
+ strategy,
889
+ version: rotation.picked.version,
890
+ picked: {
891
+ usageKey: rotation.picked.usageKey,
892
+ email: rotation.picked.email,
893
+ version: rotation.picked.version,
894
+ tier: pickedTier,
895
+ },
896
+ pickReason,
897
+ healthy: rotation.healthy.length,
898
+ excluded: rotation.excluded.length,
899
+ freshness: tally,
900
+ // An OBJECT (not an array), so the sink cannot truncate it to 10 entries
901
+ // (see ROTATION_EVENT_CANDIDATE_CAP). Keyed by POOL INDEX, not by version or
902
+ // usageKey: a RUSH-3182 provider-account pool has multiple candidates
903
+ // sharing one `version` AND a null `usageKey` (foldRegistryCandidates), so
904
+ // either would collide and silently drop rows via Object.fromEntries. The
905
+ // index is structural only; each row's identity is its `accountKey` /
906
+ // `usageKey` / `providerAccount`. `candidatesTotal` reveals cap overflow.
907
+ candidates: Object.fromEntries(pool.slice(0, ROTATION_EVENT_CANDIDATE_CAP).map((c, i) => [String(i), describeRotationCandidate(c, nowMs)])),
908
+ candidatesTotal: pool.length,
909
+ };
910
+ }
911
+ /**
912
+ * Build AND emit a rotation decision event without ever letting an observability
913
+ * bug crash a live route. `emit` swallows its own IO errors, but the payload
914
+ * ARGUMENT is evaluated before `emit` is called, so a future null-deref inside
915
+ * `describeRotationCandidate` would otherwise propagate into `resolveRunVersion`
916
+ * and abort the launch. This wraps both build and emit, so the worst case is a
917
+ * lost log line, never a failed run.
918
+ */
919
+ function emitRotationDecision(event, rotation, agent, strategy, extra = {}) {
920
+ try {
921
+ emit(event, { ...buildRotationDecisionEvent(rotation, agent, strategy), ...extra });
922
+ }
923
+ catch {
924
+ /* observability must never break a route */
925
+ }
926
+ }
719
927
  /**
720
928
  * Resolve the version `agents run` should use when the caller did not pin
721
929
  * one with `@version`. The caller supplies the effective strategy.
@@ -732,6 +940,21 @@ function readRotationStamp(agent) {
732
940
  export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
733
941
  const fallback = resolveVersion(agent, cwd);
734
942
  const candidates = await collect(agent);
943
+ // Entirely stale usage (PHNX-2526): every eligible account carries a
944
+ // stale-but-present number and none is verified. Refuse to auto-pick on a
945
+ // number that looks plausible but is wrong. `rotation` is returned so its
946
+ // `healthy` set survives for BOUNDED post-rejection failover, but `version`
947
+ // is null so the caller diverts — interactive to the account picker,
948
+ // unattended to a loud NO_VERIFIED_USAGE exit. Shared across BOTH rotating
949
+ // paths (the pinned auth-blocked-pin fallback AND balanced/available), since
950
+ // both reuse `pickAvailableCandidate`/`pickBalancedCandidate` and neither may
951
+ // launch a stale pick.
952
+ const refuseStaleUsage = (rotation) => {
953
+ emitRotationDecision('rotation.unresolved', rotation, agent, strategy, {
954
+ reason: 'no_verified_usage',
955
+ });
956
+ return { version: null, rotation, noVerifiedUsage: true };
957
+ };
735
958
  if (strategy === 'pinned') {
736
959
  const pinnedCandidate = fallback
737
960
  ? candidates.find((c) => c.version === fallback)
@@ -740,15 +963,14 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
740
963
  // guaranteed miss. Prefer a signed-in sibling on this device.
741
964
  if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
742
965
  const rotation = pickAvailableCandidate(candidates, fallback);
966
+ // The auth-blocked pin rotates to a sibling — an initial selection, so it
967
+ // gets the same verified-only gate as balanced/available. Without this, a
968
+ // revoked pin with only stale siblings launched one blind (the yosemite-s1
969
+ // trap through the pinned path — PR #3295 review).
970
+ if (rotation && rotation.noVerifiedUsage)
971
+ return refuseStaleUsage(rotation);
743
972
  if (rotation) {
744
- emit('rotation.resolved', {
745
- module: 'rotate',
746
- agent,
747
- version: rotation.picked.version,
748
- strategy,
749
- healthy: rotation.healthy.length,
750
- excluded: rotation.excluded.length,
751
- });
973
+ emitRotationDecision('rotation.resolved', rotation, agent, strategy);
752
974
  return { version: rotation.picked.version, rotation };
753
975
  }
754
976
  return {
@@ -762,6 +984,8 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
762
984
  const rotation = strategy === 'available'
763
985
  ? pickAvailableCandidate(candidates, fallback)
764
986
  : pickBalancedCandidate(candidates);
987
+ if (rotation && rotation.noVerifiedUsage)
988
+ return refuseStaleUsage(rotation);
765
989
  if (rotation) {
766
990
  // `available` is sticky to the pinned default when healthy. Use the 60s
767
991
  // anti-collision stamp to nudge parallel callers off the same version.
@@ -776,7 +1000,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
776
1000
  }
777
1001
  recordRotationPick(agent, rotation.picked.version);
778
1002
  }
779
- emit('rotation.resolved', { module: 'rotate', agent, version: rotation.picked.version, strategy, healthy: rotation.healthy.length, excluded: rotation.excluded.length });
1003
+ emitRotationDecision('rotation.resolved', rotation, agent, strategy);
780
1004
  return { version: rotation.picked.version, rotation };
781
1005
  }
782
1006
  return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
@@ -1,9 +1,18 @@
1
1
  import { type DeviceProfile } from '../devices/registry.js';
2
2
  import { type ConfiguredDeviceRole } from '../device-config.js';
3
- import { type CachedUsageSnapshot, type UsageSnapshot } from './usage.js';
3
+ import { type CachedUsageSnapshot } from './usage.js';
4
4
  /** How long a single peer push may take before it is abandoned for this tick. */
5
5
  export declare const USAGE_PUSH_DEADLINE_MS = 20000;
6
6
  export declare const USAGE_PULL_DEADLINE_MS = 20000;
7
+ /**
8
+ * A worker's newest local usage row older than this triggers a pull from the
9
+ * primary. A worker cannot self-read usage (setup-token lacks `user:profile`,
10
+ * RUSH-2392), so its only source of a fresh row is the daemon push every
11
+ * `USAGE_SYNC_TICK_MS` (15m); this cutoff is 2× that tick, so one missed push
12
+ * cycle is tolerated before the worker actively pulls. Bounds worker staleness
13
+ * to ~30m instead of the days-old caches this replaces.
14
+ */
15
+ export declare const USAGE_SYNC_MAX_AGE_MS: number;
7
16
  /** The stdin envelope the `__usage-ingest` verb reads. `v` guards the shape. */
8
17
  export interface UsageSyncPayload {
9
18
  v: 1;
@@ -75,8 +84,9 @@ export interface UsagePullDeps {
75
84
  listRoles?: () => Record<string, ConfiguredDeviceRole>;
76
85
  isPinned?: (name: string) => boolean;
77
86
  exportRows?: () => Record<string, CachedUsageSnapshot>;
78
- readRow?: (usageKey: string) => Pick<UsageSnapshot, 'windows'> | null;
79
87
  ingestRows?: (rows: Record<string, CachedUsageSnapshot>) => number;
88
+ /** Injectable clock for the staleness gate; defaults to `Date.now`. */
89
+ now?: () => number;
80
90
  /** Read the versioned payload from the primary. Default: ssh `__usage-export`. */
81
91
  pull?: (device: DeviceProfile) => {
82
92
  ok: boolean;
@@ -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
  }
@@ -5,6 +5,17 @@ export interface LocalConnection {
5
5
  port: number;
6
6
  pid: number;
7
7
  }
8
+ /**
9
+ * Arc is single-instance: relaunching the Arc binary with a fresh
10
+ * `--user-data-dir` does not create a second debuggable process — macOS routes
11
+ * the launch to the running Arc, which was started with no debug port, so the
12
+ * spawn produces a stray window and no CDP endpoint (issue #2779). agents
13
+ * browser therefore ATTACHES to the user's running Arc and never launches its
14
+ * own. When the running Arc exposes no CDP endpoint on the profile's port, we
15
+ * fail loud with the one relaunch that fixes it rather than silently spawning a
16
+ * duplicate. Exported so the contract is unit-testable without a real Arc.
17
+ */
18
+ export declare function arcAttachRequiredError(profileName: string, port: number): Error;
8
19
  export declare function connectLocal(endpoint: string, profile: BrowserProfile,
9
20
  /**
10
21
  * Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
@@ -31,6 +31,24 @@ const TUNNEL_PROCESS_NAMES = new Set(['ssh', 'autossh', 'mosh-client', 'socat'])
31
31
  function isTunnelProcess(command) {
32
32
  return TUNNEL_PROCESS_NAMES.has(command.toLowerCase());
33
33
  }
34
+ /**
35
+ * Arc is single-instance: relaunching the Arc binary with a fresh
36
+ * `--user-data-dir` does not create a second debuggable process — macOS routes
37
+ * the launch to the running Arc, which was started with no debug port, so the
38
+ * spawn produces a stray window and no CDP endpoint (issue #2779). agents
39
+ * browser therefore ATTACHES to the user's running Arc and never launches its
40
+ * own. When the running Arc exposes no CDP endpoint on the profile's port, we
41
+ * fail loud with the one relaunch that fixes it rather than silently spawning a
42
+ * duplicate. Exported so the contract is unit-testable without a real Arc.
43
+ */
44
+ export function arcAttachRequiredError(profileName, port) {
45
+ return new Error(`Arc is not exposing a CDP endpoint on cdp://127.0.0.1:${port} for profile ` +
46
+ `"${profileName}". Arc is single-instance — agents browser attaches to your ` +
47
+ `RUNNING Arc and never launches a second one, so it will not start an isolated ` +
48
+ `Arc for you. Quit Arc, then relaunch it with remote debugging on this port:\n` +
49
+ ` open -a Arc --args --remote-debugging-port=${port}\n` +
50
+ `and retry. The port must match the profile's endpoint (\`agents browser profiles list\`).`);
51
+ }
34
52
  export async function connectLocal(endpoint, profile,
35
53
  /**
36
54
  * Runtime key (`<profile>@<endpoint>`) the launched browser's user-data-dir,
@@ -71,6 +89,14 @@ key) {
71
89
  if (err instanceof Error && err.message.startsWith('Browser identity mismatch')) {
72
90
  throw err;
73
91
  }
92
+ // Arc reached the catch, so the attach above failed: the running Arc is not
93
+ // serving CDP on this port. Never fall through to launchBrowser — that would
94
+ // spawn the duplicate/stray-window this ticket set out to end (#2779,
95
+ // PHNX-2399). Fail loud with the relaunch that makes the user's Arc
96
+ // attachable.
97
+ if (profile.browser === 'arc') {
98
+ throw arcAttachRequiredError(profile.name, port);
99
+ }
74
100
  // Distinguish "nothing listening on this port" (fine to launch fresh) from
75
101
  // "something is listening but it's not a debuggable browser" (bail loudly —
76
102
  // silently launching on a different port leads to confusing `pid 0` and
@@ -513,13 +513,15 @@ export async function editProfile(name, patch) {
513
513
  const current = configToProfile(name, local.config, devices);
514
514
  const merged = { ...current, ...patch, name };
515
515
  const changed = Object.keys(patch).filter((k) => JSON.stringify(current[k]) !== JSON.stringify(merged[k]));
516
- // A target filter only means anything for an Electron app, and the gate must
516
+ // A target filter only means anything for a profile that reuses an existing
517
+ // page target rather than creating one — an Electron app or Arc (which crashes
518
+ // on tab creation, so it drives an open tab / Space, PHNX-2399). The gate must
517
519
  // read the MERGED record: `--target-filter x` on a profile that is already
518
- // electron is valid, and `--no-electron` on one that still carries a filter is
519
- // not. Checking the patch alone would accept both.
520
- if (merged.targetFilter && !merged.electron) {
521
- throw new Error(`--target-filter only applies to an Electron profile. ` +
522
- `Pass --electron, or clear the filter with --target-filter ''.`);
520
+ // electron/arc is valid, and `--no-electron` on a Chromium profile that still
521
+ // carries a filter is not. Checking the patch alone would accept both.
522
+ if (merged.targetFilter && !merged.electron && merged.browser !== 'arc') {
523
+ throw new Error(`--target-filter only applies to an Electron or Arc profile. ` +
524
+ `Pass --electron, use --browser arc, or clear the filter with --target-filter ''.`);
523
525
  }
524
526
  assertLocalPortFree(merged, { ignore: name });
525
527
  // Same rule as create: the binary lives on the remote for an SSH profile, so a
@@ -53,9 +53,10 @@ interface ProfileConnection {
53
53
  electron?: boolean;
54
54
  /**
55
55
  * The profile's declared browser family. Load-bearing for Arc: Arc answers
56
- * `Browser.getVersion` but exposes zero CDP page targets and CRASHES on
57
- * `Target.createTarget` (verified live, PR #2778), so any tab-creating path
58
- * must refuse rather than crash the user's Arc. See `createPageTarget`.
56
+ * `Browser.getVersion` and DOES expose CDP page targets it honors `Page.navigate`
57
+ * on, but CRASHES on `Target.createTarget` (verified live, PR #2778), so any
58
+ * tab-CREATING path must refuse and drive an EXISTING tab instead rather than
59
+ * crash the user's Arc. See `createPageTarget` / `pickReusableTargetWithoutCreate`.
59
60
  */
60
61
  browserType?: BrowserType;
61
62
  /** Raw `url:<v>` / `title:<v>` filter copied from the profile config. */
@@ -88,11 +89,14 @@ interface ProfileConnection {
88
89
  /** Join error lines so callers get a next command, not a dead-end message. */
89
90
  export declare function actionable(...lines: string[]): string;
90
91
  /**
91
- * Arc answers `Browser.getVersion` (so a connection succeeds) but exposes zero
92
- * CDP page targets via every discovery method and CRASHES when a new tab is
93
- * requested via `Target.createTarget` (verified live, PR #2778). It is therefore
94
- * not drivable. This is the single clear, actionable error every tab-creating
95
- * path throws instead of crashing the user's Arc window.
92
+ * Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
93
+ * user already has open (measured live against Arc: 33 targets, PR #2786), so
94
+ * agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
95
+ * survive is `Target.createTarget` — opening a brand-new tab crashes the user's
96
+ * Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
97
+ * fails clearly here (PHNX-2399): every tab-creating path throws this actionable
98
+ * error instead of crashing Arc, and steers the caller to reuse an existing
99
+ * tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
96
100
  */
97
101
  export declare function arcNotDrivableError(profileName?: string): Error;
98
102
  /** Derive a human label from an explicit title or a navigated URL. */
@@ -247,15 +247,18 @@ export function actionable(...lines) {
247
247
  return lines.filter((l) => l != null && l !== '').join('\n');
248
248
  }
249
249
  /**
250
- * Arc answers `Browser.getVersion` (so a connection succeeds) but exposes zero
251
- * CDP page targets via every discovery method and CRASHES when a new tab is
252
- * requested via `Target.createTarget` (verified live, PR #2778). It is therefore
253
- * not drivable. This is the single clear, actionable error every tab-creating
254
- * path throws instead of crashing the user's Arc window.
250
+ * Arc DOES expose CDP page targets and honors `Page.navigate` on the ones the
251
+ * user already has open (measured live against Arc: 33 targets, PR #2786), so
252
+ * agents browser drives an attached Arc by reusing existing tabs. What it CANNOT
253
+ * survive is `Target.createTarget` — opening a brand-new tab crashes the user's
254
+ * Arc window (verified live, PR #2778). Tab creation is the one CDP-only op that
255
+ * fails clearly here (PHNX-2399): every tab-creating path throws this actionable
256
+ * error instead of crashing Arc, and steers the caller to reuse an existing
257
+ * tab/Space (via `--target-filter`) or to a Chromium-family browser for new tabs.
255
258
  */
256
259
  export function arcNotDrivableError(profileName) {
257
260
  const scope = profileName ? ` (profile "${profileName}")` : '';
258
- return new Error(actionable(`Browser "arc"${scope} is not drivable: Arc exposes no CDP page targets and`, 'crashes when a new tab is requested, so `agents browser` cannot control it.', 'Use a Chromium-family browser instead — Comet, Chrome, Chromium, or Brave:', ' agents browser profiles create <name> --browser comet'));
261
+ return new Error(actionable(`Browser "arc"${scope} cannot open a NEW tab: Arc crashes when a tab is created`, 'over CDP (Target.createTarget). agents browser drives Arc by attaching to your', 'running window and reusing an EXISTING tab — bind the profile to the tab/Space you', 'want with --target-filter (url:<substring> or title:<substring>), or navigate to a', 'URL that is already open. To open brand-new tabs, use a Chromium-family browser:', ' agents browser profiles create <name> --browser comet'));
259
262
  }
260
263
  /** Derive a human label from an explicit title or a navigated URL. */
261
264
  export function deriveTaskLabel(opts) {
@@ -481,7 +484,7 @@ export class BrowserService {
481
484
  // If URL provided, reclaim a tab an abandoned task is holding on it, else
482
485
  // create one directly (no about:blank).
483
486
  let tabId;
484
- if (opts.url && !conn.electron) {
487
+ if (opts.url && !conn.electron && conn.browserType !== 'arc') {
485
488
  const adopted = opts.fresh ? undefined : await this.adoptTabShowing(conn, opts.url);
486
489
  const targetId = adopted ?? (await this.createPageTarget(conn, { url: opts.url })).targetId;
487
490
  const shortId = generateShortId();
@@ -491,7 +494,13 @@ export class BrowserService {
491
494
  await this.saveTaskState(effectiveKey, conn.tasks);
492
495
  tabId = shortId;
493
496
  }
494
- else if (opts.url && conn.electron) {
497
+ else if (opts.url && (conn.electron || conn.browserType === 'arc')) {
498
+ // Electron and Arc share the reuse-in-place path: neither may open a fresh
499
+ // tab here (Electron drives its one window; Arc crashes on Target.createTarget),
500
+ // so the implicit first navigate attaches to an existing tab — honoring the
501
+ // profile's --target-filter — instead of throwing. This is what makes the
502
+ // documented `navigate --profile arc --url …` first-use workflow attach rather
503
+ // than refuse on a task-less profile (PHNX-2399 review).
495
504
  const result = await this.navigate(taskName, opts.url, effectiveKey);
496
505
  tabId = result.tabId;
497
506
  }
@@ -583,13 +592,32 @@ export class BrowserService {
583
592
  for (const id of Object.values(t.tabs))
584
593
  owned.add(id);
585
594
  }
586
- const free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
595
+ let free = targetInfos.filter((t) => t.type === 'page' && !owned.has(t.targetId));
596
+ // A bound --target-filter (url:/title:<substring>) is how the profile names the
597
+ // tab/Space it drives — e.g. an Arc Space pinned with `--target-filter url:notion.so`.
598
+ // Scope the reusable set to tabs matching it; an explicit filter that matches nothing
599
+ // returns undefined so the caller refuses rather than borrowing an unrelated tab (the
600
+ // same contract pickWindowTarget holds for the tab-creating path). Without this the
601
+ // filter was accepted and stored but never consulted when driving Arc (PHNX-2399 review).
602
+ const parsed = parseTargetFilter(conn.targetFilter);
603
+ if (parsed) {
604
+ const needle = parsed.value.toLowerCase();
605
+ free = free.filter((t) => ((parsed.kind === 'url' ? t.url : t.title) ?? '').toLowerCase().includes(needle));
606
+ if (free.length === 0)
607
+ return undefined;
608
+ }
587
609
  const wanted = canonicalTabUrl(url);
588
610
  const sameUrl = free.find((t) => canonicalTabUrl(t.url) === wanted);
589
611
  if (sameUrl)
590
612
  return sameUrl.targetId;
591
613
  const BLANK = new Set(['', 'about:blank', 'about:newtab', 'chrome://newtab/', 'chrome://new-tab-page/']);
592
- return free.find((t) => BLANK.has(t.url))?.targetId;
614
+ const blank = free.find((t) => BLANK.has(t.url));
615
+ if (blank)
616
+ return blank.targetId;
617
+ // With a filter set, every remaining tab already IS the bound Space, so reuse the
618
+ // first even when it is neither the exact URL nor blank — that is the target the
619
+ // operator pinned. With no filter, only an exact-URL or blank tab is safe to borrow.
620
+ return parsed ? free[0]?.targetId : undefined;
593
621
  }
594
622
  async adoptTabShowing(conn, url) {
595
623
  const { targetInfos } = (await conn.cdp.send('Target.getTargets'));