@bli-cockpit/cli 0.2.193 → 0.2.195

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.
@@ -0,0 +1,131 @@
1
+ import fs from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const TRANSCRIPT_FILES = 12;
4
+ export const TRANSCRIPT_WINDOW_MS = 30 * 86_400_000;
5
+ const CHUNK_BYTES = 200_000;
6
+ const MAX_STATTED = 3000;
7
+ const SEMVER = /^\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?$/i;
8
+ /** Newer of two semver-looking strings by numeric core; a non-version never wins. */
9
+ export function newerVersion(a, b) {
10
+ if (!b || !SEMVER.test(b))
11
+ return a;
12
+ if (!a || !SEMVER.test(a))
13
+ return b;
14
+ const pa = a.split(/[.-]/).slice(0, 3).map(Number), pb = b.split(/[.-]/).slice(0, 3).map(Number);
15
+ for (let i = 0; i < 3; i++)
16
+ if (pa[i] !== pb[i])
17
+ return pb[i] > pa[i] ? b : a;
18
+ return a;
19
+ }
20
+ /** The most frequent key, ties to the one seen first. */
21
+ function top(counts) {
22
+ let best = null, n = 0;
23
+ for (const [key, count] of counts)
24
+ if (count > n) {
25
+ best = key;
26
+ n = count;
27
+ }
28
+ return best;
29
+ }
30
+ /** Fold sampled transcript text into one client reading. Pure, so tests feed it fixtures. */
31
+ export function observeTranscripts(tool, samples) {
32
+ const models = new Map(), efforts = new Map();
33
+ let version = null;
34
+ const bump = (map, value) => {
35
+ if (typeof value === "string" && value && !value.startsWith("<"))
36
+ map.set(value, (map.get(value) ?? 0) + 1);
37
+ };
38
+ for (const sample of samples)
39
+ for (const line of sample.split("\n")) {
40
+ if (!line.startsWith("{"))
41
+ continue;
42
+ let entry;
43
+ try {
44
+ entry = JSON.parse(line);
45
+ }
46
+ catch {
47
+ continue;
48
+ } // a chunk edge can cut a line
49
+ const payload = entry.payload && typeof entry.payload === "object" ? entry.payload : {};
50
+ if (tool === "claude") {
51
+ if (typeof entry.version === "string")
52
+ version = newerVersion(version, entry.version);
53
+ const message = entry.message && typeof entry.message === "object" ? entry.message : {};
54
+ if (entry.type === "assistant")
55
+ bump(models, message.model);
56
+ }
57
+ else if (entry.type === "session_meta") {
58
+ if (typeof payload.cli_version === "string")
59
+ version = newerVersion(version, payload.cli_version);
60
+ }
61
+ else if (entry.type === "turn_context") {
62
+ bump(models, payload.model);
63
+ bump(efforts, payload.effort);
64
+ }
65
+ }
66
+ return { version, model: top(models), effort: top(efforts), files: samples.length };
67
+ }
68
+ async function sampleFile(file) {
69
+ let handle;
70
+ try {
71
+ handle = await fs.open(file, "r");
72
+ const { size } = await handle.stat();
73
+ const read = async (position, length) => {
74
+ const buffer = Buffer.alloc(length);
75
+ const { bytesRead } = await handle.read(buffer, 0, length, position);
76
+ return buffer.subarray(0, bytesRead).toString("utf8");
77
+ };
78
+ if (size <= CHUNK_BYTES * 2)
79
+ return await read(0, size);
80
+ return `${await read(0, CHUNK_BYTES)}\n${await read(size - CHUNK_BYTES, CHUNK_BYTES)}`;
81
+ }
82
+ catch {
83
+ return null;
84
+ }
85
+ finally {
86
+ await handle?.close().catch(() => undefined);
87
+ }
88
+ }
89
+ const entries = (directory) => fs.readdir(directory).catch(() => []);
90
+ async function newestClaudeFiles(home, now) {
91
+ const root = path.join(home, ".claude", "projects");
92
+ const found = [];
93
+ let statted = 0;
94
+ for (const project of await entries(root)) {
95
+ for (const name of await entries(path.join(root, project))) {
96
+ if (!name.endsWith(".jsonl") || statted++ >= MAX_STATTED)
97
+ continue;
98
+ const file = path.join(root, project, name);
99
+ const stat = await fs.stat(file).catch(() => null);
100
+ if (stat && now - stat.mtimeMs <= TRANSCRIPT_WINDOW_MS)
101
+ found.push({ file, mtime: stat.mtimeMs });
102
+ }
103
+ }
104
+ return found.sort((a, b) => b.mtime - a.mtime).slice(0, TRANSCRIPT_FILES).map(entry => entry.file);
105
+ }
106
+ async function newestCodexFiles(home) {
107
+ const root = path.join(home, ".codex", "sessions");
108
+ const files = [];
109
+ const newestFirst = async (directory) => (await entries(directory)).sort().reverse();
110
+ for (const year of await newestFirst(root))
111
+ for (const month of await newestFirst(path.join(root, year)))
112
+ for (const day of await newestFirst(path.join(root, year, month))) {
113
+ for (const name of await newestFirst(path.join(root, year, month, day)))
114
+ if (name.endsWith(".jsonl") && files.length < TRANSCRIPT_FILES)
115
+ files.push(path.join(root, year, month, day, name));
116
+ if (files.length >= TRANSCRIPT_FILES)
117
+ return files;
118
+ }
119
+ return files;
120
+ }
121
+ /** Text samples of the newest transcripts of one tool in the last 30 days. Never throws. */
122
+ export async function readTranscriptSamples(tool, home, now = Date.now()) {
123
+ try {
124
+ const files = tool === "claude" ? await newestClaudeFiles(home, now) : await newestCodexFiles(home);
125
+ const samples = await Promise.all(files.map(sampleFile));
126
+ return samples.filter((sample) => sample !== null);
127
+ }
128
+ catch {
129
+ return [];
130
+ }
131
+ }
@@ -5,22 +5,45 @@ import { execFile } from "node:child_process";
5
5
  import { promisify } from "node:util";
6
6
  import { AgentSetupSchema, SetupNameSchema } from "@bli-cockpit/telemetry-core";
7
7
  import { LOCAL_COLLECTOR_VERSION } from "../local-state.js";
8
+ import { newerVersion, observeTranscripts, readTranscriptSamples } from "./agent-setup-observed.js";
9
+ /** `--version` of a cold Claude start under launchd routinely took over 750 ms (BLI-13055). */
10
+ export const VERSION_CHECK_TIMEOUT_MS = 5000;
8
11
  const exec = promisify(execFile);
9
12
  const files = {
10
13
  read: file => fs.readFile(file, "utf8"), list: directory => fs.readdir(directory),
11
14
  version: async (tool, platform) => {
12
15
  try {
13
- const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout: 750, maxBuffer: 4096, windowsHide: true });
16
+ const { stdout } = await exec(platform === "win32" ? `${tool}.exe` : tool, ["--version"], { timeout: VERSION_CHECK_TIMEOUT_MS, maxBuffer: 4096, windowsHide: true });
14
17
  return /(?:^|\s)(\d+\.\d+\.\d+(?:[-+][a-z0-9.-]+)?)(?:\s|$)/i.exec(stdout)?.[1] ?? null;
15
18
  }
16
19
  catch {
17
20
  return null;
18
21
  }
19
22
  },
23
+ transcripts: (tool, home) => readTranscriptSamples(tool, home),
20
24
  };
21
25
  const safeName = (value) => SetupNameSchema.safeParse(value).success ? value : null;
22
26
  const names = (values) => [...new Set(values.flatMap(value => safeName(value) ? [value] : []))].sort().slice(0, 500);
23
27
  const object = (value) => value && typeof value === "object" && !Array.isArray(value) ? value : {};
