@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
@@ -88,6 +88,28 @@ export declare function createBrandShim(name: string): string;
88
88
  export declare function isBrandShim(filePath: string): boolean;
89
89
  /** Remove a brand's shim companions. Returns true if anything was removed. */
90
90
  export declare function removeBrandShim(name: string): boolean;
91
+ /**
92
+ * The POSIX gh overload shim. Self-healing by construction: it resolves the real
93
+ * gh (first `gh` on PATH that is not this shims dir) and execs it directly whenever
94
+ * agents-cli is gone, the sentinel is already set (recursion), or the verb is
95
+ * anything but `pr checks` — so a leftover/orphaned shim can never break `gh`.
96
+ */
97
+ export declare function generateGhOverloadShim(): string;
98
+ /** True when the file is our gh overload shim (not a user's real gh). */
99
+ export declare function isGhOverloadShim(filePath: string): boolean;
100
+ /**
101
+ * Create/refresh the gh overload shim so a user's `gh pr checks` transparently
102
+ * escapes the GraphQL rate limit. POSIX only in v1 (Windows keeps native gh —
103
+ * resolving the real gh without recursion needs a separate `.cmd` design).
104
+ *
105
+ * Skips (returns null) when there is no real `gh` to overload — shadowing a
106
+ * non-existent binary would only turn a clean "command not found" into shim
107
+ * output. Never clobbers a non-shim `gh` a user placed in the shims dir. If a real
108
+ * gh later disappears, the shim itself fails loud with 127 rather than looping.
109
+ */
110
+ export declare function ensureGhOverloadShim(): string | null;
111
+ /** Remove the gh overload shim — real gh returns immediately. Called on uninstall. */
112
+ export declare function removeGhOverloadShim(): boolean;
91
113
  /** Remove the shim(s) for an agent. */
92
114
  export declare function removeShim(agent: AgentId): boolean;
