@phnx-labs/agents-cli 1.22.60 → 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 (85) hide show
  1. package/CHANGELOG.md +49 -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/reminders.d.ts +9 -0
  14. package/dist/commands/reminders.js +49 -0
  15. package/dist/commands/run-account-picker.d.ts +14 -0
  16. package/dist/commands/run-account-picker.js +13 -0
  17. package/dist/commands/teams.d.ts +1 -1
  18. package/dist/commands/teams.js +9 -3
  19. package/dist/index.js +9 -0
  20. package/dist/lib/accounting/rotate.d.ts +63 -0
  21. package/dist/lib/accounting/rotate.js +229 -13
  22. package/dist/lib/browser/drivers/local.d.ts +11 -0
  23. package/dist/lib/browser/drivers/local.js +26 -0
  24. package/dist/lib/browser/profiles.js +8 -6
  25. package/dist/lib/browser/service.d.ts +12 -8
  26. package/dist/lib/browser/service.js +38 -10
  27. package/dist/lib/claude-statusline.d.ts +14 -1
  28. package/dist/lib/claude-statusline.js +27 -2
  29. package/dist/lib/daemon/runner.js +17 -2
  30. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  31. package/dist/lib/devices/doctor-findings.js +22 -4
  32. package/dist/lib/doctor-diff.d.ts +21 -5
  33. package/dist/lib/doctor-diff.js +242 -76
  34. package/dist/lib/feed/events.d.ts +1 -1
  35. package/dist/lib/feed/events.js +25 -16
  36. package/dist/lib/github/gh-overload.d.ts +58 -0
  37. package/dist/lib/github/gh-overload.js +246 -0
  38. package/dist/lib/github/rest.d.ts +64 -0
  39. package/dist/lib/github/rest.js +111 -0
  40. package/dist/lib/harness-connection-test.d.ts +57 -0
  41. package/dist/lib/harness-connection-test.js +80 -0
  42. package/dist/lib/heal.js +8 -3
  43. package/dist/lib/installations/shims.d.ts +22 -0
  44. package/dist/lib/installations/shims.js +104 -0
  45. package/dist/lib/linear-project-counts.js +8 -0
  46. package/dist/lib/linear-rate-limit.d.ts +26 -0
  47. package/dist/lib/linear-rate-limit.js +163 -0
  48. package/dist/lib/mcp.d.ts +9 -0
  49. package/dist/lib/mcp.js +37 -1
  50. package/dist/lib/open-url.js +5 -3
  51. package/dist/lib/permissions.d.ts +28 -0
  52. package/dist/lib/permissions.js +156 -1
  53. package/dist/lib/refresh.js +9 -1
  54. package/dist/lib/reminders.d.ts +29 -0
  55. package/dist/lib/reminders.js +88 -0
  56. package/dist/lib/resource-content-diff.d.ts +33 -0
  57. package/dist/lib/resource-content-diff.js +103 -0
  58. package/dist/lib/rules/compile.d.ts +7 -0
  59. package/dist/lib/rules/compile.js +7 -1
  60. package/dist/lib/session/active.d.ts +41 -4
  61. package/dist/lib/session/active.js +58 -7
  62. package/dist/lib/session/host-link.d.ts +22 -0
  63. package/dist/lib/session/host-link.js +40 -4
  64. package/dist/lib/session/trajectory.d.ts +42 -0
  65. package/dist/lib/session/trajectory.js +46 -27
  66. package/dist/lib/ssh-exec.d.ts +30 -0
  67. package/dist/lib/ssh-exec.js +37 -5
  68. package/dist/lib/startup/command-registry.js +1 -1
  69. package/dist/lib/subagents-registry.d.ts +18 -0
  70. package/dist/lib/subagents-registry.js +79 -0
  71. package/dist/lib/teams/agents.d.ts +12 -0
  72. package/dist/lib/teams/agents.js +51 -0
  73. package/dist/lib/traces/schema2-build.d.ts +85 -0
  74. package/dist/lib/traces/schema2-build.js +637 -0
  75. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  76. package/dist/lib/traces/schema2-danger.js +185 -0
  77. package/dist/lib/traces/schema2.d.ts +149 -0
  78. package/dist/lib/traces/schema2.js +20 -0
  79. package/dist/lib/traces/sync.d.ts +93 -0
  80. package/dist/lib/traces/sync.js +75 -22
  81. package/dist/lib/traces/worker-template.js +5 -0
  82. package/dist/lib/uninstall.js +10 -1
  83. package/dist/lib/workflows.d.ts +11 -0
  84. package/dist/lib/workflows.js +67 -8
  85. 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, (from) => weightedRandomByCapacity(from, nowMs), '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,