28
+ /**
29
+ * Claude Code's default effort per model, from its own docs
30
+ * (https://code.claude.com/docs/en/model-config#adjust-effort-level, read
31
+ * 2026-10-08): `high` on every model that supports effort, except Opus 5.5,
32
+ * Sonnet 5.5 and Haiku 5.5 (`medium`) and Opus 4.7 (`xhigh`). A model this
33
+ * table does not know gets null, shown as "default" without a value.
34
+ */
35
+ export function claudeDefaultEffort(model) {
36
+ if (!model)
37
+ return null;
38
+ if (/^claude-(opus|sonnet|haiku)-5-5/i.test(model))
39
+ return "medium";
40
+ if (/^claude-opus-4-7/i.test(model))
41
+ return "xhigh";
42
+ if (/^claude-(opus|sonnet)-(?:4-[68]|5)(?!-\d)/i.test(model))
43
+ return "high";
44
+ return null;
45
+ }
46
+ const claudeFamily = (model) => /opus|sonnet|haiku/i.exec(model ?? "")?.[0].toLowerCase() ?? null;
24
47
  /** Bounded, explicit locations only. Never recurse through a home or read env files. */
25
48
  export async function collectAgentSetup(home, platform = process.platform, io = files, now = new Date()) {
26
49
  const paths = platform === "win32" ? path.win32 : path.posix;
@@ -53,25 +76,58 @@ export async function collectAgentSetup(home, platform = process.platform, io =
53
76
  return null;
54
77
  }
55
78
  };
56
- const installedVersion = async (tool) => {
57
- const version = await io.version?.(tool, platform).catch(() => null);
58
- if (safeName(version))
59
- return version;
60
- // npm shims on native Windows cannot be execFile'd without a shell. Read
61
- // their installed package metadata instead, never the update-available cache.
62
- const packageName = tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
63
- const packageFile = platform === "win32" ? join("AppData", "Roaming", "npm", "node_modules", ...packageName.split("/"), "package.json") :
64
- paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules", ...packageName.split("/"), "package.json");
65
- const text = await read(packageFile);
66
- try {
67
- return safeName(object(JSON.parse(text ?? "null")).version);
79
+ const packageName = (tool) => tool === "claude" ? "@anthropic-ai/claude-code" : "@openai/codex";
80
+ const versionFromPackage = async (tool) => {
81
+ const parts = packageName(tool).split("/");
82
+ // Where npm puts a global package: Windows keeps it under AppData, macOS
83
+ // under the prefix of the node that installed it (Homebrew, /usr/local,
84
+ // a user prefix) or Claude Code's own ~/.claude/local install.
85
+ const roots = platform === "win32"
86
+ ? [join("AppData", "Roaming", "npm", "node_modules")]
87
+ : [paths.join(paths.dirname(process.execPath), "..", "lib", "node_modules"), "/opt/homebrew/lib/node_modules", "/usr/local/lib/node_modules", join(".npm-global", "lib", "node_modules")];
88
+ if (tool === "claude")
89
+ roots.push(join(".claude", "local", "node_modules"));
90
+ for (const root of roots) {
91
+ const text = await read(paths.join(root, ...parts, "package.json"));
92
+ try {
93
+ const version = safeName(object(JSON.parse(text ?? "null")).version);
94
+ if (version)
95
+ return version;
96
+ }
97
+ catch { /* unreadable metadata: try the next root */ }
68
98
  }
69
- catch {
70
- return null;
99
+ if (tool === "claude") {
100
+ // Claude Code's native installer keeps one directory per version.
101
+ const native = platform === "win32" ? join("AppData", "Local", "claude", "versions") : join(".local", "share", "claude", "versions");
102
+ try {
103
+ return (await io.list(native)).reduce((newest, name) => newerVersion(newest, name.replace(/\.exe$/i, "")), null);
104
+ }
105
+ catch {
106
+ return null;
107
+ }
71
108
  }
109
+ return null;
110
+ };
111
+ const resolveVersion = async (tool, observing) => {
112
+ const viaCli = await io.version?.(tool, platform).catch(() => null);
113
+ if (safeName(viaCli))
114
+ return { value: viaCli, source: "cli" };
115
+ // npm shims on native Windows cannot be execFile'd without a shell. Read
116
+ // their installed package metadata instead, never the update-available cache.
117
+ const viaPackage = await versionFromPackage(tool);
118
+ if (viaPackage)
119
+ return { value: viaPackage, source: "package" };
120
+ const viaTranscript = safeName((await observing).version);
121
+ return viaTranscript ? { value: viaTranscript, source: "transcript" } : { value: null, source: "none" };
122
+ };
123
+ // Everything slow starts together: both `--version` checks (up to 5 s each) and
124
+ // both transcript samples run side by side, so the whole step costs one wait.
125
+ const observe = async (tool) => {
126
+ const samples = await io.transcripts?.(tool, home).catch(() => []) ?? [];
127
+ return observeTranscripts(tool, samples);
72
128
  };
73
- const claudeVersion = await installedVersion("claude");
74
- const codexVersion = await installedVersion("codex");
129
+ const claudeObserving = observe("claude"), codexObserving = observe("codex");
130
+ const [claudeVersion, codexVersion, claudeObserved, codexObserved] = await Promise.all([resolveVersion("claude", claudeObserving), resolveVersion("codex", codexObserving), claudeObserving, codexObserving]);
75
131
  const codexHooks = await json(".codex", "hooks.json");
76
132
  const claudePlugins = await json(".claude", "plugins", "installed_plugins.json");
77
133
  const pluginSkills = [], pluginAgents = [];
@@ -134,19 +190,33 @@ export async function collectAgentSetup(home, platform = process.platform, io =
134
190
  return { name, exists: error.code === "ENOENT" ? false : null, sha256: null, bytes: null };
135
191
  }
136
192
  }));