93
115
  /**
@@ -806,6 +806,110 @@ export function removeBrandShim(name) {
806
806
  }
807
807
  return removed;
808
808
  }
809
+ // ── gh overload shim ────────────────────────────────────────────────────────
810
+ // A transparent interceptor for the ONE thing that keeps rate-limiting the fleet:
811
+ // agents' trained `gh pr checks`. It routes that (and only that) to REST via
812
+ // `agents __gh`, and execs the real gh for everything else. Agents keep their
813
+ // habit; we decide what it runs. See cli/src/lib/github/gh-overload.ts + PHNX-3501.
814
+ const GH_OVERLOAD_MARKER = '# gh overload shim:';
815
+ /**
816
+ * The POSIX gh overload shim. Self-healing by construction: it resolves the real
817
+ * gh (first `gh` on PATH that is not this shims dir) and execs it directly whenever
818
+ * agents-cli is gone, the sentinel is already set (recursion), or the verb is
819
+ * anything but `pr checks` — so a leftover/orphaned shim can never break `gh`.
820
+ */
821
+ export function generateGhOverloadShim() {
822
+ const agentsBin = shellQuote(getAgentsBinForGeneratedShim());
823
+ const shimsDir = shellQuote(getShimsDir());
824
+ return `#!/bin/sh
825
+ # Auto-generated by agents-cli - do not edit
826
+ ${GH_OVERLOAD_MARKER} routes 'gh pr checks' to REST via 'agents __gh' (GraphQL rate-limit escape)
827
+ AGENTS_BIN=${agentsBin}
828
+ SHIMS_DIR=${shimsDir}
829
+ find_real_gh() {
830
+ _oldifs=$IFS; IFS=:
831
+ for _d in $PATH; do
832
+ [ "$_d" = "$SHIMS_DIR" ] && continue
833
+ if [ -x "$_d/gh" ]; then IFS=$_oldifs; printf '%s\\n' "$_d/gh"; return 0; fi
834
+ done
835
+ IFS=$_oldifs; return 1
836
+ }
837
+ REAL_GH=$(find_real_gh)
838
+ # No real gh on PATH: fail loud like "command not found" (127). NEVER fall back to
839
+ # the bare string 'gh' — on a gh-less box that resolves to THIS shim and loops.
840
+ if [ -z "$REAL_GH" ]; then
841
+ echo "gh: not found (agents-cli gh overload: no real gh on PATH)" >&2
842
+ exit 127
843
+ fi
844
+ # Self-heal + recursion guard: agents-cli missing, or already inside the overload,
845
+ # or any verb other than 'pr checks' -> just be plain gh. REAL_GH is always an
846
+ # absolute path here, so this can never re-enter the shim.
847
+ if [ -n "$AGENTS_GH_SHIM" ] || [ -z "$AGENTS_BIN" ] || [ ! -x "$AGENTS_BIN" ]; then
848
+ exec "$REAL_GH" "$@"
849
+ fi
850
+ if [ "$1" = "pr" ] && [ "$2" = "checks" ]; then
851
+ AGENTS_GH_SHIM=1 exec "$AGENTS_BIN" __gh --real-gh "$REAL_GH" -- "$@"
852
+ fi
853
+ exec "$REAL_GH" "$@"
854
+ `;
855
+ }
856
+ /** True when the file is our gh overload shim (not a user's real gh). */
857
+ export function isGhOverloadShim(filePath) {
858
+ try {
859
+ return fs.readFileSync(filePath, 'utf-8').slice(0, 300).includes(GH_OVERLOAD_MARKER);
860
+ }
861
+ catch {
862
+ return false;
863
+ }
864
+ }
865
+ /** True when a REAL `gh` binary exists on PATH outside our shims dir. */
866
+ function hasRealGhOnPath() {
867
+ const shimsDir = path.resolve(getShimsDir());
868
+ for (const dir of (process.env.PATH || '').split(path.delimiter)) {
869
+ if (!dir || path.resolve(dir) === shimsDir)
870
+ continue;
871
+ const candidate = path.join(dir, 'gh');
872
+ try {
873
+ fs.accessSync(candidate, fs.constants.X_OK);
874
+ return true;
875
+ }
876
+ catch {
877
+ // not here / not executable — keep scanning
878
+ }
879
+ }
880
+ return false;
881
+ }
882
+ /**
883
+ * Create/refresh the gh overload shim so a user's `gh pr checks` transparently
884
+ * escapes the GraphQL rate limit. POSIX only in v1 (Windows keeps native gh —
885
+ * resolving the real gh without recursion needs a separate `.cmd` design).
886
+ *
887
+ * Skips (returns null) when there is no real `gh` to overload — shadowing a
888
+ * non-existent binary would only turn a clean "command not found" into shim
889
+ * output. Never clobbers a non-shim `gh` a user placed in the shims dir. If a real
890
+ * gh later disappears, the shim itself fails loud with 127 rather than looping.
891
+ */
892
+ export function ensureGhOverloadShim() {
893
+ if (!shimTargetsFor(process.platform).bash)
894
+ return null;
895
+ if (!hasRealGhOnPath())
896
+ return null;
897
+ ensureAgentsDir();
898
+ const shimPath = path.join(getShimsDir(), 'gh');
899
+ if (fs.existsSync(shimPath) && !isGhOverloadShim(shimPath))
900
+ return null;
901
+ fs.writeFileSync(shimPath, generateGhOverloadShim(), { mode: 0o755 });
902
+ return shimPath;
903
+ }
904
+ /** Remove the gh overload shim — real gh returns immediately. Called on uninstall. */
905
+ export function removeGhOverloadShim() {
906
+ const shimPath = path.join(getShimsDir(), 'gh');
907
+ if (fs.existsSync(shimPath) && isGhOverloadShim(shimPath)) {
908
+ fs.unlinkSync(shimPath);
909
+ return true;
910
+ }
911
+ return false;
912
+ }
809
913
  /**
810
914
  * Generate a Windows `.cmd` launcher that delegates to `agents __shim <spec>`.
811
915
  * `spec` is the agent's cliCommand for the default-version shim, or
@@ -28,6 +28,7 @@ import * as fs from 'fs';
28
28
  import * as os from 'os';
29
29
  import * as path from 'path';
30
30
  import { isRateLimited, noteRateLimited, parseRateLimitReset, readCached, writeCached, resolveLinearApiKey } from './linear-cache.js';
31
+ import { reserveLinearRequest } from './linear-rate-limit.js';
31
32
  const LINEAR_API = 'https://api.linear.app/graphql';
32
33
  /** Overall budget across all pages — the card must never hang on Linear. */
33
34
  const TIMEOUT_MS = 8_000;