@@ -424,13 +452,19 @@ export function pickAvailableCandidate(candidates, preferredVersion, nowMs = Dat
424
452
  // unconfirmed "48% used" outranks an accurate "90% used" — the same inversion
425
453
  // that put a launch on an exhausted account under `balanced`. It routes on the
426
454
  // same cache, so it gets the same rule: confirmed headroom first.
427
- const { picked: bestVerified, usageUnverified } = preferVerified(sorted, nowMs, (from) => from[0]);
455
+ const { picked: bestVerified, usageUnverified, noVerifiedUsage } = preferVerified(sorted, nowMs, (from) => from[0]);
428
456
  // An explicit version preference is an instruction, not a ranking signal, so it
429
457
  // still wins — but only while that version is actually eligible.
430
458
  const preferred = preferredVersion
431
459
  ? sorted.find((candidate) => candidate.version === preferredVersion)
432
460
  : undefined;
433
- 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 };
434
468
  }
435
469
  /**
436
470
  * Classify every harness's candidates into healthy (with a representative
@@ -547,6 +581,37 @@ export function formatNoHealthyAccountError(agent, strategy, excluded, nowMs = D
547
581
  const resetSummary = formatResetSummary(earliestResetAcross(excluded, nowMs));
548
582
  return `agents: no healthy ${agent} account under strategy '${strategy}' — excluded: ${excludedStr}; earliest window resets ${resetSummary}. Use --strategy pinned to force the default.`;
549
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
+ }
550
615
  /**
551
616
  * The zero-healthy-harness error for `agents run auto` — names each harness's
552
617
  * exclusion reason plus the earliest reset across all snapshots.
@@ -724,6 +789,141 @@ function readRotationStamp(agent) {
724
789
  catch { /* missing or corrupt — treat as no stamp */ }
725
790
  return null;
726
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
+ }
727
927
  /**
728
928
  * Resolve the version `agents run` should use when the caller did not pin
729
929
  * one with `@version`. The caller supplies the effective strategy.
@@ -740,6 +940,21 @@ function readRotationStamp(agent) {
740
940
  export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), collect = collectRunCandidates) {
741
941
  const fallback = resolveVersion(agent, cwd);
742
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
+ };
743
958
  if (strategy === 'pinned') {
744
959
  const pinnedCandidate = fallback
745
960
  ? candidates.find((c) => c.version === fallback)
@@ -748,15 +963,14 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
748
963
  // guaranteed miss. Prefer a signed-in sibling on this device.
749
964
  if (pinnedCandidate && isSignInRecoverable(readinessFromCandidate(pinnedCandidate))) {
750
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);
751
972
  if (rotation) {
752
- emit('rotation.resolved', {
753
- module: 'rotate',
754
- agent,
755
- version: rotation.picked.version,
756
- strategy,
757
- healthy: rotation.healthy.length,
758
- excluded: rotation.excluded.length,
759
- });
973
+ emitRotationDecision('rotation.resolved', rotation, agent, strategy);
760
974
  return { version: rotation.picked.version, rotation };
761
975
  }
762
976
  return {
@@ -770,6 +984,8 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
770
984
  const rotation = strategy === 'available'
771
985
  ? pickAvailableCandidate(candidates, fallback)
772
986
  : pickBalancedCandidate(candidates);
987
+ if (rotation && rotation.noVerifiedUsage)
988
+ return refuseStaleUsage(rotation);
773
989
  if (rotation) {
774
990
  // `available` is sticky to the pinned default when healthy. Use the 60s
775
991
  // anti-collision stamp to nudge parallel callers off the same version.
@@ -784,7 +1000,7 @@ export async function resolveRunVersion(agent, strategy, cwd = process.cwd(), co
784
1000
  }
785
1001
  recordRotationPick(agent, rotation.picked.version);
786
1002
  }
787
- 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);
788
1004
  return { version: rotation.picked.version, rotation };
789
1005
  }
790
1006
  return { version: fallback, rotation: null, exhausted: candidates.length > 0 ? candidates : undefined };
@@ -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'));
@@ -9,6 +9,7 @@ export declare const CLAUDE_STATUSLINE_COMMAND = "agents __claude-statusline";
9
9
  export declare function isStatusLineSelfReference(command: string): boolean;
10
10
  interface ClaudeStatusLinePayload {
11
11
  cwd?: string;
12
+ session_id?: string;
12
13
  workspace?: {
13
14
  current_dir?: string;
14
15
  };
@@ -29,7 +30,19 @@ interface ClaudeStatusLinePayload {
29
30
  }
30
31
  export declare function ingestClaudeStatusLineUsage(payload: ClaudeStatusLinePayload, versionHome: string): boolean;
31
32
  export declare function renderDelegate(payload: string, versionHome: string): string;
32
- export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string): string;
33
+ /**
34
+ * Format a reminder as a dimmed statusline part (empty string when none). The
35
+ * ◆ marker distinguishes it from the host/model/usage parts, and the ANSI dim
36
+ * keeps it quiet next to the live figures.
37
+ */
38
+ export declare function formatReminderPart(short: string | undefined): string;
39
+ export declare function renderClaudeStatusLine(payload: ClaudeStatusLinePayload, host?: string, delegated?: string, reminder?: string): string;
40
+ /**
41
+ * Resolve the per-session reminder for the statusline, or '' when none is
42
+ * configured. A malformed reminders file is swallowed here on purpose — a broken
43
+ * prompt is worse than a missing reminder — while `agents reminders` surfaces it.
44
+ */
45
+ export declare function resolveReminderPart(sessionId?: string): string;
33
46
  export declare function runClaudeStatusLine(): Promise<number>;