193
+ // Model: what the person set, else what their sessions actually ran most.
194
+ const claudeSetModel = safeName(claudeSettings?.model), codexSetModel = scalar("model");
195
+ const claudeModel = claudeSetModel ?? claudeObserved.model;
196
+ // The default effort belongs to the model that really runs. A set alias such
197
+ // as "opus" has no version, so the observed full id of that family refines it.
198
+ const claudeDefaultFor = claudeSetModel && claudeFamily(claudeSetModel) !== claudeFamily(claudeObserved.model) ? claudeSetModel : claudeObserved.model ?? claudeSetModel;
199
+ const claudeSetEffort = safeName(claudeSettings?.effortLevel), codexSetEffort = scalar("model_reasoning_effort");
200
+ const claudeDefault = claudeSetEffort ? null : claudeDefaultEffort(claudeDefaultFor);
137
201
  const result = AgentSetupSchema.parse({
138
202
  checked_at: now.toISOString(), os: platform === "darwin" || platform === "win32" ? platform : "other", cli_version: LOCAL_COLLECTOR_VERSION,
139
- claude: { version: claudeVersion, model: safeName(claudeSettings?.model), effort: safeName(claudeSettings?.effortLevel),
203
+ claude: { version: claudeVersion.value, version_source: claudeVersion.source,
204
+ model: claudeModel, model_basis: claudeSetModel ? "set" : claudeModel ? "used" : "none",
205
+ effort: claudeSetEffort ?? claudeDefault, effort_basis: claudeSetEffort ? "set" : "default",
140
206
  skills: claudeSkills === null || pluginInventoryUnknown ? null : names([...claudeSkills, ...pluginSkills]), agents: claudeAgents === null || pluginInventoryUnknown ? null : names([...claudeAgents, ...pluginAgents]),
141
207
  plugins: claudePlugins === null ? null : names(Object.keys(object(claudePlugins.plugins))),
142
208
  hooks: claudeSettings === null ? null : names(Object.keys(object(claudeSettings.hooks))), mcp: claudeMcp },
143
- codex: { version: codexVersion, model: scalar("model"), effort: scalar("model_reasoning_effort"),
209
+ codex: { version: codexVersion.value, version_source: codexVersion.source,
210
+ model: codexSetModel ?? codexObserved.model, model_basis: codexSetModel ? "set" : codexObserved.model ? "used" : "none",
211
+ effort: codexSetEffort ?? codexObserved.effort, effort_basis: codexSetEffort ? "set" : codexObserved.effort ? "observed" : "default",
144
212
  skills: codexSkills === null || sharedSkills === null || systemSkills === null ? null : names([...(codexSkills ?? []), ...(sharedSkills ?? []), ...(systemSkills ?? [])]),
145
213
  agents: toml === null ? null : names([...toml.matchAll(/^\s*\[agents\.([a-z0-9_-]+)\]\s*$/gim)].map(match => match[1])),
146
214
  plugins: toml === null ? null : names([...toml.matchAll(/^\s*\[plugins\."([a-z0-9_@.+-]+)"\]\s*$/gim)].map(match => match[1])),
147
215
  hooks: codexHooks === null && !/^notify\s*=/m.test(rootToml) ? null : names([...(codexHooks === null ? [] : Object.keys(object(codexHooks.hooks))), ...(/^notify\s*=/m.test(rootToml) ? ["notify"] : [])]), mcp: codexMcp }, instructions,
148
216
  });
149
- const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + Object.values(tool).filter(value => value === null).length, 0);
217
+ const evidence = (tool) => ({ version_source: result[tool].version_source, model_basis: result[tool].model_basis, effort_basis: result[tool].effort_basis });
218
+ console.error("[setup] versions read", JSON.stringify({ reason: "version_resolved", claude: evidence("claude"), codex: evidence("codex"), transcript_files: { claude: claudeObserved.files, codex: codexObserved.files } }));
219
+ const unknownFields = [result.claude, result.codex].reduce((count, tool) => count + ["skills", "plugins", "agents", "hooks", "mcp"].filter(key => tool[key] === null).length, 0);
150
220
  console.error("[setup] inventory read", JSON.stringify({ reason: unknownFields ? "inventory_partial" : "inventory_observed", unknown_fields: unknownFields, os: result.os, mcp_count: (result.claude.mcp?.length ?? 0) + (result.codex.mcp?.length ?? 0) }));
151
221
  return result;
152
222
  }
