@bli-cockpit/cli 0.2.194 → 0.2.196

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.");
@@ -1,12 +1,17 @@
1
1
  import { optionalNonEmpty, optionalPositiveInteger, optionalUrl, parseNamedArgs } from "./local-arg-values.js";
2
+ import { parseSeenWindow } from "./speed-seen.js";
2
3
  import { SPEED_PAGE_NAMES } from "./speed-pages.js";
3
4
  /** The most runs one page gets; a speed check is a handful of reads, not a load test. */
4
5
  export const SPEED_MAX_RUNS = 50;
5
- /** `cockpit speed [--page <name>]... [--runs <n>] [--json]` (BLI-13033). */
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
+ */
6
11
  export function parseSpeedArgs(args) {
7
12
  const values = parseNamedArgs(args, {
8
- allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url"],
9
- valueFlags: ["--page", "--runs", "--home", "--dashboard-url"],
13
+ allowedFlags: ["--page", "--runs", "--json", "--home", "--dashboard-url", "--seen", "--since", "--everyone"],
14
+ valueFlags: ["--page", "--runs", "--home", "--dashboard-url", "--since"],
10
15
  });
11
16
  if (values.positionals.length) {
12
17
  throw new Error("speed takes --page <name>, not a subcommand. Pages: " + SPEED_PAGE_NAMES.join(", ") + ".");
@@ -20,10 +25,26 @@ export function parseSpeedArgs(args) {
20
25
  if (runs !== undefined && runs > SPEED_MAX_RUNS) {
21
26
  throw new Error(`--runs takes at most ${SPEED_MAX_RUNS}.`);
22
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
+ }
23
43
  return {
24
44
  kind: "speed",
25
45
  pages: [...new Set(pages)],
26
46
  runs,
47
+ ...(seen ? { seen: true, ...(since ? { since } : {}), ...(everyone ? { everyone: true } : {}) } : {}),
27
48
  json: values.booleans.has("--json"),
28
49
  homeDir: optionalNonEmpty(values.flags.get("--home")),
29
50
  dashboardUrl: optionalUrl(values.flags.get("--dashboard-url")),
@@ -179,6 +179,7 @@ export const TOWER_COMMAND_HELP = [
179
179
  "speed",
180
180
  [
181
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]",
182
183
  "",
183
184
  "Times every Tower rail page's data door from this terminal, with this machine's device token.",
184
185
  "Per page: the first run is reported as cold, the rest as warm; p50 and p95 are over the warm runs",
@@ -191,6 +192,12 @@ export const TOWER_COMMAND_HELP = [
191
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.",
192
193
  "Exits 1 when any page failed; a page that is not permitted does not fail the run.",
193
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.",
194
201
  ],
195
202
  ],
196
203
  [
@@ -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
  [
@@ -144,7 +144,7 @@ export function localCommandHelp(command) {
144
144
  " cockpit rename [list|resolve <id> --scope <page|everywhere|undo>] [--dashboard-url <url>] [--json]",
145
145
  " cockpit logs <collector|dashboard|railway|railway:<service>|-> [--since 15m|1h|2d|<iso>] [--raw <template-id> [--limit <n>]] [--all-levels] [--table <file>] [--json]",
146
146
  " cockpit brain status [--project <id>] [--show open|running|needs_human|fixed|not_actionable|cleared] [--dashboard-url <url>] [--json]",
147
- " 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]",
148
148
  " cockpit usage sessions [--task <BLI-NNNN> | --subject <name>] [--repo <label>] [--since <window>] [--json]",
149
149
  " cockpit usage sessions --session <id> [--from-window <n>] [--json]",
150
150
  " --by-topic groups work types; --by-subject groups open subjects from session summaries.",
@@ -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.194");
24
+ writeLine(io?.stdout ?? process.stdout, "0.2.196");
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,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
+ }
@@ -21,11 +21,15 @@
21
21
  import { resolveContainerTag } from "@bli-cockpit/memory-mcp/dist/container-tag.js";
22
22
  import { writeLine } from "./cli-io.js";
23
23
  import { classifyStatus, rankPages, renderSpeedTable, summarizeSamples, } from "./speed-math.js";
24
+ import { runSpeedSeen } from "./speed-seen.js";
24
25
  import { speedPages } from "./speed-pages.js";
25
26
  import { loadPairedSession, towerFailureDetail, towerRequest } from "../tower-client.js";
26
27
  export const DEFAULT_SPEED_RUNS = 5;
27
28
  const REQUEST_DEADLINE_MS = 60_000;
28
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);
29
33
  const now = deps.now ?? (() => performance.now());
30
34
  const log = (line) => writeLine(io.stderr, line);
31
35
  const session = await loadPairedSession("speed", command.homeDir);
@@ -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.194",
3
+ "version": "0.2.196",
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.106",
32
- "@bli-cockpit/telemetry-core": "0.1.66"
30
+ "@bli-cockpit/memory-mcp": "0.1.59",
31
+ "@bli-cockpit/mcp": "0.1.108",
32
+ "@bli-cockpit/telemetry-core": "0.1.68"
33
33
  },
34
34
  "repository": {
35
35
  "type": "git",