34
47
  export declare function installClaudeStatusLine(versionHome: string): {
35
48
  changed: boolean;
@@ -5,6 +5,7 @@ import { spawnSync } from 'child_process';
5
5
  import { readClaudeHomeConfig } from './agent-spec/agents.js';
6
6
  import { atomicWriteFileSync } from './fs-atomic.js';
7
7
  import { mergeClaudeUsageCacheWindows } from './accounting/usage.js';
8
+ import { loadReminders, pickReminderForSession } from './reminders.js';
8
9
  export const CLAUDE_STATUSLINE_COMMAND = 'agents __claude-statusline';
9
10
  const DELEGATE_FILE = path.join('.agents', 'claude-statusline-delegate');
10
11
  // The private subcommand this feature runs. It is only ever invoked internally,
@@ -103,7 +104,16 @@ export function renderDelegate(payload, versionHome) {
103
104
  });
104
105
  return result.status === 0 ? result.stdout.trim() : '';
105
106
  }
106
- export function renderClaudeStatusLine(payload, host = os.hostname().split('.')[0] || os.hostname(), delegated = '') {
107
+ /**
108
+ * Format a reminder as a dimmed statusline part (empty string when none). The
109
+ * ◆ marker distinguishes it from the host/model/usage parts, and the ANSI dim
110
+ * keeps it quiet next to the live figures.
111
+ */
112
+ export function formatReminderPart(short) {
113
+ const text = short?.trim();
114
+ return text ? `\x1b[2m◆ ${text}\x1b[22m` : '';
115
+ }
116
+ export function renderClaudeStatusLine(payload, host = os.hostname().split('.')[0] || os.hostname(), delegated = '', reminder = '') {
107
117
  const model = payload.model?.display_name?.trim() || payload.model?.id?.trim() || 'model pending';
108
118
  const parts = [host, model];
109
119
  if (delegated)
@@ -114,8 +124,23 @@ export function renderClaudeStatusLine(payload, host = os.hostname().split('.')[
114
124
  parts.push(`5h ${Math.round(fiveHour)}%`);
115
125
  if (Number.isFinite(sevenDay))
116
126
  parts.push(`7d ${Math.round(sevenDay)}%`);
127
+ if (reminder)
128
+ parts.push(reminder);
117
129
  return parts.join(' · ');
118
130
  }
131
+ /**
132
+ * Resolve the per-session reminder for the statusline, or '' when none is
133
+ * configured. A malformed reminders file is swallowed here on purpose — a broken
134
+ * prompt is worse than a missing reminder — while `agents reminders` surfaces it.
135
+ */
136
+ export function resolveReminderPart(sessionId) {
137
+ try {
138
+ return formatReminderPart(pickReminderForSession(loadReminders(), sessionId)?.short);
139
+ }
140
+ catch {
141
+ return '';
142
+ }
143
+ }
119
144
  export async function runClaudeStatusLine() {
120
145
  const raw = await new Promise((resolve, reject) => {
121
146
  let input = '';
@@ -135,7 +160,7 @@ export async function runClaudeStatusLine() {
135
160
  const versionHome = versionHomeFromEnv(process.env);
136
161
  if (versionHome)
137
162
  ingestClaudeStatusLineUsage(payload, versionHome);
138
- process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : ''));
163
+ process.stdout.write(renderClaudeStatusLine(payload, undefined, versionHome ? renderDelegate(raw, versionHome) : '', resolveReminderPart(payload.session_id)));
139
164
  return 0;
140
165
  }
141
166
  export function installClaudeStatusLine(versionHome) {