@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.
- package/CHANGELOG.md +49 -0
- package/dist/cli/command-registry.d.ts +1 -0
- package/dist/cli/command-registry.js +2 -0
- package/dist/commands/browser.js +9 -4
- package/dist/commands/doctor.js +1 -1
- package/dist/commands/exec.js +35 -1
- package/dist/commands/harness-hooks.d.ts +55 -0
- package/dist/commands/harness-hooks.js +104 -0
- package/dist/commands/harness-wizard.d.ts +33 -14
- package/dist/commands/harness-wizard.js +53 -23
- package/dist/commands/harness.d.ts +14 -0
- package/dist/commands/harness.js +86 -5
- package/dist/commands/reminders.d.ts +9 -0
- package/dist/commands/reminders.js +49 -0
- package/dist/commands/run-account-picker.d.ts +14 -0
- package/dist/commands/run-account-picker.js +13 -0
- package/dist/commands/teams.d.ts +1 -1
- package/dist/commands/teams.js +9 -3
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +229 -13
- package/dist/lib/browser/drivers/local.d.ts +11 -0
- package/dist/lib/browser/drivers/local.js +26 -0
- package/dist/lib/browser/profiles.js +8 -6
- package/dist/lib/browser/service.d.ts +12 -8
- package/dist/lib/browser/service.js +38 -10
- package/dist/lib/claude-statusline.d.ts +14 -1
- package/dist/lib/claude-statusline.js +27 -2
- package/dist/lib/daemon/runner.js +17 -2
- package/dist/lib/devices/doctor-findings.d.ts +1 -1
- package/dist/lib/devices/doctor-findings.js +22 -4
- package/dist/lib/doctor-diff.d.ts +21 -5
- package/dist/lib/doctor-diff.js +242 -76
- package/dist/lib/feed/events.d.ts +1 -1
- package/dist/lib/feed/events.js +25 -16
- package/dist/lib/github/gh-overload.d.ts +58 -0
- package/dist/lib/github/gh-overload.js +246 -0
- package/dist/lib/github/rest.d.ts +64 -0
- package/dist/lib/github/rest.js +111 -0
- package/dist/lib/harness-connection-test.d.ts +57 -0
- package/dist/lib/harness-connection-test.js +80 -0
- package/dist/lib/heal.js +8 -3
- package/dist/lib/installations/shims.d.ts +22 -0
- package/dist/lib/installations/shims.js +104 -0
- package/dist/lib/linear-project-counts.js +8 -0
- package/dist/lib/linear-rate-limit.d.ts +26 -0
- package/dist/lib/linear-rate-limit.js +163 -0
- package/dist/lib/mcp.d.ts +9 -0
- package/dist/lib/mcp.js +37 -1
- package/dist/lib/open-url.js +5 -3
- package/dist/lib/permissions.d.ts +28 -0
- package/dist/lib/permissions.js +156 -1
- package/dist/lib/refresh.js +9 -1
- package/dist/lib/reminders.d.ts +29 -0
- package/dist/lib/reminders.js +88 -0
- package/dist/lib/resource-content-diff.d.ts +33 -0
- package/dist/lib/resource-content-diff.js +103 -0
- package/dist/lib/rules/compile.d.ts +7 -0
- package/dist/lib/rules/compile.js +7 -1
- package/dist/lib/session/active.d.ts +41 -4
- package/dist/lib/session/active.js +58 -7
- package/dist/lib/session/host-link.d.ts +22 -0
- package/dist/lib/session/host-link.js +40 -4
- package/dist/lib/session/trajectory.d.ts +42 -0
- package/dist/lib/session/trajectory.js +46 -27
- package/dist/lib/ssh-exec.d.ts +30 -0
- package/dist/lib/ssh-exec.js +37 -5
- package/dist/lib/startup/command-registry.js +1 -1
- package/dist/lib/subagents-registry.d.ts +18 -0
- package/dist/lib/subagents-registry.js +79 -0
- package/dist/lib/teams/agents.d.ts +12 -0
- package/dist/lib/teams/agents.js +51 -0
- package/dist/lib/traces/schema2-build.d.ts +85 -0
- package/dist/lib/traces/schema2-build.js +637 -0
- package/dist/lib/traces/schema2-danger.d.ts +36 -0
- package/dist/lib/traces/schema2-danger.js +185 -0
- package/dist/lib/traces/schema2.d.ts +149 -0
- package/dist/lib/traces/schema2.js +20 -0
- package/dist/lib/traces/sync.d.ts +93 -0
- package/dist/lib/traces/sync.js +75 -22
- package/dist/lib/traces/worker-template.js +5 -0
- package/dist/lib/uninstall.js +10 -1
- package/dist/lib/workflows.d.ts +11 -0
- package/dist/lib/workflows.js +67 -8
- 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
|
*/
|
package/dist/lib/open-url.js
CHANGED
|
@@ -128,9 +128,11 @@ export async function resolveViewer(opts = {}) {
|
|
|
128
128
|
return 'os';
|
|
129
129
|
}
|
|
130
130
|
if (profile.browser === 'arc') {
|
|
131
|
-
//
|
|
132
|
-
// a
|
|
133
|
-
|
|
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;
|
package/dist/lib/permissions.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/lib/refresh.js
CHANGED
|
@@ -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) {
|