@@ -5,7 +5,7 @@ export function parseSetupArgs(args) {
5
5
  throw new Error("setup takes --person or --compare, not a subcommand.");
6
6
  const compare = optionalNonEmpty(values.flags.get("--compare"));
7
7
  if (compare && (compare.split(",").length !== 2 || compare.split(",").some(value => !value.trim())))
8
- throw new Error("--compare takes two emails separated by a comma.");
8
+ throw new Error("--compare takes two people (first names or emails) separated by a comma.");
9
9
  const since = optionalNonEmpty(values.flags.get("--since"));
10
10
  if (since && since !== "7d" && since !== "30d" && (!/^\d{4}-\d{2}-\d{2}(?:T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z)?$/.test(since) || !Number.isFinite(Date.parse(since)) || new Date(since).toISOString().slice(0, 10) !== since.slice(0, 10) || Date.parse(since) >= Date.now()))
11
11
  throw new Error("--since takes 7d, 30d, or an ISO date before now.");
@@ -0,0 +1,52 @@
1
+ import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
+ import { parseSeenWindow } from "./speed-seen.js";
3
+ import { SPEED_PAGE_NAMES } from "./speed-pages.js";
4
+ /** The most runs one page gets; a speed check is a handful of reads, not a load test. */
5
+ export const SPEED_MAX_RUNS = 50;
6
+ /**
7
+ * `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033), and
8
+ * `cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--json]`
9
+ * for real-visit timings (BLI-13054).
10
+ */
11
+ export function parseSpeedArgs(args) {
12
+ const values = parseNamedArgs(args, {
13
+ allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url", "--seen", "--since", "--everyone"],
14
+ valueFlags: ["--page", "--runs", "--home", "--dashboard-url", "--since"],
15
+ });
16
+ if (values.positionals.length) {
17
+ throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
18
+ }
19
+ const pages = (values.flagValues.get("--page") ?? []).map((name) => name.trim().toLowerCase());
20
+ const unknown = pages.filter((name) => !SPEED_PAGE_NAMES.includes(name));
21
+ if (unknown.length) {
22
+ throw new Error(`Unknown speed page: ${unknown.join(", ")}. Pages: ${SPEED_PAGE_NAMES.join(", ")}.`);
23
+ }
24
+ const runs = optionalPositiveInteger(values.flags.get("--runs"), "--runs");
25
+ if (runs !== undefined && runs > SPEED_MAX_RUNS) {
26
+ throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
27
+ }
28
+ const seen = values.booleans.has("--seen");
29
+ const sinceText = optionalNonEmpty(values.flags.get("--since"));
30
+ const everyone = values.booleans.has("--everyone");
31
+ if (!seen && (sinceText !== undefined || everyone)) {
32
+ throw new Error("--since and --everyone belong to `cockpit speed --seen`.");
33
+ }
34
+ if (seen && runs !== undefined)
35
+ throw new Error("--runs times the data doors; `--seen` reads real visits and takes no --runs.");
36
+ let since;
37
+ if (sinceText !== undefined) {
38
+ const window = parseSeenWindow(sinceText);
39
+ if (window === null)
40
+ throw new Error("--since takes <n>m, <n>h or <n>d, at most 30d (for example 24h).");
41
+ since = window;
42
+ }
43
+ return {
44
+ kind: "speed",
45
+ pages: [...new Set(pages)],
46
+ runs,
47
+ ...(seen ? { seen: true, ...(since ? { since } : {}), ...(everyone ? { everyone: true } : {}) } : {}),
48
+ json: values.booleans.has("--json"),
49
+ homeDir: optionalNonEmpty(values.flags.get("--home")),
50
+ dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
51
+ };
52
+ }
@@ -38,6 +38,7 @@ export { parseModelsArgs } from "./local-args-tower-models.js";
38
38
  export { parseMailArgs } from "./local-args-tower-mail.js";
39
39
  export { parseCalArgs } from "./local-args-tower-cal.js";
40
40
  export { parseSetupArgs } from "./local-args-tower-setup.js";
41
+ export { parseSpeedArgs } from "./local-args-tower-speed.js";
41
42
  export { parseUsageArgs } from "./local-args-tower-usage.js";
42
43
  export { parseLogsArgs } from "./local-args-tower-logs.js";
43
44
  export { parseBrainArgs } from "./local-args-tower-brain.js";
@@ -19,7 +19,7 @@ import { parseGlossaryArgs } from "./glossary.js";
19
19
  import { parseRootsArgs } from "./roots.js";
20
20
  import { SURFACE_ALIASES } from "./local-help.js";
21
21
  import { parseAgentRulesArgs, parseAnalyzeArgs, parseAutostartArgs, parseBackfillArgs, parseCleanArgs, parseDoctorArgs, parseInstallArgs, parseLoginArgs, parseMemoryArgs, parseLogoutArgs, parseOnboardArgs, parseReleaseArgs, parseServeArgs, parseSessionsArgs, parseSourcesArgs, parseStartArgs, parseStatusArgs, parseSyncArgs, parseUpdateArgs, } from "./local-args-collector.js";
22
- import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
22
+ import { parseBriefArgs, parseCorrectArgs, parseDocsArgs, parseIssueArgs, parseJarvisArgs, parseMailArgs, parseCalArgs, parseModelArgs, parseMsgArgs, parseNotesArgs, parseOpsArgs, parseProjectArgs, parseModelsArgs, parseScoutArgs, parseGithubArgs, parseSearchArgs, parseSettingsArgs, parseSlackArgs, parseTeamArgs, parseWorkbookArgs, parseUsageArgs, parseSetupArgs, parseSpeedArgs, parseLogsArgs, parseBrainArgs, parseCareersArgs, parseRenameArgs, } from "./local-args-tower.js";
23
23
  // `normalizeUrl` has always been part of this module's surface — `local.ts` and
24
24
  // `local-auth.ts` import it from here — so it stays exported from this address
25
25
  // even though it now lives next door. The same goes for the four names the
@@ -138,6 +138,8 @@ export function parseLocalArgs(argv) {
138
138
  return parseSetupArgs(argv.slice(1));
139
139
  case "usage":
140
140
  return parseUsageArgs(argv.slice(1));
141
+ case "speed":
142
+ return parseSpeedArgs(argv.slice(1));
141
143
  case "logs":
142
144
  return parseLogsArgs(argv.slice(1));
143
145
  case "brain":
@@ -175,6 +175,31 @@ export const TOWER_COMMAND_HELP = [
175
175
  "--json writes one machine-readable object to stdout; every reason and receipt line stays on stderr.",
176
176
  ],
177
177
  ],
178
+ [
179
+ "speed",
180
+ [
181
+ "Usage: cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
182
+ " cockpit speed --seen [--since <n>m|h|d] [--everyone] [--page <name>]... [--dashboard-url <url>] [--json]",
183
+ "",
184
+ "Times every Tower rail page's data door from this terminal, with this machine's device token.",
185
+ "Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
186
+ "(nearest rank). Also the HTTP status, the response size and the door's own Server-Timing total.",
187
+ "Slowest first. A page your account may not open (Careers for a member) reads \"not permitted\", not as a failure.",
188
+ "--page <name> limits the run to one page; repeat it for several. Names: today, meeting-notes, careers, scout,",
189
+ "messages, mail, docs, memory, tickets, setup, usage, calendar, workbook, settings.",
190
+ "--runs <n> sets the requests per page (default 5, at most 50).",
191
+ "--json writes one object to stdout ({ ok, runs, pages: [...] }); every log line stays on stderr.",
192
+ "It times the DATA DOOR the page opens on, not the page's HTML or paint, and it never uses the local read cache.",
193
+ "Exits 1 when any page failed; a page that is not permitted does not fail the run.",
194
+ "The same measurement is the speed_read MCP tool.",
195
+ "",
196
+ "--seen reads what REAL visits saw instead: per page the visit count, p50 and p95 first-useful milliseconds (the moment",
197
+ "real content showed, from the last-known screen or the fresh one), the share painted from the snapshot, and median HTML",
198
+ "and script transfer. The browser sends one metadata-only beacon per page view; nothing is measured from this machine.",
199
+ "--since <n>m|h|d is the window (default 24h, at most 30d). --everyone reads every person's visits (super_admin only).",
200
+ "A member sees their own visits. --page limits the table to those pages. The same read is the speed_seen MCP tool.",
201
+ ],
202
+ ],
178
203
  [
179
204
  "usage",
180
205
  [
@@ -27,7 +27,7 @@ export function localSubcommandHelp(command) {
27
27
  // BLI-3709: the Tower nouns live next door, split along the same seam the
28
28
  // argument parsers already use — see `local-help-commands-tower.ts`.
29
29
  ...TOWER_COMMAND_HELP,
30
- ["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <email,email>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two exact emails."]],
30
+ ["setup", ["Usage: cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]", "Read agent setups beside windowed outcomes (default 30 days), or compare two people by first name or email."]],
31
31
  [
32
32
  "onboard",
33
33
  [
@@ -83,6 +83,7 @@ export const rootCommandNames = new Set([
83
83
  "rename",
84
84
  "setup",
85
85
  "usage",
86
+ "speed",
86
87
  "logs",
87
88
  "brain",
88
89
  ...SURFACE_ALIASES.keys(),
@@ -143,7 +144,7 @@ export function localCommandHelp(command) {
143
144
  " cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
144
145
  " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
145
146
  " cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
146
- " cockpit setup [--since <7d|30d|date>] [--details] [--person <email>] [--compare <email,email>] [--json]",
147
+ " cockpit setup [--since <7d|30d|date>] [--details] [--person <name|email>] [--compare <name,name>] [--json]",
147
148
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
148
149
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
149
150
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -153,6 +154,7 @@ export function localCommandHelp(command) {
153
154
  " cockpit calendar [same verbs and flags as cockpit cal]",
154
155
  " cockpit messages [same verbs and flags as cockpit msg]",
155
156
  " cockpit meeting-notes [same verbs and flags as cockpit notes]",
157
+ " cockpit speed [--page <name>]... [--runs <n>] [--dashboard-url <url>] [--json]",
156
158
  " cockpit usage people [--by-repo | --by-topic | --by-subject | --by-task] [--min-confidence high|medium|low] [--person <email|me>] [--all] [--detail] [--since <n>d|<n>h|<iso>] [--until <iso>] [--include-automated] [--dashboard-url <url>] [--json]",
157
159
  "",
158
160
  `Default dashboard: ${DEFAULT_DASHBOARD_URL}. Omit --dashboard-url for normal production use; pass it only for staging/custom dashboards or to force a different pairing.`,
@@ -52,6 +52,7 @@ import { runCareers } from "./careers.js";
52
52
  import { runRename } from "./rename-scope.js";
53
53
  import { runSetup } from "./setup.js";
54
54
  import { runUsage } from "./usage.js";
55
+ import { runSpeed } from "./speed.js";
55
56
  import { runLogs } from "./logs.js";
56
57
  import { runBrain } from "./brain.js";
57
58
  import { parseLocalArgs } from "./local-args.js";
@@ -249,6 +250,7 @@ export async function runLocalCockpitCli(argv, io = { ...defaultIo(), runDoctorA
249
250
  return await runRename(command, io);
250
251
  case "setup": return await runSetup(command, io);
251
252
  case "usage": return await runUsage(command, io);
253
+ case "speed": return await runSpeed(command, io);
252
254
  case "logs": return await runLogs(command, io);
253
255
  case "brain": return await runBrain(command, io);
254
256
  }
@@ -21,7 +21,7 @@ export async function runCockpitCli(argv, io) {
21
21
  }
22
22
 
23
23
  if (command === "--version" || command === "-V" || command === "version") {
24
- writeLine(io?.stdout ?? process.stdout, "0.2.193");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.195");
25
25
  return 0;
26
26
  }
27
27
 
@@ -41,6 +41,7 @@ import { readLocalWorkContextForRepo } from "../local-state.js";
41
41
  import os from "node:os";
42
42
  import { getCollectorRuntimePaths } from "../local-state.js";
43
43
  import { buildAgentSessionReport, buildAgentSessionSummary, } from "./agent-session-report.js";
44
+ import { collectSessionCommits, withSessionCommits } from "../session-commits.js";
44
45
  import { planSyncFromLocalState } from "./session-sync-plan.js";
45
46
  import { scanAndAttributeBothSources } from "./session-sync-scan.js";
46
47
  import { syncEveryTargetWorktree } from "./session-sync-upload.js";
@@ -100,14 +101,19 @@ export async function runAttributedWorktreeSync(options) {
100
101
  ticketByWorktree.set(worktree.worktree_fingerprint, ticket);
101
102
  }
102
103
  console.error("[session-sync] bindings read", JSON.stringify({ bound_worktrees: ticketByWorktree.size, reason: "active_work_context" }));
103
- const sessions = buildAgentSessionReport({
104
+ // BLI-13028: which commits each session's working tree created while it ran.
105
+ const commitsBySession = await collectSessionCommits([
106
+ ...scan.codexAttribution.results.map((result) => ({ key: `codex:${result.codex_session_id}`, result })),
107
+ ...scan.claudeAttribution.results.map((result) => ({ key: `claude_code:${result.claude_session_id}`, result })),
108
+ ]);
109
+ const sessions = withSessionCommits(buildAgentSessionReport({
104
110
  ticketByWorktree,
105
111
  codexResults: scan.codexAttribution.results,
106
112
  claudeResults: scan.claudeAttribution.results,
107
113
  outcomes: worktreePass.outcomes,
108
114
  now,
109
115
  claudePriorDurablePointers: worktreePass.claudePriorDurablePointers,
110
- });
116
+ }), commitsBySession);
111
117
  // BLI-4341: every session should arrive counted. Say how many did, and say
112
118
  // it on the success path too: a silent drop from "all" to "some" is exactly
113
119
  // the kind of decay nobody notices.
@@ -24,12 +24,15 @@ export async function runSetup(command, io) {
24
24
  writeLine(io.stdout, " No setup received.");
25
25
  for (const machine of person.machines) {
26
26
  if (!machine.setup) {
27
- writeLine(io.stdout, " Setup is unknown.");
27
+ writeLine(io.stdout, " No readable setup from this machine yet.");
28
28
  continue;
29
29
  }
30
30
  writeLine(io.stdout, ` ${machine.setup.os} · TOWER CLI ${machine.setup.cli_version}${machine.stale ? " · stale setup" : ""}`);
31
- for (const tool of ["claude", "codex"])
32
- writeLine(io.stdout, ` ${tool}: ${machine.setup[tool].model ?? "unknown model"}, ${machine.setup[tool].effort ?? "unknown effort"}`);
31
+ // The server words every model, effort and version (BLI-13055); an older server sends only the raw receipt.
32
+ for (const tool of ["claude", "codex"]) {
33
+ const words = machine.setup_labels?.[tool];
34
+ writeLine(io.stdout, words ? ` ${tool}: model ${words.model}, effort ${words.effort}, version ${words.version}` : ` ${tool}: model ${machine.setup[tool].model ?? "not reported by this machine yet"}, effort ${machine.setup[tool].effort ?? "default"}`);
35
+ }
33
36
  }
34
37
  }
35
38
  for (const pair of body.comparison ?? []) {
@@ -0,0 +1,135 @@
1
+ /**
2
+ * The arithmetic and the layout behind `cockpit speed` (BLI-13033). Pure: no
3
+ * clock, no network, so the timing math, the ranking and the "not permitted"
4
+ * rule are all tested with plain numbers.
5
+ */
6
+ /** 401 is a bad token (a failure to fix); 403 is a page this caller may not open. */
7
+ export function classifyStatus(status) {
8
+ if (status === 403)
9
+ return "not_permitted";
10
+ if (status !== null && status >= 200 && status < 300)
11
+ return "ok";
12
+ return "failed";
13
+ }
14
+ /** Nearest-rank percentile over unsorted values. 0 < p <= 100. Null on no values. */
15
+ export function percentile(values, p) {
16
+ if (values.length === 0)
17
+ return null;
18
+ const sorted = [...values].sort((a, b) => a - b);
19
+ const rank = Math.max(1, Math.ceil((p / 100) * sorted.length));
20
+ return sorted[Math.min(rank, sorted.length) - 1] ?? null;
21
+ }
22
+ /** `total;dur=123.4` out of a Server-Timing header. */
23
+ export function serverTotalMs(header) {
24
+ if (!header)
25
+ return null;
26
+ for (const part of header.split(",")) {
27
+ const [name, ...params] = part.trim().split(";");
28
+ if (name?.trim() !== "total")
29
+ continue;
30
+ for (const param of params) {
31
+ const [key, value] = param.trim().split("=");
32
+ if (key === "dur" && value !== undefined && Number.isFinite(Number(value)))
33
+ return Number(value);
34
+ }
35
+ }
36
+ return null;
37
+ }
38
+ const round1 = (value) => (value === null ? null : Math.round(value * 10) / 10);
39
+ export function summarizeSamples(page, samples) {
40
+ const base = { page: page.name, label: page.label, door: page.path, ...(page.note ? { note: page.note } : {}) };
41
+ const last = samples[samples.length - 1];
42
+ const good = samples.filter((sample) => classifyStatus(sample.status) === "ok");
43
+ const denied = samples.find((sample) => classifyStatus(sample.status) === "not_permitted");
44
+ if (good.length === 0) {
45
+ const first = samples[0];
46
+ const outcome = denied ? "not_permitted" : "failed";
47
+ return {
48
+ ...base,
49
+ outcome,
50
+ cold_ms: null,
51
+ p50_ms: null,
52
+ p95_ms: null,
53
+ runs: samples.length,
54
+ failed_runs: denied ? 0 : samples.length,
55
+ http_status: (denied ?? first)?.status ?? null,
56
+ bytes: null,
57
+ server_timing: null,
58
+ server_total_ms: null,
59
+ reason: denied
60
+ ? "not permitted for this account"
61
+ : (first?.failure ?? (first?.status === null || first === undefined ? "no_response" : `http_${first.status}`)),
62
+ };
63
+ }
64
+ const cold = samples[0] && classifyStatus(samples[0].status) === "ok" ? samples[0] : null;
65
+ const warm = good.filter((sample) => sample !== cold);
66
+ const timed = (warm.length > 0 ? warm : good).map((sample) => sample.ms);
67
+ const lastGood = good[good.length - 1] ?? last;
68
+ return {
69
+ ...base,
70
+ outcome: "ok",
71
+ cold_ms: round1(cold ? cold.ms : null),
72
+ p50_ms: round1(percentile(timed, 50)),
73
+ p95_ms: round1(percentile(timed, 95)),
74
+ runs: samples.length,
75
+ failed_runs: samples.length - good.length,
76
+ http_status: lastGood?.status ?? null,
77
+ bytes: lastGood?.bytes ?? null,
78
+ server_timing: lastGood?.serverTiming ?? null,
79
+ server_total_ms: serverTotalMs(lastGood?.serverTiming ?? null),
80
+ };
81
+ }
82
+ /**
83
+ * Slowest first by median. Pages that answered come first, then pages that
84
+ * failed (a failure is something to fix, so it sits above the pages that were
85
+ * simply not open to this account), then pages not permitted.
86
+ */
87
+ export function rankPages(pages) {
88
+ const tier = (page) => (page.outcome === "ok" ? 0 : page.outcome === "failed" ? 1 : 2);
89
+ return [...pages].sort((a, b) => {
90
+ if (tier(a) !== tier(b))
91
+ return tier(a) - tier(b);
92
+ return (b.p50_ms ?? 0) - (a.p50_ms ?? 0) || a.page.localeCompare(b.page);
93
+ });
94
+ }
95
+ function pad(value, width, right = false) {
96
+ return right ? value.padStart(width) : value.padEnd(width);
97
+ }
98
+ function ms(value) {
99
+ return value === null ? "-" : `${Math.round(value)} ms`;
100
+ }
101
+ function size(bytes) {
102
+ if (bytes === null)
103
+ return "-";
104
+ if (bytes < 1024)
105
+ return `${bytes} B`;
106
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
107
+ }
108
+ /** The ranked table a person reads. Already ranked: this lays out, it does not sort. */
109
+ export function renderSpeedTable(ranked, runs) {
110
+ const rows = ranked.map((page, index) => {
111
+ if (page.outcome === "ok") {
112
+ return [
113
+ String(index + 1),
114
+ page.page,
115
+ ms(page.cold_ms),
116
+ ms(page.p50_ms),
117
+ ms(page.p95_ms),
118
+ String(page.http_status ?? "-"),
119
+ size(page.bytes),
120
+ page.server_total_ms === null ? "-" : ms(page.server_total_ms),
121
+ ];
122
+ }
123
+ const word = page.outcome === "not_permitted" ? "not permitted" : `FAILED (${page.reason ?? "unknown"})`;
124
+ return [String(index + 1), page.page, word, "", "", String(page.http_status ?? "-"), "-", "-"];
125
+ });
126
+ const head = ["#", "PAGE", "COLD", "P50", "P95", "HTTP", "BYTES", "SERVER"];
127
+ const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
128
+ const line = (cells) => cells.map((cell, column) => pad(cell, widths[column] ?? 0, column >= 2 && column !== 5)).join(" ").trimEnd();
129
+ return [
130
+ line(head),
131
+ ...rows.map(line),
132
+ "",
133
+ `${runs} run${runs === 1 ? "" : "s"} per page: the first is cold, p50 and p95 are over the warm runs${runs === 1 ? " (only the cold run was made)" : ""}. SERVER is the door's own Server-Timing total.`,
134
+ ];
135
+ }
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Which door each rail page opens on (BLI-13033).
3
+ *
4
+ * `cockpit speed` times the DATA DOOR of every rail page: the API read the
5
+ * page's first useful content comes from, or (where the page reads the
6
+ * database straight from the server) the nearest door that reads the same
7
+ * rows. It does not time the page's HTML, hydration or paint, so a number here
8
+ * is the floor of what a person waits, never more than that.
9
+ *
10
+ * Order is the rail's order (`ck-rail.tsx` `NAV_ITEMS`). The MCP twin
11
+ * (`packages/bli-cockpit-mcp/src/speed-tools.ts`) carries its own copy, and a
12
+ * test in that package fails when the two lists drift.
13
+ */
14
+ /** The pages in rail order. `memoryContainer` is the tag `cockpit memory profile` would use here. */
15
+ export function speedPages(memoryContainer) {
16
+ return [
17
+ { name: "today", label: "Today (JARVIS home)", path: "/api/jarvis/brief" },
18
+ { name: "meeting-notes", label: "Meeting notes", path: "/api/notes/library" },
19
+ { name: "careers", label: "Careers", path: "/api/careers/applications" },
20
+ { name: "scout", label: "Scout", path: "/api/cockpit/scout" },
21
+ { name: "messages", label: "Messages", path: "/api/msg/channels" },
22
+ { name: "mail", label: "Mail", path: "/api/mail/inbox" },
23
+ { name: "docs", label: "Docs", path: "/api/docs/tree" },
24
+ {
25
+ name: "memory",
26
+ label: "Memory",
27
+ path: `/api/memory/profile?container=${encodeURIComponent(memoryContainer)}`,
28
+ note: "the page reads agent_memories on the server; the profile door is the nearest read",
29
+ },
30
+ { name: "tickets", label: "Tickets", path: "/api/work/issues" },
31
+ { name: "setup", label: "Setup", path: "/api/setup" },
32
+ { name: "usage", label: "Usage", path: "/api/usage/people?since=30d" },
33
+ { name: "calendar", label: "Calendar", path: "/api/cal/week" },
34
+ { name: "workbook", label: "Workbook", path: "/api/cockpit/workbook" },
35
+ { name: "settings", label: "Settings", path: "/api/settings/personal" },
36
+ ];
37
+ }
38
+ export const SPEED_PAGE_NAMES = speedPages("x").map((page) => page.name);
@@ -0,0 +1,119 @@
1
+ /**
2
+ * `cockpit speed --seen [--since 24h] [--everyone]` (BLI-13054) — what real
3
+ * visits experienced on each Tower page, from the page-view beacons the browser
4
+ * sends (`apps/dashboard/src/lib/speed/first-useful.ts`).
5
+ *
6
+ * `cockpit speed` times a page's DATA DOOR from this terminal. It cannot see a
7
+ * page painted instantly from its last-known screen, or one that no longer
8
+ * waits to fetch after it loads. A visit's number is the moment real content
9
+ * first appeared, on that person's own network. Per page: visits, p50 and p95
10
+ * first-useful milliseconds, the share painted from the snapshot, and the
11
+ * median HTML and script transfer where the browser knew them.
12
+ *
13
+ * The door (`GET /api/speed/seen`) does the counting in the database, on the
14
+ * caller's own row policies: a person sees their own visits; `--everyone` needs
15
+ * a super_admin and is refused for anyone else. stdout is `--json`'s alone;
16
+ * every log line goes to stderr, metadata only (counts, a window, a reason).
17
+ */
18
+ import { writeLine } from "./cli-io.js";
19
+ import { loadPairedSession, towerFailureDetail, towerJsonRequest } from "../tower-client.js";
20
+ export const SEEN_DEFAULT_SINCE = "24h";
21
+ const SEEN_MAX_DAYS = 30;
22
+ const UNIT_MS = { m: 60_000, h: 3_600_000, d: 86_400_000 };
23
+ const REQUEST_DEADLINE_MS = 60_000;
24
+ /** `90m`, `24h`, `7d`, at most 30 days (rows are pruned after that); null otherwise. */
25
+ export function parseSeenWindow(text) {
26
+ const match = /^(\d{1,4})([mhd])$/.exec(text.trim().toLowerCase());
27
+ if (!match)
28
+ return null;
29
+ const ms = Number(match[1]) * UNIT_MS[match[2]];
30
+ return ms > 0 && ms <= SEEN_MAX_DAYS * UNIT_MS.d ? `${match[1]}${match[2]}` : null;
31
+ }
32
+ const ms = (value) => (value === null ? "-" : `${Math.round(value)} ms`);
33
+ const percent = (share) => `${Math.round(share * 100)}%`;
34
+ function size(bytes) {
35
+ if (bytes === null)
36
+ return "-";
37
+ if (bytes < 1024)
38
+ return `${bytes} B`;
39
+ return bytes < 1024 * 1024 ? `${(bytes / 1024).toFixed(1)} KB` : `${(bytes / 1024 / 1024).toFixed(2)} MB`;
40
+ }
41
+ /** Slowest median first, like `cockpit speed`; ties by page name. */
42
+ export function rankSeen(pages) {
43
+ return [...pages].sort((a, b) => b.p50_ms - a.p50_ms || a.page.localeCompare(b.page));
44
+ }
45
+ /** The table a person reads. Already ranked: this lays out, it does not sort. */
46
+ export function renderSeenTable(ranked, since, everyone) {
47
+ const head = ["#", "PAGE", "VISITS", "P50", "P95", "SNAPSHOT", "SNAP P50", "NET P50", "HTML", "JS"];
48
+ const rows = ranked.map((page, index) => [
49
+ String(index + 1),
50
+ page.page,
51
+ String(page.visits),
52
+ ms(page.p50_ms),
53
+ ms(page.p95_ms),
54
+ percent(page.snapshot_share),
55
+ ms(page.snapshot_p50_ms),
56
+ ms(page.network_p50_ms),
57
+ size(page.html_bytes_p50),
58
+ size(page.js_bytes_p50),
59
+ ]);
60
+ const widths = head.map((title, column) => Math.max(title.length, ...rows.map((row) => (row[column] ?? "").length)));
61
+ const line = (cells) => cells.map((cell, column) => (column === 1 ? cell.padEnd(widths[column] ?? 0) : cell.padStart(widths[column] ?? 0))).join(" ").trimEnd();
62
+ const who = everyone ? "everyone's visits" : "your visits";
63
+ if (ranked.length === 0) {
64
+ return [`No page views recorded in the last ${since} (${who}). Open Tower in a browser, then ask again.`];
65
+ }
66
+ return [
67
+ line(head),
68
+ ...rows.map(line),
69
+ "",
70
+ `${who}, last ${since}. Times are from the moment real content first showed (the last-known screen or the fresh one) `
71
+ + "to the press on the link (soft) or the start of the load (hard). P50/P95 are nearest-rank. SNAPSHOT is the share "
72
+ + "painted from the last-known screen. HTML and JS are median transfer sizes where the browser reported them (a cached copy reports none).",
73
+ ];
74
+ }
75
+ export async function runSpeedSeen(command, io) {
76
+ const log = (line) => writeLine(io.stderr, line);
77
+ const session = await loadPairedSession("speed", command.homeDir);
78
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
79
+ const since = command.since ?? SEEN_DEFAULT_SINCE;
80
+ const everyone = command.everyone === true;
81
+ const query = new URLSearchParams({ since });
82
+ if (everyone)
83
+ query.set("everyone", "1");
84
+ const answer = await towerJsonRequest({
85
+ dashboardUrl,
86
+ path: `/api/speed/seen?${query.toString()}`,
87
+ method: "GET",
88
+ deviceToken: session.device_token,
89
+ fetch: io.fetch,
90
+ label: "speed-seen",
91
+ timeoutMs: REQUEST_DEADLINE_MS,
92
+ log: (line) => {
93
+ if (!line.startsWith("[tower client] responded"))
94
+ log(line);
95
+ },
96
+ });
97
+ if (!answer.ok) {
98
+ const reason = towerFailureDetail(answer.reason, answer.detail);
99
+ log(`[speed cli] seen refused ${JSON.stringify({ since, everyone, reason: answer.reason, http_status: answer.httpStatus ?? null })}`);
100
+ if (command.json)
101
+ writeLine(io.stdout, JSON.stringify({ ok: false, reason: answer.reason, message: reason }));
102
+ else
103
+ writeLine(io.stderr, `cockpit speed --seen: ${reason}`);
104
+ return 1;
105
+ }
106
+ const body = (answer.body ?? {});
107
+ const wanted = command.pages;
108
+ const pages = rankSeen((body.pages ?? []).filter((page) => wanted.length === 0 || wanted.includes(page.page)));
109
+ const visits = pages.reduce((sum, page) => sum + page.visits, 0);
110
+ log(`[speed cli] seen read ${JSON.stringify({ since, everyone, pages: pages.length, visits })}`);
111
+ if (command.json) {
112
+ writeLine(io.stdout, JSON.stringify({ ok: true, since, everyone, dashboard_url: dashboardUrl, pages }));
113
+ }
114
+ else {
115
+ for (const line of renderSeenTable(pages, since, everyone))
116
+ writeLine(io.stdout, line);
117
+ }
118
+ return 0;
119
+ }
@@ -0,0 +1,134 @@
1
+ /**
2
+ * `cockpit speed` (BLI-13033) — how long each Tower rail page's data door takes,
3
+ * measured from this terminal with this machine's own device token.
4
+ *
5
+ * For every page: one request per run, the first reported as cold and the rest
6
+ * as warm, then p50, p95, the HTTP status, the response size and the door's
7
+ * own `Server-Timing` header. A page this account may not open (Careers for a
8
+ * member) reads "not permitted", which is an answer, not a failure.
9
+ *
10
+ * What a number is: wall-clock from sending the request to the last byte of the
11
+ * body, on THIS machine, so it includes the network between here and Tower. It
12
+ * is the data door, not the page's HTML or paint (`speed-pages.ts` says which
13
+ * door stands for which page). Reads only, and never the local read cache or
14
+ * the warm-connection route: a speed number that a cache answered would be a
15
+ * lie about Tower.
16
+ *
17
+ * stdout is `--json`'s alone; every log line goes to stderr, metadata only (a
18
+ * page name, a status, a millisecond count). The device token is attached by
19
+ * the shared Tower client and never printed.
20
+ */
21
+ import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
22
+ import { writeLine } from "./cli-io.js";
23
+ import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
24
+ import { runSpeedSeen } from "./speed-seen.js";
25
+ import { speedPages } from "./speed-pages.js";
26
+ import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
27
+ export const DEFAULT_SPEED_RUNS = 5;
28
+ const REQUEST_DEADLINE_MS = 60_000;
29
+ export async function runSpeed(command, io, deps = {}) {
30
+ // BLI-13054: `--seen` reads real visits (speed-seen.ts) instead of timing the data doors.
31
+ if (command.seen)
32
+ return runSpeedSeen(command, io);
33
+ const now = deps.now ?? (() => performance.now());
34
+ const log = (line) => writeLine(io.stderr, line);
35
+ const session = await loadPairedSession("speed", command.homeDir);
36
+ const dashboardUrl = command.dashboardUrl ?? session.dashboard_url;
37
+ const runs = command.runs ?? DEFAULT_SPEED_RUNS;
38
+ const everyPage = speedPages(resolveContainerTag({ cwd: process.cwd() }).containerTag);
39
+ const chosen = command.pages.length > 0
40
+ ? everyPage.filter((page) => command.pages.includes(page.name))
41
+ : everyPage;
42
+ const results = [];
43
+ for (const page of chosen) {
44
+ const samples = [];
45
+ for (let run = 0; run < runs; run += 1) {
46
+ const sample = await timeOneRead(page, { dashboardUrl, deviceToken: session.device_token, io, now, log });
47
+ samples.push(sample);
48
+ // A page this account may not open answers the same way every time.
49
+ if (classifyStatus(sample.status) === "not_permitted")
50
+ break;
51
+ }
52
+ const summary = summarizeSamples(page, samples);
53
+ results.push(summary);
54
+ log(`[speed cli] page measured ${JSON.stringify({
55
+ page: summary.page,
56
+ outcome: summary.outcome,
57
+ http_status: summary.http_status,
58
+ runs: samples.length,
59
+ failed_runs: summary.failed_runs,
60
+ p50_ms: summary.p50_ms,
61
+ ...(summary.reason ? { reason: summary.reason } : {}),
62
+ })}`);
63
+ }
64
+ const ranked = rankPages(results);
65
+ const failed = ranked.filter((page) => page.outcome === "failed").length;
66
+ const notPermitted = ranked.filter((page) => page.outcome === "not_permitted").length;
67
+ log(`[speed cli] done ${JSON.stringify({
68
+ pages: ranked.length,
69
+ measured: ranked.length - failed - notPermitted,
70
+ failed,
71
+ not_permitted: notPermitted,
72
+ runs,
73
+ })}`);
74
+ if (command.json) {
75
+ writeLine(io.stdout, JSON.stringify({
76
+ ok: failed === 0,
77
+ measured_at: new Date().toISOString(),
78
+ dashboard_url: dashboardUrl,
79
+ runs,
80
+ pages: ranked,
81
+ }));
82
+ }
83
+ else {
84
+ for (const line of renderSpeedTable(ranked, runs))
85
+ writeLine(io.stdout, line);
86
+ }
87
+ return failed === 0 ? 0 : 1;
88
+ }
89
+ async function timeOneRead(page, context) {
90
+ const startedAt = context.now();
91
+ const answer = await towerRequest({
92
+ dashboardUrl: context.dashboardUrl,
93
+ path: page.path,
94
+ method: "GET",
95
+ deviceToken: context.deviceToken,
96
+ fetch: context.io.fetch,
97
+ label: `speed-${page.name}`,
98
+ timeoutMs: REQUEST_DEADLINE_MS,
99
+ // The client's per-request success trace would print 70 lines here; the
100
+ // failure lines stay.
101
+ log: (line) => {
102
+ if (!line.startsWith("[tower client] responded"))
103
+ context.log(line);
104
+ },
105
+ });
106
+ if (!answer.ok) {
107
+ return {
108
+ status: null,
109
+ ms: context.now() - startedAt,
110
+ bytes: 0,
111
+ serverTiming: null,
112
+ failure: `${answer.reason}: ${towerFailureDetail(answer.reason, answer.detail)}`.slice(0, 120),
113
+ };
114
+ }
115
+ let bytes = 0;
116
+ try {
117
+ bytes = (await answer.response.arrayBuffer()).byteLength;
118
+ }
119
+ catch {
120
+ return {
121
+ status: answer.response.status,
122
+ ms: context.now() - startedAt,
123
+ bytes: 0,
124
+ serverTiming: null,
125
+ failure: "body_read_failed",
126
+ };
127
+ }
128
+ return {
129
+ status: answer.response.status,
130
+ ms: context.now() - startedAt,
131
+ bytes,
132
+ serverTiming: answer.response.headers.get("server-timing"),
133
+ };
134
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Which commits a session made (BLI-13028).
3
+ *
4
+ * The Setup page asks "did this session's work ship in a merged pull request?".
5
+ * A session's branch answers that for only a minority of sessions (most sit on
6
+ * `main`), but the commits a session made always can: git remembers, in the
7
+ * working tree's own reflog, every commit created there and when. So the
8
+ * collector reads that reflog once per working tree and hands each session the
9
+ * commits created between its first and last transcript timestamps.
10
+ *
11
+ * Why the reflog and not `git log`: the reflog is per working tree, so a
12
+ * session in one worktree is never credited with a commit another worktree made
13
+ * at the same moment (a log of the shared history would be). It also holds the
14
+ * new SHAs a rebase writes, which are the ones the pull request ends up with.
15
+ *
16
+ * Metadata only: full commit SHAs, never messages, authors or diffs. One git
17
+ * call per working tree per sync, not per session, because a first run
18
+ * revisits thousands of sessions and a Windows `git` start costs ~100 ms.
19
+ * `execFile` with an argument array runs the same on macOS and native Windows
20
+ * (no shell, no quoting).
21
+ */
22
+ import { execFile } from "node:child_process";
23
+ import { promisify } from "node:util";
24
+ import { describeError } from "./health-detail.js";
25
+ const execFileAsync = promisify(execFile);
26
+ /** Newest reflog entries read per working tree. A busy tree makes a few hundred a day. */
27
+ const REFLOG_ENTRY_LIMIT = 20_000;
28
+ /** A commit made just after the last transcript line still belongs to the session that asked for it. */
29
+ export const SESSION_COMMIT_GRACE_MS = 120_000;
30
+ /** Same cap the wire schema enforces. */
31
+ export const SESSION_COMMIT_SHA_LIMIT = 100;
32
+ /** Reflog subjects that mean "this working tree just created this commit". */
33
+ const COMMIT_CREATING_SUBJECT = /^(commit\b|cherry-pick\b|revert\b|rebase(?: -i)? \((?:pick|reword|squash|fixup|continue|edit)\))/;
34
+ const FULL_SHA = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
35
+ export const runGitForCommits = async (args, cwd) => {
36
+ const { stdout } = await execFileAsync("git", args, {
37
+ cwd,
38
+ timeout: 20_000,
39
+ maxBuffer: 16 * 1024 * 1024,
40
+ windowsHide: true,
41
+ env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
42
+ });
43
+ return stdout;
44
+ };
45
+ /** Every commit-creating reflog entry of one working tree, or the reason it could not be read. */
46
+ export async function readCreatedCommits(repoRoot, git = runGitForCommits) {
47
+ let raw;
48
+ try {
49
+ raw = await git(["log", "-g", "--date=unix", `-n${REFLOG_ENTRY_LIMIT}`, "--format=%H%x1f%gd%x1f%gs", "HEAD"], repoRoot);
50
+ }
51
+ catch (error) {
52
+ console.error("[session-commits] reflog unreadable", JSON.stringify({ reason: "git_reflog_failed", ...describeError(error) }));
53
+ return { failure: "git_reflog_failed" };
54
+ }
55
+ return { commits: parseCreatedCommits(raw) };
56
+ }
57
+ export function parseCreatedCommits(raw) {
58
+ const commits = [];
59
+ for (const line of raw.split(/\r?\n/)) {
60
+ const [sha, selector, subject] = line.split("\x1f");
61
+ if (!sha || !FULL_SHA.test(sha) || !subject || !COMMIT_CREATING_SUBJECT.test(subject))
62
+ continue;
63
+ const at = Number(/@\{(\d+)\}$/.exec(selector ?? "")?.[1]);
64
+ if (Number.isFinite(at))
65
+ commits.push({ sha, at });
66
+ }
67
+ return commits;
68
+ }
69
+ /** The commits created in `[startMs, endMs + grace]`, newest first, capped. */
70
+ export function commitsInSpan(commits, startMs, endMs) {
71
+ const seen = new Set();
72
+ for (const commit of commits) {
73
+ const atMs = commit.at * 1000;
74
+ if (atMs >= startMs && atMs <= endMs + SESSION_COMMIT_GRACE_MS)
75
+ seen.add(commit.sha);
76
+ }
77
+ return [...seen].slice(0, SESSION_COMMIT_SHA_LIMIT);
78
+ }
79
+ /**
80
+ * Commit SHAs per session, keyed `${source}:${session_id}`. A session with no
81
+ * working tree, or no transcript timestamps, gets no entry: "unknown", not
82
+ * "made no commits". A session whose tree was read and made none gets `[]`.
83
+ */
84
+ export async function collectSessionCommits(results, git = runGitForCommits) {
85
+ const ledgers = new Map();
86
+ const bySession = new Map();
87
+ const counts = { sessions_with_commits: 0, sessions_without_commits: 0, sessions_unknown: 0, trees_read: 0, trees_failed: 0 };
88
+ for (const { key, result } of results) {
89
+ const start = Date.parse(result.session_facts?.first_timestamp ?? "");
90
+ const end = Date.parse(result.session_facts?.last_timestamp ?? "");
91
+ if (!result.worktree || !Number.isFinite(start) || !Number.isFinite(end)) {
92
+ counts.sessions_unknown += 1;
93
+ continue;
94
+ }
95
+ const root = result.worktree.repo_root;
96
+ if (!ledgers.has(root))
97
+ ledgers.set(root, readCreatedCommits(root, git));
98
+ const ledger = await ledgers.get(root);
99
+ if ("failure" in ledger) {
100
+ counts.sessions_unknown += 1;
101
+ continue;
102
+ }
103
+ const shas = commitsInSpan(ledger.commits, start, end);
104
+ bySession.set(key, [...new Set([...(bySession.get(key) ?? []), ...shas])].slice(0, SESSION_COMMIT_SHA_LIMIT));
105
+ counts[shas.length > 0 ? "sessions_with_commits" : "sessions_without_commits"] += 1;
106
+ }
107
+ for (const ledger of ledgers.values())
108
+ counts["failure" in (await ledger) ? "trees_failed" : "trees_read"] += 1;
109
+ console.error("[session-commits] commits recorded per session", JSON.stringify({ reason: "session_commits_collected", ...counts }));
110
+ return bySession;
111
+ }
112
+ /** The wire field, only when the collector actually looked. */
113
+ export function commitShasField(shas) {
114
+ return shas ? { commit_shas: shas } : {};
115
+ }
116
+ /** Stamp each report row with the commits recorded for its session. */
117
+ export function withSessionCommits(rows, commitsBySession) {
118
+ return rows.map((row) => ({
119
+ ...row,
120
+ ...commitShasField(commitsBySession.get(`${row.source}:${row.codex_session_id}`)),
121
+ }));
122
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bli-cockpit/cli",
3
- "version": "0.2.193",
3
+ "version": "0.2.195",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,9 +27,9 @@
27
27
  "test": "node dist/cli.js --help && node ../../scripts/assert-public-cli-routing.mjs && node ../../scripts/assert-public-cli-verb-help.mjs && node ../../scripts/assert-public-cli-surface-doors.mjs && node ../../scripts/assert-public-cli-runtime-files.mjs && node ../../scripts/assert-public-cli-exit-contract.mjs && node ../../scripts/assert-public-cli-no-fleet-posts.mjs && node ../../scripts/assert-public-cli-doctor-json.mjs && node ../../scripts/assert-public-package-pack.mjs --workspace=@bli-cockpit/cli"
28
28
  },
29
29
  "dependencies": {
30
- "@bli-cockpit/memory-mcp": "0.1.57",
31
- "@bli-cockpit/mcp": "0.1.105",
32
- "@bli-cockpit/telemetry-core": "0.1.66"
30
+ "@bli-cockpit/memory-mcp": "0.1.58",
31
+ "@bli-cockpit/mcp": "0.1.107",
32
+ "@bli-cockpit/telemetry-core": "0.1.67"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",