@@ -212,6 +213,13 @@ async function fetchLinearIssuesPage(projectId, after, signal) {
212
213
  const apiKey = resolveApiKey();
213
214
  if (!apiKey)
214
215
  return undefined;
216
+ // Proactive shared budget (PHNX-2310): every agent on this key spends from one
217
+ // hourly pool, so N concurrent drains can't collectively blow Linear's 2500/hr
218
+ // limit. When the pool is spent, skip the request — the accumulator serves the
219
+ // last good (stale) snapshot instead, exactly as it does on any other failure.
220
+ // This is the proactive complement to the reactive 429 backoff below.
221
+ if (!reserveLinearRequest(apiKey))
222
+ return undefined;
215
223
  // The declared-milestone list rides along on the FIRST page only — it does
216
224
  // not paginate, and re-requesting it per page would spend up to MAX_PAGES
217
225
  // copies of the same answer.
@@ -0,0 +1,26 @@
1
+ /** One hour, the window Linear's request quota is measured over. */
2
+ export declare const LINEAR_RATE_WINDOW_MS: number;
3
+ /**
4
+ * The shared proactive budget, one key's requests per rolling hour. Deliberately
5
+ * under Linear's hard 2500/hr so it leaves headroom for a human using the key and
6
+ * absorbs the concurrent check-then-create overshoot (at most N-1 for N racing
7
+ * agents) without ever reaching a real 429.
8
+ */
9
+ export declare const LINEAR_HOURLY_REQUEST_BUDGET = 2400;
10
+ export declare function setLinearRateLimitDirForTest(dir: string | null): string | null;
11
+ /**
12
+ * Count this key's requests inside the rolling window, sweeping elapsed stamp
13
+ * files as it goes (they can only accumulate at the rate requests are issued, and
14
+ * this is the one place that already lists them). Pure read otherwise.
15
+ */
16
+ export declare function linearRequestsInWindow(apiKey: string, nowMs?: number): number;
17
+ /**
18
+ * Try to reserve one Linear request against the shared hourly budget.
19
+ *
20
+ * Returns `true` and records the reservation when there is room, `false` when the
21
+ * budget is exhausted — the caller then serves the cache instead of spending a
22
+ * request that would 429. The write is a single empty file whose NAME carries the
23
+ * whole record, so a concurrent reserver can neither clobber it nor be clobbered
24
+ * by it.
25
+ */
26
+ export declare function reserveLinearRequest(apiKey: string, nowMs?: number): boolean;
@@ -0,0 +1,163 @@
1
+ /**
2
+ * A shared, cross-process request budget for the Linear API — the proactive half
3
+ * of the frugality story `linear-cache.ts` started.
4
+ *
5
+ * Linear meters requests per API KEY at 2500/hr (complexity is 99.999% untouched,
6
+ * so requests are the only budget that binds — see `linear-cache.ts`). The
7
+ * failure this exists to stop: this box routinely runs ~13 agent sessions on ONE
8
+ * key, each a separate short-lived process, and nothing coordinated their
9
+ * spending. `linear-cache.ts` already caches reads and backs off AFTER a 429
10
+ * lands (`isRateLimited`/`noteRateLimited`), but that is reactive — by the time
11
+ * the 429 arrives the budget is already gone and every agent's status update is
12
+ * throttled fleet-wide (PHNX-2310). This adds the missing PROACTIVE gate: before
13
+ * a request goes out, it must fit inside a shared hourly budget, so the N agents
14
+ * degrade to serving the (stale) cache instead of collectively blowing the limit.
15
+ *
16
+ * ## The budget lives in the FILENAMES, and that is the whole design
17
+ *
18
+ * State is on disk, not in memory, because the spenders are separate processes.
19
+ * The obvious shape — one JSON document holding a counter — is read-modify-write,
20
+ * and without a lock two agents reserving at once both read the old count and let
21
+ * the lower write land last, silently under-counting. That is not theoretical
22
+ * here: the triggering condition is a burst of concurrent agents, which is
23
+ * exactly what a fleet drain issues. This is the same race `usage-backoff.ts`
24
+ * documents, and the answer is the same: no shared mutable document.
25
+ *
26
+ * Each reserved request is its own empty file, `<createdMs>.<pid>.<seq>`, and the
27
+ * count is `readdir().length` after elapsed files (older than the window) are
28
+ * swept. Two concurrent writers create two DIFFERENT files and neither can erase
29
+ * the other, so the count can only be under-read by an in-flight peer that has
30
+ * not yet created its file — never corrupted, and never over-counted. A sliding
31
+ * one-hour window of stamp files is a token bucket whose tokens refill as the
32
+ * oldest requests age out, with no lock on a path every reservation touches.
33
+ *
34
+ * The budget is set BELOW Linear's hard 2500/hr ({@link LINEAR_HOURLY_REQUEST_BUDGET}).
35
+ * Two reasons: it leaves headroom for a human running `projects status` by hand
36
+ * (the CLI is not the only caller of the key), and it absorbs the small
37
+ * check-then-create race — N agents reserving in the same instant can each read
38
+ * `count < budget` and all create, overshooting by at most (N-1). Bounding the
39
+ * budget under the real ceiling keeps that overshoot from ever reaching a real 429.
40
+ *
41
+ * State is per KEY (hashed, so the raw key never lands on disk), because the
42
+ * 2500/hr quota is per key: two different keys spend independently.
43
+ *
44
+ * Scope is per MACHINE. The state lives under `getCacheDir()`
45
+ * (`~/.agents/.cache/`), which is machine-local and NOT fleet-synced — so this
46
+ * budgets the many concurrent agent processes on one box against each other, the
47
+ * case that actually exhausted the key (~13 sessions on one machine). Two
48
+ * different boxes sharing the same key each keep their own budget, so their
49
+ * aggregate can still exceed 2500/hr; coordinating the budget across devices
50
+ * (a synced or served counter) is a known limitation, not yet built.
51
+ */
52
+ import * as crypto from 'crypto';
53
+ import * as fs from 'fs';
54
+ import * as path from 'path';
55
+ import { getCacheDir } from './state.js';
56
+ /** One hour, the window Linear's request quota is measured over. */
57
+ export const LINEAR_RATE_WINDOW_MS = 60 * 60 * 1000;
58
+ /**
59
+ * The shared proactive budget, one key's requests per rolling hour. Deliberately
60
+ * under Linear's hard 2500/hr so it leaves headroom for a human using the key and
61
+ * absorbs the concurrent check-then-create overshoot (at most N-1 for N racing
62
+ * agents) without ever reaching a real 429.
63
+ */
64
+ export const LINEAR_HOURLY_REQUEST_BUDGET = 2400;
65
+ /**
66
+ * Test seam, mirroring `setUsageBackoffDirForTest`. The cache dir resolves `HOME`
67
+ * once at import time, so a test that swaps `process.env.HOME` afterwards would
68
+ * otherwise read and WRITE the developer's real cache and throttle their own
69
+ * Linear reads. Returns the previous value so a test can restore it.
70
+ */
71
+ let rateLimitDirOverride = null;
72
+ export function setLinearRateLimitDirForTest(dir) {
73
+ const prev = rateLimitDirOverride;
74
+ rateLimitDirOverride = dir;
75
+ return prev;
76
+ }
77
+ function rateLimitRoot() {
78
+ return rateLimitDirOverride ?? path.join(getCacheDir(), 'linear-rate-limit');
79
+ }
80
+ /**
81
+ * Per-key directory. The key is hashed so the raw credential never touches disk;
82
+ * a short hex prefix is collision-safe enough for the handful of keys one fleet
83
+ * uses and keeps the path short.
84
+ */
85
+ function keyDir(apiKey) {
86
+ const hash = crypto.createHash('sha256').update(apiKey).digest('hex').slice(0, 16);
87
+ return path.join(rateLimitRoot(), hash);
88
+ }
89
+ /** Parse `<createdMs>.<pid>.<seq>` back to its creation instant, or null if it is not one of ours. */
90
+ function createdMsOf(name) {
91
+ const first = name.indexOf('.');
92
+ if (first <= 0)
93
+ return null;
94
+ const head = name.slice(0, first);
95
+ if (!/^\d+$/.test(head))
96
+ return null;
97
+ const n = Number(head);
98
+ return Number.isFinite(n) ? n : null;
99
+ }
100
+ /**
101
+ * Count this key's requests inside the rolling window, sweeping elapsed stamp
102
+ * files as it goes (they can only accumulate at the rate requests are issued, and
103
+ * this is the one place that already lists them). Pure read otherwise.
104
+ */
105
+ export function linearRequestsInWindow(apiKey, nowMs = Date.now()) {
106
+ const dir = keyDir(apiKey);
107
+ let names;
108
+ try {
109
+ names = fs.readdirSync(dir);
110
+ }
111
+ catch {
112
+ return 0; // no directory yet — nothing spent
113
+ }
114
+ const cutoff = nowMs - LINEAR_RATE_WINDOW_MS;
115
+ let live = 0;
116
+ for (const name of names) {
117
+ const created = createdMsOf(name);
118
+ if (created === null)
119
+ continue;
120
+ if (created <= cutoff) {
121
+ try {
122
+ fs.rmSync(path.join(dir, name), { force: true });
123
+ }
124
+ catch {
125
+ /* another process may have swept it already */
126
+ }
127
+ }
128
+ else {
129
+ live++;
130
+ }
131
+ }
132
+ return live;
133
+ }
134
+ let reserveSeq = 0;
135
+ /**
136
+ * Try to reserve one Linear request against the shared hourly budget.
137
+ *
138
+ * Returns `true` and records the reservation when there is room, `false` when the
139
+ * budget is exhausted — the caller then serves the cache instead of spending a
140
+ * request that would 429. The write is a single empty file whose NAME carries the
141
+ * whole record, so a concurrent reserver can neither clobber it nor be clobbered
142
+ * by it.
143
+ */
144
+ export function reserveLinearRequest(apiKey, nowMs = Date.now()) {
145
+ if (linearRequestsInWindow(apiKey, nowMs) >= LINEAR_HOURLY_REQUEST_BUDGET)
146
+ return false;
147
+ const dir = keyDir(apiKey);
148
+ try {
149
+ fs.mkdirSync(dir, { recursive: true });
150
+ // `<createdMs>.<pid>.<seq>`: pid separates processes, the in-process counter
151
+ // separates two reservations in the same millisecond, so the name is unique
152
+ // without a lock or a random token. Empty contents — the name is the record.
153
+ fs.writeFileSync(path.join(dir, `${nowMs}.${process.pid}.${reserveSeq++}`), '');
154
+ return true;
155
+ }
156
+ catch {
157
+ // An unwritable cache dir costs the cross-process budget, not correctness: if
158
+ // we cannot record the reservation we still let the request through rather
159
+ // than blocking a caller on a broken cache dir. The reactive 429 backoff in
160
+ // linear-cache.ts remains the backstop.
161
+ return true;
162
+ }
163
+ }
package/dist/lib/mcp.d.ts CHANGED
@@ -160,6 +160,15 @@ export declare function installMcpServers(agentId: AgentId, version: string, ver
160
160
  * Write an MCP server config to ~/.agents/mcp/.
161
161
  */
162
162
  export declare function writeMcpServerConfig(config: McpYamlConfig): string;
163
+ /**
164
+ * True when MCP server `name` materialized in `agent`'s version home byte-matches
165
+ * the resolved SOURCE definition `source` — the content-drift predicate `agents
166
+ * doctor` uses. Parses the home's canonical MCP config for the harness (no
167
+ * `claude mcp add` shell-out — the on-disk file is parseable) and structurally
168
+ * compares command/args/env/url. Returns false when the server is absent from
169
+ * the home (surfaced as `missing`/`extra` at the name level, not here).
170
+ */
171
+ export declare function mcpServerMatches(agent: AgentId, versionHome: string, name: string, source: McpYamlConfig): boolean;
163
172
  /**
164
173
  * Remove an MCP server config from ~/.agents/mcp/.
165
174
  */
package/dist/lib/mcp.js CHANGED
@@ -16,7 +16,7 @@ import * as os from 'os';
16
16
  import { getMcpDir, getUserMcpDir, getProjectAgentsDir, getVersionsDir, getUserAgentsDir } from './state.js';
17
17
  import { getBinaryPath, getVersionHomePath } from './installations/versions.js';
18
18
  import { IS_WINDOWS, execFileShellSpec } from './platform/index.js';
19
- import { AGENTS, getMcpConfigPathForHome, getProjectMcpConfigPath, stripJsonComments } from './agents.js';
19
+ import { AGENTS, getMcpConfigPathForHome, getProjectMcpConfigPath, parseMcpConfig, stripJsonComments } from './agents.js';
20
20
  import { MCP_TARGETS, mcpWriteUnsupportedReason } from './mcp-registry.js';
21
21
  import { isCapable } from './capabilities.js';
22
22
  /**
@@ -814,6 +814,42 @@ export function writeMcpServerConfig(config) {
814
814
  fs.writeFileSync(filePath, content, 'utf-8');
815
815
  return filePath;
816
816
  }
817
+ /**
818
+ * Canonical string form of an MCP server for a structural, format-agnostic
819
+ * compare: http servers by url; stdio servers by command + args (order
820
+ * significant) + env (key-sorted, so serialization order is not drift). Undefined
821
+ * / empty fields are dropped so `{args: []}` and `{}` compare equal.
822
+ */
823
+ function mcpCanonical(entry) {
824
+ if (entry.url)
825
+ return JSON.stringify({ url: entry.url });
826
+ const env = entry.env && Object.keys(entry.env).length > 0
827
+ ? Object.fromEntries(Object.entries(entry.env).sort(([a], [b]) => a.localeCompare(b)))
828
+ : undefined;
829
+ return JSON.stringify({
830
+ command: entry.command,
831
+ args: entry.args && entry.args.length > 0 ? entry.args : undefined,
832
+ env,
833
+ });
834
+ }
835
+ /**
836
+ * True when MCP server `name` materialized in `agent`'s version home byte-matches
837
+ * the resolved SOURCE definition `source` — the content-drift predicate `agents
838
+ * doctor` uses. Parses the home's canonical MCP config for the harness (no
839
+ * `claude mcp add` shell-out — the on-disk file is parseable) and structurally
840
+ * compares command/args/env/url. Returns false when the server is absent from
841
+ * the home (surfaced as `missing`/`extra` at the name level, not here).
842
+ */
843
+ export function mcpServerMatches(agent, versionHome, name, source) {
844
+ const homePath = getMcpConfigPathForHome(agent, versionHome);
845
+ const home = parseMcpConfig(agent, homePath)[name];
846
+ if (!home)
847
+ return false;
848
+ const sourceComparable = source.transport === 'http'
849
+ ? { url: source.url }
850
+ : { command: source.command, args: source.args, env: source.env };
851
+ return mcpCanonical(sourceComparable) === mcpCanonical(home);
852
+ }
817
853
  /**
818
854
  * Remove an MCP server config from ~/.agents/mcp/.
819
855
  */
@@ -128,9 +128,11 @@ export async function resolveViewer(opts = {}) {
128
128
  return 'os';
129
129
  }
130
130
  if (profile.browser === 'arc') {
131
- // Arc exposes no CDP page targets and crashes on tab creation, so it can be
132
- // a configured profile but never a drivable viewer.
133
- console.error(`[viewer] "${resolved}" is Arc, which cannot be driven — using the OS browser.`);
131
+ // agents browser can drive an attached Arc's existing tabs, but showing the
132
+ // user a page needs its OWN fresh tab, and Arc crashes on tab creation over
133
+ // CDP (Target.createTarget). So Arc is a fine automation profile but never a
134
+ // viewer — fall back to the OS browser for anything a human is meant to read.
135
+ console.error(`[viewer] "${resolved}" is Arc, which cannot open a new viewer tab — using the OS browser.`);
134
136
  return 'os';
135
137
  }
136
138
  if (!isProfileLaunchableHere(profile)) {
@@ -347,3 +347,31 @@ export declare function saveDefaultPermissionSet(set: PermissionSet): {
347
347
  success: boolean;
348
348
  error?: string;
349
349
  };
350
+ /**
351
+ * Harnesses whose native permission file carries a per-rule allow/deny list the
352
+ * writer emits verbatim, so `agents doctor` can verify a group's rules survived
353
+ * into the version home rule-for-rule. The compare is done in the harness's OWN
354
+ * native vocabulary (Cursor `Shell(...)`, Droid command arrays, …) — NOT
355
+ * canonical — because every target's canonical round-trip is lossy
356
+ * (`lossyBecause` in `permissions-registry.ts`), so a canonical subset check
357
+ * would false-diff a correctly-synced home.
358
+ *
359
+ * Every other allowlist harness (codex/grok/kimi/kiro/antigravity/hermes/copilot)
360
+ * stores a lossy projection — a sandbox flag, whole-tool gate, per-directory
361
+ * approval, or a split/merged pattern — with no faithful per-group provenance, so
362
+ * doctor stays presence-only there and says so (`detail: 'format cannot verify
363
+ * content'`) rather than faking `ok`.
364
+ */
365
+ export declare const PERMISSIONS_REPRESENTABLE: ReadonlySet<AgentId>;
366
+ /**
367
+ * True when permission GROUP `groupName` is faithfully present in `agent`'s
368
+ * version home — every rule the group renders into that harness's native format
369
+ * is on disk. Only meaningful for {@link PERMISSIONS_REPRESENTABLE} agents (the
370
+ * caller keeps the lossy harnesses presence-only); returns true for a lossy
371
+ * harness or an empty/header group so the caller does not down-rank it.
372
+ *
373
+ * The expected rules are re-derived from the CURRENT source group every call
374
+ * (never a stored hash), so a rule edited/added in the source group surfaces as
375
+ * drift even though the group name is unchanged (PHNX-3504).
376
+ */
377
+ export declare function permissionsGroupMatches(agent: AgentId, versionHome: string, groupName: string): boolean;
@@ -776,7 +776,16 @@ export function convertToGrokFormat(set) {
776
776
  }
777
777
  function canonicalToGrokRule(perm, action) {
778
778
  if (BLANKET_BASH_FORMS.has(perm)) {
779
- return { action, tool: 'bash', pattern: '*' };
779
+ // Grok's `*` is a SINGLE-LEVEL wildcard, so a `pattern: '*'` bash rule does
780
+ // NOT auto-approve a multi-token command like `ssh host cmd` or `scp a b`
781
+ // (PHNX-3294 — verified: two boxes carrying the identical `pattern="*"` rule
782
+ // differed only by `[ui].permission_mode`, and only the always-approve box
783
+ // ran ssh without prompting). Grok's documented "bare prefix matches all
784
+ // invocations" idiom is a rule with NO `pattern` key — the true allow-all
785
+ // shell form, matching how kimi (bare `Bash`), droid (`*`) and claude
786
+ // express a blanket Bash grant. This reads back as `Bash(*)` via the
787
+ // registry's pattern-less path, so the round-trip is unchanged.
788
+ return { action, tool: 'bash' };
780
789
  }
781
790
  const parsed = parseCanonicalPattern(perm);
782
791
  if (!parsed)
@@ -1885,3 +1894,149 @@ export function saveDefaultPermissionSet(set) {
1885
1894
  set.name = DEFAULT_PERMISSION_SET_NAME;
1886
1895
  return savePermissionSet(set);
1887
1896
  }
1897
+ // ============================================================================
1898
+ // Content-drift check (agents doctor, PHNX-3504)
1899
+ // ============================================================================
1900
+ /**
1901
+ * Harnesses whose native permission file carries a per-rule allow/deny list the
1902
+ * writer emits verbatim, so `agents doctor` can verify a group's rules survived
1903
+ * into the version home rule-for-rule. The compare is done in the harness's OWN
1904
+ * native vocabulary (Cursor `Shell(...)`, Droid command arrays, …) — NOT
1905
+ * canonical — because every target's canonical round-trip is lossy
1906
+ * (`lossyBecause` in `permissions-registry.ts`), so a canonical subset check
1907
+ * would false-diff a correctly-synced home.
1908
+ *
1909
+ * Every other allowlist harness (codex/grok/kimi/kiro/antigravity/hermes/copilot)
1910
+ * stores a lossy projection — a sandbox flag, whole-tool gate, per-directory
1911
+ * approval, or a split/merged pattern — with no faithful per-group provenance, so
1912
+ * doctor stays presence-only there and says so (`detail: 'format cannot verify
1913
+ * content'`) rather than faking `ok`.
1914
+ */
1915
+ export const PERMISSIONS_REPRESENTABLE = new Set([
1916
+ 'claude',
1917
+ 'opencode',
1918
+ 'cursor',
1919
+ 'droid',
1920
+ 'openclaw',
1921
+ ]);
1922
+ function toStringSet(v) {
1923
+ return new Set(Array.isArray(v) ? v.filter((x) => typeof x === 'string') : []);
1924
+ }
1925
+ function readJsonFileSafe(filePath) {
1926
+ try {
1927
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
1928
+ }
1929
+ catch {
1930
+ return null;
1931
+ }
1932
+ }
1933
+ /** allow/deny rule strings in `agent`'s NATIVE vocabulary from its version home. */
1934
+ function homeNativePermissionRules(agent, versionHome) {
1935
+ const target = PERMISSION_TARGETS[agent];
1936
+ if (!target)
1937
+ return null;
1938
+ const configPath = target.home(versionHome);
1939
+ if (!fs.existsSync(configPath))
1940
+ return null;
1941
+ switch (agent) {
1942
+ case 'claude':
1943
+ case 'cursor': {
1944
+ const c = readJsonFileSafe(configPath);
1945
+ const perms = (c?.permissions ?? {});
1946
+ return { allow: toStringSet(perms.allow), deny: toStringSet(perms.deny) };
1947
+ }
1948
+ case 'droid': {
1949
+ const c = readJsonFileSafe(configPath);
1950
+ return { allow: toStringSet(c?.commandAllowlist), deny: toStringSet(c?.commandDenylist) };
1951
+ }
1952
+ case 'openclaw': {
1953
+ const c = readJsonFileSafe(configPath);
1954
+ const tools = (c?.tools ?? {});
1955
+ return { allow: toStringSet(tools.alsoAllow), deny: toStringSet(tools.deny) };
1956
+ }
1957
+ case 'opencode': {
1958
+ let c = null;
1959
+ try {
1960
+ c = JSON.parse(stripJsonComments(fs.readFileSync(configPath, 'utf-8')));
1961
+ }
1962
+ catch {
1963
+ return null;
1964
+ }
1965
+ const bash = (c?.permission?.bash ?? {});
1966
+ const allow = new Set();
1967
+ const deny = new Set();
1968
+ for (const [pattern, action] of Object.entries(bash)) {
1969
+ if (action === 'allow')
1970
+ allow.add(pattern);
1971
+ else if (action === 'deny')
1972
+ deny.add(pattern);
1973
+ }
1974
+ return { allow, deny };
1975
+ }
1976
+ default:
1977
+ return null;
1978
+ }
1979
+ }
1980
+ /** allow/deny rule strings in `agent`'s NATIVE vocabulary the writer WOULD emit. */
1981
+ function expectedNativePermissionRules(agent, set) {
1982
+ switch (agent) {
1983
+ case 'claude': {
1984
+ const c = convertToClaudeFormat(set);
1985
+ return { allow: new Set(c.permissions.allow), deny: new Set(c.permissions.deny) };
1986
+ }
1987
+ case 'cursor': {
1988
+ const c = convertToCursorFormat(set);
1989
+ return { allow: new Set(c.permissions.allow), deny: new Set(c.permissions.deny ?? []) };
1990
+ }
1991
+ case 'droid': {
1992
+ const c = convertToDroidFormat(set);
1993
+ return { allow: new Set(c.commandAllowlist), deny: new Set(c.commandDenylist) };
1994
+ }
1995
+ case 'openclaw': {
1996
+ const c = convertToOpenClawFormat(set);
1997
+ return { allow: new Set(c.alsoAllow), deny: new Set(c.deny) };
1998
+ }
1999
+ case 'opencode': {
2000
+ const c = convertToOpenCodeFormat(set);
2001
+ const allow = new Set();
2002
+ const deny = new Set();
2003
+ for (const [pattern, action] of Object.entries(c.permission.bash)) {
2004
+ if (action === 'allow')
2005
+ allow.add(pattern);
2006
+ else if (action === 'deny')
2007
+ deny.add(pattern);
2008
+ }
2009
+ return { allow, deny };
2010
+ }
2011
+ default:
2012
+ return { allow: new Set(), deny: new Set() };
2013
+ }
2014
+ }
2015
+ /**
2016
+ * True when permission GROUP `groupName` is faithfully present in `agent`'s
2017
+ * version home — every rule the group renders into that harness's native format
2018
+ * is on disk. Only meaningful for {@link PERMISSIONS_REPRESENTABLE} agents (the
2019
+ * caller keeps the lossy harnesses presence-only); returns true for a lossy
2020
+ * harness or an empty/header group so the caller does not down-rank it.
2021
+ *
2022
+ * The expected rules are re-derived from the CURRENT source group every call
2023
+ * (never a stored hash), so a rule edited/added in the source group surfaces as
2024
+ * drift even though the group name is unchanged (PHNX-3504).
2025
+ */
2026
+ export function permissionsGroupMatches(agent, versionHome, groupName) {
2027
+ if (!PERMISSIONS_REPRESENTABLE.has(agent))
2028
+ return true;
2029
+ const expected = expectedNativePermissionRules(agent, buildPermissionsFromGroups([groupName]));
2030
+ if (expected.allow.size === 0 && expected.deny.size === 0)
2031
+ return true; // header / empty group
2032
+ const home = homeNativePermissionRules(agent, versionHome);
2033
+ if (!home)
2034
+ return false;
2035
+ for (const r of expected.allow)
2036
+ if (!home.allow.has(r))
2037
+ return false;
2038
+ for (const r of expected.deny)
2039
+ if (!home.deny.has(r))
2040
+ return false;
2041
+ return true;
2042
+ }
@@ -20,7 +20,7 @@ import { readManifest, MANIFEST_FILENAME } from './manifest.js';
20
20
  import { getUserAgentsDir } from './state.js';
21
21
  import { installVersion, listInstalledVersions, isVersionIsolated, getGlobalDefault, setGlobalDefault, getVersionHomePath, syncResourcesToVersion, getAvailableResources, getActuallySyncedResources, getNewResources, getProjectOnlyResources, hasNewResources, promptNewResourceSelection, promptResourceSelection, resolveConfiguredAgentTargets, } from './installations/versions.js';
22
22
  import { listCliStatus, installCli, describeMethod, describeCheck, selectInstallMethod, } from './cli-resources.js';
23
- import { ensureShimCurrent, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from './installations/shims.js';
23
+ import { ensureShimCurrent, ensureGhOverloadShim, isShimsInPath, addShimsToPath, getPathSetupInstructions, switchConfigSymlink, switchHomeFileSymlinks, } from './installations/shims.js';
24
24
  import { parseHookManifest, registerHooksToSettings } from './hooks/install.js';
25
25
  import { isPromptCancelled } from './format.js';
26
26
  /**
@@ -240,6 +240,14 @@ export async function refresh(options = {}) {
240
240
  }
241
241
  }
242
242
  // 5. Auto-add shims to PATH
243
+ // Refresh the gh overload shim so `gh pr checks` escapes the GraphQL rate limit
244
+ // for every user, transparently (PHNX-3501). Idempotent; POSIX-only in v1.
245
+ try {
246
+ ensureGhOverloadShim();
247
+ }
248
+ catch {
249
+ // Never let a shim-write hiccup break sync — real gh stays fine without it.
250
+ }
243
251
  if (!isShimsInPath()) {
244
252
  const pathResult = addShimsToPath();
245
253
  if (pathResult.success && !pathResult.alreadyPresent) {