@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.
- package/CHANGELOG.md +71 -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/perf.js +10 -0
- 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/sessions-picker.d.ts +13 -0
- package/dist/commands/sessions-picker.js +17 -8
- package/dist/commands/sessions.js +13 -11
- package/dist/commands/teams-picker.js +20 -6
- package/dist/commands/teams.d.ts +3 -3
- package/dist/commands/teams.js +86 -24
- package/dist/index.js +9 -0
- package/dist/lib/accounting/rotate.d.ts +63 -0
- package/dist/lib/accounting/rotate.js +240 -16
- package/dist/lib/accounting/usage-sync.d.ts +12 -2
- package/dist/lib/accounting/usage-sync.js +34 -6
- 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 +28 -15
- 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/perf/db.d.ts +1 -1
- package/dist/lib/perf/db.js +53 -2
- package/dist/lib/perf/types.d.ts +14 -0
- 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/live-metadata.js +3 -3
- 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/teams/api.d.ts +8 -0
- package/dist/lib/teams/api.js +50 -6
- package/dist/lib/teams/delivery.d.ts +14 -4
- package/dist/lib/teams/delivery.js +15 -5
- 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
|
@@ -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
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
*/
|
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)) {
|
package/dist/lib/perf/db.d.ts
CHANGED
|
@@ -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;
|
package/dist/lib/perf/db.js
CHANGED
|
@@ -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);
|