@phnx-labs/agents-cli 1.22.59 → 1.22.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/CHANGELOG.md +71 -0
  2. package/dist/cli/command-registry.d.ts +1 -0
  3. package/dist/cli/command-registry.js +2 -0
  4. package/dist/commands/browser.js +9 -4
  5. package/dist/commands/doctor.js +1 -1
  6. package/dist/commands/exec.js +35 -1
  7. package/dist/commands/harness-hooks.d.ts +55 -0
  8. package/dist/commands/harness-hooks.js +104 -0
  9. package/dist/commands/harness-wizard.d.ts +33 -14
  10. package/dist/commands/harness-wizard.js +53 -23
  11. package/dist/commands/harness.d.ts +14 -0
  12. package/dist/commands/harness.js +86 -5
  13. package/dist/commands/perf.js +10 -0
  14. package/dist/commands/reminders.d.ts +9 -0
  15. package/dist/commands/reminders.js +49 -0
  16. package/dist/commands/run-account-picker.d.ts +14 -0
  17. package/dist/commands/run-account-picker.js +13 -0
  18. package/dist/commands/sessions-picker.d.ts +13 -0
  19. package/dist/commands/sessions-picker.js +17 -8
  20. package/dist/commands/sessions.js +13 -11
  21. package/dist/commands/teams-picker.js +20 -6
  22. package/dist/commands/teams.d.ts +3 -3
  23. package/dist/commands/teams.js +86 -24
  24. package/dist/index.js +9 -0
  25. package/dist/lib/accounting/rotate.d.ts +63 -0
  26. package/dist/lib/accounting/rotate.js +240 -16
  27. package/dist/lib/accounting/usage-sync.d.ts +12 -2
  28. package/dist/lib/accounting/usage-sync.js +34 -6
  29. package/dist/lib/browser/drivers/local.d.ts +11 -0
  30. package/dist/lib/browser/drivers/local.js +26 -0
  31. package/dist/lib/browser/profiles.js +8 -6
  32. package/dist/lib/browser/service.d.ts +12 -8
  33. package/dist/lib/browser/service.js +38 -10
  34. package/dist/lib/claude-statusline.d.ts +14 -1
  35. package/dist/lib/claude-statusline.js +27 -2
  36. package/dist/lib/daemon/runner.js +17 -2
  37. package/dist/lib/devices/doctor-findings.d.ts +1 -1
  38. package/dist/lib/devices/doctor-findings.js +22 -4
  39. package/dist/lib/doctor-diff.d.ts +21 -5
  40. package/dist/lib/doctor-diff.js +242 -76
  41. package/dist/lib/feed/events.d.ts +1 -1
  42. package/dist/lib/feed/events.js +28 -15
  43. package/dist/lib/github/gh-overload.d.ts +58 -0
  44. package/dist/lib/github/gh-overload.js +246 -0
  45. package/dist/lib/github/rest.d.ts +64 -0
  46. package/dist/lib/github/rest.js +111 -0
  47. package/dist/lib/harness-connection-test.d.ts +57 -0
  48. package/dist/lib/harness-connection-test.js +80 -0
  49. package/dist/lib/heal.js +8 -3
  50. package/dist/lib/installations/shims.d.ts +22 -0
  51. package/dist/lib/installations/shims.js +104 -0
  52. package/dist/lib/linear-project-counts.js +8 -0
  53. package/dist/lib/linear-rate-limit.d.ts +26 -0
  54. package/dist/lib/linear-rate-limit.js +163 -0
  55. package/dist/lib/mcp.d.ts +9 -0
  56. package/dist/lib/mcp.js +37 -1
  57. package/dist/lib/open-url.js +5 -3
  58. package/dist/lib/perf/db.d.ts +1 -1
  59. package/dist/lib/perf/db.js +53 -2
  60. package/dist/lib/perf/types.d.ts +14 -0
  61. package/dist/lib/permissions.d.ts +28 -0
  62. package/dist/lib/permissions.js +156 -1
  63. package/dist/lib/refresh.js +9 -1
  64. package/dist/lib/reminders.d.ts +29 -0
  65. package/dist/lib/reminders.js +88 -0
  66. package/dist/lib/resource-content-diff.d.ts +33 -0
  67. package/dist/lib/resource-content-diff.js +103 -0
  68. package/dist/lib/rules/compile.d.ts +7 -0
  69. package/dist/lib/rules/compile.js +7 -1
  70. package/dist/lib/session/active.d.ts +41 -4
  71. package/dist/lib/session/active.js +58 -7
  72. package/dist/lib/session/host-link.d.ts +22 -0
  73. package/dist/lib/session/host-link.js +40 -4
  74. package/dist/lib/session/live-metadata.js +3 -3
  75. package/dist/lib/session/trajectory.d.ts +42 -0
  76. package/dist/lib/session/trajectory.js +46 -27
  77. package/dist/lib/ssh-exec.d.ts +30 -0
  78. package/dist/lib/ssh-exec.js +37 -5
  79. package/dist/lib/startup/command-registry.js +1 -1
  80. package/dist/lib/subagents-registry.d.ts +18 -0
  81. package/dist/lib/subagents-registry.js +79 -0
  82. package/dist/lib/teams/agents.d.ts +12 -0
  83. package/dist/lib/teams/agents.js +51 -0
  84. package/dist/lib/teams/api.d.ts +8 -0
  85. package/dist/lib/teams/api.js +50 -6
  86. package/dist/lib/teams/delivery.d.ts +14 -4
  87. package/dist/lib/teams/delivery.js +15 -5
  88. package/dist/lib/traces/schema2-build.d.ts +85 -0
  89. package/dist/lib/traces/schema2-build.js +637 -0
  90. package/dist/lib/traces/schema2-danger.d.ts +36 -0
  91. package/dist/lib/traces/schema2-danger.js +185 -0
  92. package/dist/lib/traces/schema2.d.ts +149 -0
  93. package/dist/lib/traces/schema2.js +20 -0
  94. package/dist/lib/traces/sync.d.ts +93 -0
  95. package/dist/lib/traces/sync.js +75 -22
  96. package/dist/lib/traces/worker-template.js +5 -0
  97. package/dist/lib/uninstall.js +10 -1
  98. package/dist/lib/workflows.d.ts +11 -0
  99. package/dist/lib/workflows.js +67 -8
  100. package/package.json +1 -1
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Pre-save connection test for a custom harness (PHNX-2221).
3
+ *
4
+ * A harness binds a (host CLI + model + endpoint + auth) — but none of that is
5
+ * exercised until the first real run, so a typo in the base URL, a wrong account,
6
+ * or a model id the endpoint doesn't serve only surfaces later. This module runs
7
+ * a genuine minimal request through the SAME resolution path a real run takes —
8
+ * `agents run <name> "say alive in one word" --headless --timeout 60s`, which
9
+ * flows `resolveProfileForRun` → `resolveProfileEnv` (keychain/account read) →
10
+ * `buildExecEnv` → spawn — and classifies the result so the user learns it works
11
+ * (or exactly why it doesn't) before committing the harness.
12
+ *
13
+ * There is no mock or dry-run: the profile must already be on disk (the caller
14
+ * writes it first) because the test drives the real `agents run` argv. The
15
+ * classifier is a pure function over the child's exit code + combined output, so
16
+ * it is unit-tested against real stderr samples with no spawn.
17
+ */
18
+ import { spawnSync } from 'node:child_process';
19
+ import { getCliLaunch } from './cli-entry.js';
20
+ /** The prompt the smoke test sends — cheap, deterministic, one token of output. */
21
+ export const CONNECTION_TEST_PROMPT = 'say alive in one word';
22
+ /**
23
+ * Classify a finished `agents run` smoke test from its exit code and combined
24
+ * stdout+stderr. Pure — no spawn — so the mapping (pass / auth / endpoint /
25
+ * model / unknown) is unit-tested against real provider error strings.
26
+ *
27
+ * Exit 0 is a pass. Otherwise the output is matched against provider-error
28
+ * shapes in priority order: an auth rejection (401 / invalid key) is the most
29
+ * specific, then a model-not-served error, then a transport/DNS failure; a
30
+ * failure that matches none is `unknown` (the run failed but not in a way we can
31
+ * name — surfaced verbatim, never swallowed).
32
+ */
33
+ export function classifyConnectionOutput(exitCode, output) {
34
+ if (exitCode === 0)
35
+ return { ok: true, message: 'Connection test passed.' };
36
+ const text = output || '';
37
+ const firstLine = text.split('\n').map((l) => l.trim()).find((l) => l.length > 0);
38
+ const detail = firstLine ? ` (${firstLine})` : '';
39
+ if (/\b401\b|unauthor|invalid[\s_-]?(x-)?api[\s_-]?key|authentication|invalid x-api-key|missing[\s_-]?api[\s_-]?key/i.test(text)) {
40
+ return { ok: false, reason: 'auth', message: `Authentication rejected — check the harness's account/key.${detail}` };
41
+ }
42
+ if (/model[^\n]{0,48}(not\s+found|not\s+exist|does\s+not\s+exist|unknown|invalid|unsupported|unavailable)|no\s+such\s+model|unknown\s+model|invalid\s+model|unrecognized\s+model/i.test(text)) {
43
+ return { ok: false, reason: 'model', message: `The endpoint did not accept that model id.${detail}` };
44
+ }
45
+ if (/ENOTFOUND|ECONNREFUSED|EAI_AGAIN|ETIMEDOUT|ECONNRESET|EHOSTUNREACH|getaddrinfo|connection\s+refused|could\s+not\s+connect|unreachable|fetch\s+failed|socket\s+hang\s+up|network\s+error|dns|certificate|self[\s-]?signed/i.test(text)) {
46
+ return { ok: false, reason: 'endpoint', message: `The endpoint was unreachable — check the base URL.${detail}` };
47
+ }
48
+ return { ok: false, reason: 'unknown', message: `Run failed (exit ${exitCode ?? 'null'}).${detail}` };
49
+ }
50
+ /**
51
+ * Run the real connection test against an already-saved harness. Spawns the CLI
52
+ * itself (`getCliLaunch`, the one self-invocation primitive) with the same argv a
53
+ * user would type, and classifies the result. Never throws on a failed run — a
54
+ * failure is returned as a classified {@link ConnectionTestResult}; only a spawn
55
+ * that never produced an exit (killed by the wall-clock cap) reads as `endpoint`
56
+ * (the request hung).
57
+ */
58
+ export async function runHarnessConnectionTest(name, opts = {}) {
59
+ const { command, args } = getCliLaunch([
60
+ 'run',
61
+ name,
62
+ CONNECTION_TEST_PROMPT,
63
+ '--headless',
64
+ '--timeout',
65
+ opts.timeout ?? '60s',
66
+ ]);
67
+ const result = spawnSync(command, args, {
68
+ encoding: 'utf-8',
69
+ timeout: opts.killAfterMs ?? 90_000,
70
+ stdio: ['ignore', 'pipe', 'pipe'],
71
+ });
72
+ if (result.error && result.error.code === 'ETIMEDOUT') {
73
+ return { ok: false, reason: 'endpoint', message: 'The request hung past the timeout — the endpoint may be unreachable or wrong.' };
74
+ }
75
+ if (result.error) {
76
+ return { ok: false, reason: 'unknown', message: `Could not launch the test: ${result.error.message}` };
77
+ }
78
+ const output = `${result.stdout ?? ''}\n${result.stderr ?? ''}`;
79
+ return classifyConnectionOutput(result.status, output);
80
+ }
package/dist/lib/heal.js CHANGED
@@ -43,6 +43,7 @@ const KIND_TO_SELECTION = {
43
43
  permissions: 'permissions',
44
44
  subagents: 'subagents',
45
45
  plugins: 'plugins',
46
+ workflows: 'workflows',
46
47
  };
47
48
  function totalHealed(r) {
48
49
  return r.versions.reduce((n, v) => n + v.healed.length, 0);
@@ -173,9 +174,13 @@ function healVersion(agent, version, opts) {
173
174
  result.skipped.push({ kind: row.kind, name: row.name, reason: 'drift' });
174
175
  continue;
175
176
  }
176
- if (row.kind === 'promptcuts')
177
- continue; // not version-synced
178
- if (row.kind === 'rules') {
177
+ // Rules (the composed instruction file) and knowledge memory (~/.agents/
178
+ // memory/ facts) are both repaired by a sync of this version: rules via the
179
+ // preset re-composition `selection.memory` drives, knowledge facts via the
180
+ // unconditional `syncMemoryToVersionHome` fan-out every sync runs. Setting
181
+ // the rules selection guarantees a sync executes, so a memory-fact drift is
182
+ // reconciled the same pass (PHNX-3504).
183
+ if (row.kind === 'rules' || row.kind === 'memory') {
179
184
  selection.memory = 'all';
180
185
  attempted.push({ kind: row.kind, name: row.name, was: row.status });
181
186
  continue;
@@ -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)) {
@@ -6,7 +6,7 @@
6
6
  */
7
7
  import Database from '../sqlite.js';
8
8
  import type { AggregateOptions, PerfAggregateRow } from './types.js';
9
- export type { AggregateOptions, PerfAggregateRow, PerfSample } from './types.js';
9
+ export type { AggregateOptions, PerfAggregateRow, PerfPhaseStat, PerfSample } from './types.js';
10
10
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
11
11
  export { percentile } from '../percentile.js';
12
12
  export declare const PERF_SCHEMA_VERSION = 1;
@@ -12,6 +12,31 @@ import { localMachineId } from '../origin-machine.js';
12
12
  import { resolveProjectKey } from '../project-key.js';
13
13
  import { percentile } from '../percentile.js';
14
14
  import { resolveSpoolPath, shortSessionId, _resetPerfSpoolForTest } from './spool.js';
15
+ /**
16
+ * Parse the `phases` map from a sample's meta_json. Fail-soft: a row with no
17
+ * meta_json, malformed JSON, or a non-numeric phase value contributes nothing
18
+ * rather than throwing (the warehouse must survive any writer's shape).
19
+ */
20
+ function parsePhases(metaJson) {
21
+ if (!metaJson)
22
+ return undefined;
23
+ let parsed;
24
+ try {
25
+ parsed = JSON.parse(metaJson);
26
+ }
27
+ catch {
28
+ return undefined;
29
+ }
30
+ const phases = parsed?.phases;
31
+ if (!phases || typeof phases !== 'object')
32
+ return undefined;
33
+ const out = {};
34
+ for (const [name, val] of Object.entries(phases)) {
35
+ if (typeof val === 'number' && Number.isFinite(val))
36
+ out[name] = val;
37
+ }
38
+ return Object.keys(out).length > 0 ? out : undefined;
39
+ }
15
40
  export { recordSample, shortSessionId, resolveSpoolPath } from './spool.js';
16
41
  export { percentile } from '../percentile.js';
17
42
  export const PERF_SCHEMA_VERSION = 1;
@@ -222,7 +247,7 @@ export function aggregateSamples(opts = {}) {
222
247
  clauses.push('agent = ?');
223
248
  params.push(opts.agent);
224
249
  }
225
- const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd
250
+ const rows = db.prepare(`SELECT kind, label, duration_ms, cache, exit_code, status, cwd, meta_json
226
251
  FROM samples WHERE ${clauses.join(' AND ')}`).all(...params);
227
252
  // Memoize cwd -> project key: resolveProjectKey walks the filesystem for
228
253
  // a repo root, and many rows in one warehouse query share the same cwd.
@@ -244,10 +269,21 @@ export function aggregateSamples(opts = {}) {
244
269
  const key = `${r.kind}\0${r.label}`;
245
270
  let b = map.get(key);
246
271
  if (!b) {
247
- b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0 };
272
+ b = { kind: r.kind, label: r.label, durations: [], hits: 0, stale: 0, misses: 0, errors: 0, blocks: 0, timeouts: 0, phases: new Map() };
248
273
  map.set(key, b);
249
274
  }
250
275
  b.durations.push(Number(r.duration_ms));
276
+ const phases = parsePhases(r.meta_json);
277
+ if (phases) {
278
+ for (const [name, ms] of Object.entries(phases)) {
279
+ let arr = b.phases.get(name);
280
+ if (!arr) {
281
+ arr = [];
282
+ b.phases.set(name, arr);
283
+ }
284
+ arr.push(ms);
285
+ }
286
+ }
251
287
  if (r.cache === 'hit')
252
288
  b.hits++;
253
289
  else if (r.cache === 'stale-prefetch')
@@ -301,6 +337,21 @@ export function aggregateSamples(opts = {}) {
301
337
  }
302
338
  if (b.timeouts > 0)
303
339
  row.timeoutRate = Math.round((b.timeouts / n) * 1000) / 1000;
340
+ if (b.phases.size > 0) {
341
+ const phases = {};
342
+ for (const [name, durs] of b.phases) {
343
+ if (durs.length === 0)
344
+ continue;
345
+ const ps = durs.slice().sort((a, c) => a - c);
346
+ phases[name] = {
347
+ n: ps.length,
348
+ p50Ms: Math.round(percentile(ps, 50)),
349
+ p90Ms: Math.round(percentile(ps, 90)),
350
+ };
351
+ }
352
+ if (Object.keys(phases).length > 0)
353
+ row.phases = phases;
354
+ }
304
355
  if (opts.project)
305
356
  row.project = opts.project;
306
357
  out.push(row);