@tekmidian/pai 0.43.0 → 0.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/{chain-CEC1a5mX.mjs → chain-DAEC9AU7.mjs} +2 -2
  2. package/dist/{chain-CEC1a5mX.mjs.map → chain-DAEC9AU7.mjs.map} +1 -1
  3. package/dist/cli/index.mjs +5 -5
  4. package/dist/cli/program.mjs +5 -5
  5. package/dist/daemon/index.mjs +4 -4
  6. package/dist/{daemon-BhldpFvO.mjs → daemon-DNBEFHaj.mjs} +6 -6
  7. package/dist/{daemon-BhldpFvO.mjs.map → daemon-DNBEFHaj.mjs.map} +1 -1
  8. package/dist/{daemon-QIvCXB5f.mjs → daemon-Dq2ZSdex.mjs} +4 -4
  9. package/dist/daemon-mcp/index.mjs +3 -3
  10. package/dist/{fallback-BQJ8PbCK.mjs → fallback-CxGDTXWt.mjs} +2 -2
  11. package/dist/{fallback-BQJ8PbCK.mjs.map → fallback-CxGDTXWt.mjs.map} +1 -1
  12. package/dist/hooks/context-compression-hook.mjs.map +2 -2
  13. package/dist/hooks/load-project-context.mjs +28 -12
  14. package/dist/hooks/load-project-context.mjs.map +2 -2
  15. package/dist/hooks/mcp-deferred-reminder.mjs.map +1 -1
  16. package/dist/hooks/post-compact-inject.mjs.map +2 -2
  17. package/dist/{planner-BlKGgpWI.mjs → planner-B4DvNpXd.mjs} +3 -3
  18. package/dist/{planner-BlKGgpWI.mjs.map → planner-B4DvNpXd.mjs.map} +1 -1
  19. package/dist/{program-C5jRQ1kg.mjs → program-DvuMelhj.mjs} +645 -55
  20. package/dist/program-DvuMelhj.mjs.map +1 -0
  21. package/dist/{run-ByBKowN5.mjs → run-BgExF8Xg.mjs} +16 -4
  22. package/dist/{run-ByBKowN5.mjs.map → run-BgExF8Xg.mjs.map} +1 -1
  23. package/dist/{tools-DiRC5glA.mjs → tools-B77WHeVZ.mjs} +2 -2
  24. package/dist/{tools-DiRC5glA.mjs.map → tools-B77WHeVZ.mjs.map} +1 -1
  25. package/dist/{tools-CAG2sUjf.mjs → tools-NnR_n-Ja.mjs} +2 -2
  26. package/dist/{wakeup-BfElduEU.mjs → wakeup-UrhiW-KU.mjs} +27 -14
  27. package/dist/wakeup-UrhiW-KU.mjs.map +1 -0
  28. package/docs/commands/README.md +3 -1
  29. package/docs/commands/audit.md +25 -2
  30. package/package.json +1 -1
  31. package/src/hooks/ts/lib/context-fill.test.ts +40 -0
  32. package/src/hooks/ts/lib/context-fill.ts +12 -6
  33. package/src/hooks/ts/session-start/load-project-context.ts +8 -4
  34. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -2
  35. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +0 -1
  36. package/dist/hooks/session-commands.mjs +0 -26
  37. package/dist/hooks/session-commands.mjs.map +0 -7
  38. package/dist/program-C5jRQ1kg.mjs.map +0 -1
  39. package/dist/wakeup-BfElduEU.mjs.map +0 -1
  40. package/src/hooks/ts/session-start/session-commands.ts +0 -30
@@ -12,17 +12,17 @@ import { B as claudeProjectsDir, C as findLatestNote, H as scanTranscriptFolders
12
12
  import { a as expandHome, c as paiConfigFilePath, i as ensureConfigDir, l as OWNER_LABEL_PREFIX, n as CONFIG_FILE$2, o as loadConfig, s as migrateConfigFile, t as CONFIG_DIR, u as UNROUTED } from "./config-DCu8THKi.mjs";
13
13
  import { a as schedulerLogPath, i as paiSocketPath, n as daemonLogPath, r as daemonPidPath } from "./runtime-paths-B0P1TvUr.mjs";
14
14
  import { A as DEFAULT_CONTEXT_WINDOW, C as inlineWorkersYamlKeys, D as relocateWorkersYaml, E as parseWorkersYamlDocument, M as stripModelVariant, N as readJsonStrict, O as workersYamlLegacyNotice, P as writeJsonAtomic, S as initWorkersYaml, T as needsWorkersYamlRelocation, d as getProviderOrNative, f as isModelCapability, h as nativeAnthropicProvider, i as PROVIDER_TAGS, j as contextWindowFromModelId, k as workersYamlPath, m as maskKey, o as WorkersConfigError, r as MODEL_CAPABILITIES, t as ANTHROPIC_NATIVE, u as expandHome$1, v as readWorkersSection, w as migrateWorkersToYaml } from "./config-DxMZjpvY.mjs";
15
- import { A as loadStatuses, B as expandMcpNames, C as UNLABELED, F as waitForTerminalStatus, I as longInlinePromptHint, L as parseRunnerArgs, N as saveStatus, O as isChatPane, P as setWorkerLabel, a as parseWorkerReport, c as mergeWorker, d as sayToWorker, h as openPaneForWorker, i as usageContextTokens, k as loadStatus, l as handoffFromInside, m as openFollowPane, n as runWorker, p as checkPaneForWorker, r as testProvider, s as discardWorker, x as appendLedger, z as describeMcp } from "./run-ByBKowN5.mjs";
15
+ import { A as loadStatus, B as describeMcp, F as setWorkerLabel, I as waitForTerminalStatus, L as longInlinePromptHint, P as saveStatus, R as parseRunnerArgs, S as appendLedger, V as expandMcpNames, a as projectLaunchConfig, c as discardWorker, f as sayToWorker, g as openPaneForWorker, h as openFollowPane, i as usageContextTokens, j as loadStatuses, k as isChatPane, l as mergeWorker, m as checkPaneForWorker, n as runWorker, o as parseWorkerReport, r as testProvider, u as handoffFromInside, w as UNLABELED } from "./run-BgExF8Xg.mjs";
16
16
  import { t as PaiClient } from "./ipc-client-C6NryMMb.mjs";
17
17
  import { r as humanDuration, t as createStorageBackend } from "./factory-DNYAqjH0.mjs";
18
- import { n as migrateIdentityFile } from "./wakeup-BfElduEU.mjs";
18
+ import { n as migrateIdentityFile } from "./wakeup-UrhiW-KU.mjs";
19
19
  import { a as renderDedupedSessions, c as probeResume, d as fmtAge, f as migrateSessionScanCache, h as scanSessions, i as normalizeName$2, l as restoreTopLevel, m as scanCacheFilePath, o as hasConversation, p as resolveSessionByNameOrId, r as buildDeduped, s as launchInDir, t as cmdMain, u as printExitDir } from "./main-resolver-DvYu9jbl.mjs";
20
20
  import { n as queriesDirPath, t as migrateQueriesDir } from "./query-feedback-CYyOGi3y.mjs";
21
21
  import { s as kgQuery } from "./kg-entity-BSjM5xZh.mjs";
22
22
  import { _ as workersLogDir, f as ensureNoMcpConfig, i as buildRunEnv, m as ledgerPath, n as ensureProxyRunning, p as eventsPath, r as stopProxy, t as DEFAULT_PROXY_PORT } from "./server-Ch99xt5K.mjs";
23
23
  import { i as sendToSession, n as fetchLiveSessions, r as revealItermSession, t as callAiBroker } from "./aibroker-client-Bn9SnGMa.mjs";
24
- import { t as runChain } from "./chain-CEC1a5mX.mjs";
25
- import { C as readSpecPrompt, S as statusLineOutput, _ as updateProvider, a as addProvider, b as psOutput, c as describeProviders, d as resolveProviderName, f as setClass, g as unsetClass, h as setWorkersEnabled, i as fallbackStatusText, l as modelPrefsText, m as setProviderModel, n as fallbackOn, o as classTargetText, p as setProviderEnabled, r as fallbackStatus, s as describeModels, t as fallbackOff, u as removeProvider, v as useProvider, w as resolveSpecPath, x as replayOutput, y as followWorkers } from "./fallback-BQJ8PbCK.mjs";
24
+ import { t as runChain } from "./chain-DAEC9AU7.mjs";
25
+ import { C as readSpecPrompt, S as statusLineOutput, _ as updateProvider, a as addProvider, b as psOutput, c as describeProviders, d as resolveProviderName, f as setClass, g as unsetClass, h as setWorkersEnabled, i as fallbackStatusText, l as modelPrefsText, m as setProviderModel, n as fallbackOn, o as classTargetText, p as setProviderEnabled, r as fallbackStatus, s as describeModels, t as fallbackOff, u as removeProvider, v as useProvider, w as resolveSpecPath, x as replayOutput, y as followWorkers } from "./fallback-CxGDTXWt.mjs";
26
26
  import { appendFileSync, chmodSync, copyFileSync, cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
27
27
  import { homedir, platform, tmpdir } from "node:os";
28
28
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -1282,7 +1282,7 @@ function cmdConfig(db, identifier, opts) {
1282
1282
  * configured server loads, today's behavior. Names are validated against
1283
1283
  * ~/.claude.json's mcpServers / workers.mcpSets the same way `--mcp` is.
1284
1284
  */
1285
- function cmdMcp(db, names, opts = {}) {
1285
+ function cmdMcp$1(db, names, opts = {}) {
1286
1286
  const project = currentProject(db, opts.cwd);
1287
1287
  if (!project) {
1288
1288
  console.error(err("No registered project matches the current directory."));
@@ -2077,7 +2077,7 @@ function registerProjectsCommands(projectsCmd, getDb) {
2077
2077
  cmdConfig(getDb(), identifier, opts);
2078
2078
  });
2079
2079
  projectsCmd.command("mcp [names...]").description("View or set the MCP servers this project's interactive supervisor session\nloads (run from inside the project, like `pai projects here`).\nNo names: print the current value. Names (comma-separated or repeated\nargs): set it, validated against ~/.claude.json's mcpServers.\n--clear: unset (all servers load — today's default).").option("--clear", "Unset — all MCP servers load again").action((names, opts) => {
2080
- cmdMcp(getDb(), names, opts);
2080
+ cmdMcp$1(getDb(), names, opts);
2081
2081
  });
2082
2082
  projectsCmd.command("tools [names...]").description("View or set the built-in tool schemas this project's interactive\nsupervisor session loads (run from inside the project, like `pai projects\nhere`). No names: print the current value. Names (comma-separated or\nrepeated args): set it. --clear: unset (all tools load — today's default).").option("--clear", "Unset — all built-in tools load again").action((names, opts) => {
2083
2083
  cmdTools(getDb(), names, opts);
@@ -5906,7 +5906,7 @@ function cmdLogs(opts) {
5906
5906
  }
5907
5907
  function registerDaemonCommands(daemonCmd) {
5908
5908
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
5909
- const { serve } = await import("./daemon-QIvCXB5f.mjs");
5909
+ const { serve } = await import("./daemon-Dq2ZSdex.mjs");
5910
5910
  const { loadConfig: lc, ensureConfigDir } = await import("./config-DeqFqa1N.mjs");
5911
5911
  ensureConfigDir();
5912
5912
  await serve(lc());
@@ -7173,10 +7173,6 @@ async function stepSettings(rl, daName) {
7173
7173
  DA: daName
7174
7174
  },
7175
7175
  hooks: [
7176
- {
7177
- hookType: "SessionStart",
7178
- command: "${ADAPTER_DIR}/Hooks/session-commands.mjs"
7179
- },
7180
7176
  {
7181
7177
  hookType: "SessionStart",
7182
7178
  command: "${ADAPTER_DIR}/Hooks/load-core-context.mjs"
@@ -9970,18 +9966,37 @@ function emptyTotals() {
9970
9966
  };
9971
9967
  }
9972
9968
  /**
9969
+ * `type:"system"` `subtype:"compact_boundary"` lines mark a compaction.
9970
+ * turnIndex is the count of assistant turns already folded when it fired,
9971
+ * so it lines up with the turn numbering the text/JSON report prints.
9972
+ */
9973
+ function isCompactBoundary(line) {
9974
+ return line.type === "system" && line.subtype === "compact_boundary";
9975
+ }
9976
+ function parseCompactionEvent(line, turnIndex) {
9977
+ const meta = line.compactMetadata;
9978
+ if (!meta || typeof meta.preTokens !== "number") return null;
9979
+ return {
9980
+ trigger: meta.trigger ?? "unknown",
9981
+ preTokens: meta.preTokens,
9982
+ turnIndex
9983
+ };
9984
+ }
9985
+ /**
9973
9986
  * Fold one already-parsed JSONL line into a report being accumulated.
9974
9987
  * Exported separately so both the streaming file reader below and tests
9975
9988
  * (which build fixtures as arrays of objects, not files) share one path.
9989
+ * Returns the turn's context value when a new turn was counted, else null
9990
+ * (non-assistant line, no usage, or a duplicate message.id already seen).
9976
9991
  */
9977
- function foldAssistantLine(report, seenIds, line) {
9978
- if (line.type !== "assistant") return;
9992
+ function foldAssistantLine(report, seenIds, line, threshold) {
9993
+ if (line.type !== "assistant") return null;
9979
9994
  const message = line.message;
9980
9995
  const usage = message?.usage;
9981
- if (!usage) return;
9996
+ if (!usage) return null;
9982
9997
  const id = message?.id ?? line.uuid;
9983
9998
  if (id) {
9984
- if (seenIds.has(id)) return;
9999
+ if (seenIds.has(id)) return null;
9985
10000
  seenIds.add(id);
9986
10001
  }
9987
10002
  report.turns++;
@@ -9992,6 +10007,9 @@ function foldAssistantLine(report, seenIds, line) {
9992
10007
  const context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
9993
10008
  if (report.firstTurnContext === null) report.firstTurnContext = context;
9994
10009
  report.lastTurnContext = context;
10010
+ report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);
10011
+ if (context > threshold) report.turnsAboveThreshold++;
10012
+ if ((Number(usage.cache_creation_input_tokens) || 0) > 2e4) report.cacheRebuildTurns++;
9995
10013
  const model = message?.model ?? "unknown";
9996
10014
  report.models[model] = (report.models[model] ?? 0) + 1;
9997
10015
  const split = usage.cache_creation;
@@ -9999,9 +10017,23 @@ function foldAssistantLine(report, seenIds, line) {
9999
10017
  report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;
10000
10018
  report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;
10001
10019
  }
10020
+ return context;
10021
+ }
10022
+ /**
10023
+ * True for a real human-authored `type:"user"` prompt line: content is a
10024
+ * plain string, or a content array with no `tool_result` block. Claude Code
10025
+ * encodes tool results as `type:"user"` messages whose content array is
10026
+ * entirely (or partly) tool_result blocks — those must not count as prompts.
10027
+ */
10028
+ function isRealUserPrompt(line) {
10029
+ if (line.type !== "user") return false;
10030
+ const content = line.message?.content;
10031
+ if (typeof content === "string") return true;
10032
+ if (Array.isArray(content)) return !content.some((block) => block?.type === "tool_result");
10033
+ return false;
10002
10034
  }
10003
10035
  /** Parse a session/subagent/worker-event JSONL file into a usage report. */
10004
- async function parseSessionUsage(path) {
10036
+ async function parseSessionUsage(path, threshold = 2e5) {
10005
10037
  const report = {
10006
10038
  path,
10007
10039
  sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,
@@ -10013,9 +10045,16 @@ async function parseSessionUsage(path) {
10013
10045
  cacheCreationSplit: {
10014
10046
  ephemeral5m: 0,
10015
10047
  ephemeral1h: 0
10016
- }
10048
+ },
10049
+ avgContext: null,
10050
+ maxContext: null,
10051
+ turnsAboveThreshold: 0,
10052
+ cacheRebuildTurns: 0,
10053
+ userPrompts: 0,
10054
+ compactions: []
10017
10055
  };
10018
10056
  const seenIds = /* @__PURE__ */ new Set();
10057
+ let contextSum = 0;
10019
10058
  const rl = createInterface({
10020
10059
  input: createReadStream(path, "utf8"),
10021
10060
  crlfDelay: Infinity
@@ -10028,8 +10067,19 @@ async function parseSessionUsage(path) {
10028
10067
  } catch {
10029
10068
  continue;
10030
10069
  }
10031
- foldAssistantLine(report, seenIds, obj);
10070
+ if (obj.type === "user") {
10071
+ if (isRealUserPrompt(obj)) report.userPrompts++;
10072
+ continue;
10073
+ }
10074
+ if (isCompactBoundary(obj)) {
10075
+ const event = parseCompactionEvent(obj, report.turns);
10076
+ if (event) report.compactions.push(event);
10077
+ continue;
10078
+ }
10079
+ const context = foldAssistantLine(report, seenIds, obj, threshold);
10080
+ if (context !== null) contextSum += context;
10032
10081
  }
10082
+ report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;
10033
10083
  return report;
10034
10084
  }
10035
10085
  function totalUsageTokens(totals) {
@@ -10079,10 +10129,10 @@ function newestSessionLog() {
10079
10129
  }
10080
10130
  return best?.path ?? null;
10081
10131
  }
10082
- async function auditSession(path) {
10132
+ async function auditSession(path, threshold) {
10083
10133
  const target = path ?? newestSessionLog();
10084
10134
  if (!target) return null;
10085
- const report = await parseSessionUsage(target);
10135
+ const report = await parseSessionUsage(target, threshold);
10086
10136
  const total = totalUsageTokens(report.totals) || 1;
10087
10137
  const percentages = {};
10088
10138
  for (const [key, value] of Object.entries(report.totals)) percentages[key] = 100 * value / total;
@@ -10689,7 +10739,7 @@ function findCatalogueFiles(claudeDir) {
10689
10739
  return found;
10690
10740
  }
10691
10741
  /** Strip a single layer of matching quotes ("..." or '...') from a trimmed value. */
10692
- function unquote(value) {
10742
+ function unquote$1(value) {
10693
10743
  if (value.length >= 2) {
10694
10744
  const first = value[0];
10695
10745
  const last = value[value.length - 1];
@@ -10704,8 +10754,8 @@ function parseFrontmatter(text) {
10704
10754
  const nameMatch = /^name:\s*(.+)$/m.exec(fm);
10705
10755
  const descMatch = /(?:^|\n)description:\s*(.+?)(?=\n\w[\w-]*:|$)/s.exec(fm);
10706
10756
  return {
10707
- name: nameMatch ? unquote(nameMatch[1].trim()) : null,
10708
- description: descMatch ? unquote(descMatch[1].trim()) : ""
10757
+ name: nameMatch ? unquote$1(nameMatch[1].trim()) : null,
10758
+ description: descMatch ? unquote$1(descMatch[1].trim()) : ""
10709
10759
  };
10710
10760
  }
10711
10761
  function nameFallback(path, source) {
@@ -10713,7 +10763,7 @@ function nameFallback(path, source) {
10713
10763
  return basename(dirname(path));
10714
10764
  }
10715
10765
  /** Build one catalogue entry from a file, deriving its name/description/tokens. */
10716
- function readEntry(path, source) {
10766
+ function readEntry$1(path, source) {
10717
10767
  let text = "";
10718
10768
  try {
10719
10769
  text = readFileSync(path, "utf8");
@@ -10815,7 +10865,7 @@ function auditSkills(claudeDir = join(homedir(), ".claude"), settingsPath = join
10815
10865
  deduped.push(file);
10816
10866
  }
10817
10867
  const enabledPlugins = readEnabledPlugins(settingsPath);
10818
- const entries = deduped.map((f) => readEntry(f.path, f.source));
10868
+ const entries = deduped.map((f) => readEntry$1(f.path, f.source));
10819
10869
  const dedupeKeys = /* @__PURE__ */ new Map();
10820
10870
  for (const entry of entries) {
10821
10871
  if (entry.source !== "plugins") continue;
@@ -10875,6 +10925,8 @@ const SKILL_CATALOGUE_RED = 6e3;
10875
10925
  const HOOK_TOKEN_LIMIT = 1e3;
10876
10926
  const FIRST_TURN_CONTEXT_LIMIT = 3e4;
10877
10927
  const DAEMON_FAILURE_RATE_LIMIT = .05;
10928
+ const CONTEXT_GROWTH_AVG_RED = 1e5;
10929
+ const CONTEXT_GROWTH_MAX_AMBER = 15e4;
10878
10930
  const SEVERITY_RANK = {
10879
10931
  RED: 0,
10880
10932
  AMBER: 1,
@@ -10946,6 +10998,49 @@ function buildFindings(input) {
10946
10998
  severity: skillTokens > SKILL_CATALOGUE_RED ? "RED" : skillTokens > SKILL_CATALOGUE_AMBER ? "AMBER" : "GREEN",
10947
10999
  evidence: `${skillTokens} tokens loaded (${input.skills.total} on disk) across ${input.skills.entries.length} entries` + (input.skills.duplicates.length ? `, ${input.skills.duplicates.length} case-insensitive duplicate name(s)` : "")
10948
11000
  });
11001
+ const inheriting = input.subagents.entries.filter((e) => e.model === "inherits");
11002
+ if (input.subagents.entries.length === 0) findings.push({
11003
+ finding: "subagent model pinning",
11004
+ severity: "GREEN",
11005
+ evidence: "no agent files"
11006
+ });
11007
+ else findings.push({
11008
+ finding: "subagent model pinning",
11009
+ severity: inheriting.length > 0 ? "AMBER" : "GREEN",
11010
+ evidence: `${inheriting.length} of ${input.subagents.entries.length} inherit`
11011
+ });
11012
+ const configuredCount = input.mcp.servers.filter((s) => s.configuredIn.length > 0).length;
11013
+ const plainLaunch = input.mcp.liveProcesses.find((p) => p.mcpConfigPath === null);
11014
+ if (!plainLaunch) findings.push({
11015
+ finding: "MCP servers loaded vs. used",
11016
+ severity: "GREEN",
11017
+ evidence: `${configuredCount} configured, no plain launch found`
11018
+ });
11019
+ else {
11020
+ const zeroCallServers = input.mcp.servers.filter((s) => s.configuredIn.length > 0 && s.calls30d === 0);
11021
+ findings.push({
11022
+ finding: "MCP servers loaded vs. used",
11023
+ severity: zeroCallServers.length > 0 ? "AMBER" : "GREEN",
11024
+ evidence: `${configuredCount} configured, ${zeroCallServers.length} loaded by pid ${plainLaunch.pid}`
11025
+ });
11026
+ }
11027
+ if (input.session) {
11028
+ const avg = input.session.avgContext ?? 0;
11029
+ const max = input.session.maxContext ?? 0;
11030
+ const severity = avg > CONTEXT_GROWTH_AVG_RED || input.session.turnsAboveThreshold > 0 ? "RED" : max > CONTEXT_GROWTH_MAX_AMBER ? "AMBER" : "GREEN";
11031
+ findings.push({
11032
+ finding: "context growth",
11033
+ severity,
11034
+ evidence: `avg ${avg}, max ${max}, ${input.session.turnsAboveThreshold} turns > ${input.ctxThreshold}, ${input.session.turns} turns`
11035
+ });
11036
+ const compactions = input.session.compactions;
11037
+ const overshoots = compactions.filter((c) => c.trigger === "auto").filter((c) => c.preTokens > 1.25 * input.ctxThreshold);
11038
+ findings.push({
11039
+ finding: "compaction trigger",
11040
+ severity: overshoots.length > 0 ? "RED" : "GREEN",
11041
+ evidence: overshoots.length > 0 ? `override not honoured: ${overshoots.map((c) => `preTokens=${c.preTokens}`).join(", ")} vs configured ${input.ctxThreshold}` : compactions.length === 0 ? `0 compactions in ${input.session.turns} turns` : `${compactions.length} compaction(s), all within 25% of configured ${input.ctxThreshold}`
11042
+ });
11043
+ }
10949
11044
  return sortFindings(findings);
10950
11045
  }
10951
11046
 
@@ -11122,6 +11217,307 @@ async function auditLadder(opts) {
11122
11217
  };
11123
11218
  }
11124
11219
 
11220
+ //#endregion
11221
+ //#region src/audit/subagents.ts
11222
+ /**
11223
+ * `pai audit tokens subagents` — token cost of every Claude Code subagent
11224
+ * definition: `.claude/agents/*.md` under the user's home and (separately)
11225
+ * under the current project. A subagent with no `model:` frontmatter field
11226
+ * inherits the caller's model at runtime rather than costing a fixed model
11227
+ * choice, so it is reported as `"inherits"` rather than left blank.
11228
+ */
11229
+ /** Strip a single layer of matching quotes ("..." or '...') from a trimmed value. */
11230
+ function unquote(value) {
11231
+ if (value.length >= 2) {
11232
+ const first = value[0];
11233
+ const last = value[value.length - 1];
11234
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
11235
+ }
11236
+ return value;
11237
+ }
11238
+ /** The top-level `model:` frontmatter field, or `"inherits"` if absent/empty. */
11239
+ function parseModel(text) {
11240
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
11241
+ if (!match) return "inherits";
11242
+ const modelMatch = /^model:\s*(.+)$/m.exec(match[1]);
11243
+ if (!modelMatch) return "inherits";
11244
+ const value = unquote(modelMatch[1].trim());
11245
+ return value === "" ? "inherits" : value;
11246
+ }
11247
+ /** Every top-level `.md` file directly under `agentsDir` (not recursive). */
11248
+ function findAgentFiles(agentsDir) {
11249
+ let names;
11250
+ try {
11251
+ names = readdirSync(agentsDir);
11252
+ } catch {
11253
+ return [];
11254
+ }
11255
+ return names.filter((n) => n.endsWith(".md")).map((n) => join(agentsDir, n));
11256
+ }
11257
+ function readEntry(path) {
11258
+ let text = "";
11259
+ try {
11260
+ text = readFileSync(path, "utf8");
11261
+ } catch {}
11262
+ return {
11263
+ path,
11264
+ tokens: countTokens(text),
11265
+ model: parseModel(text)
11266
+ };
11267
+ }
11268
+ function auditSubagents(homeDir, cwd) {
11269
+ return {
11270
+ encoding: TOKEN_ENCODING,
11271
+ entries: [...findAgentFiles(join(homeDir, ".claude", "agents")), ...findAgentFiles(join(cwd, ".claude", "agents"))].map(readEntry)
11272
+ };
11273
+ }
11274
+
11275
+ //#endregion
11276
+ //#region src/audit/mcp.ts
11277
+ /**
11278
+ * `pai audit mcp` — cross-reference every MCP server name found anywhere
11279
+ * (global/project config, project `.mcp.json`, live process argv, 30-day
11280
+ * transcript usage) against where it's configured, whether it's pinned via
11281
+ * `pai project mcp`, whether a live process actually loaded it, and how much
11282
+ * it was used in the last 30 days.
11283
+ *
11284
+ * The `--connect` path is the only side-effecting one: it launches every
11285
+ * configured stdio MCP server to call `listTools()`. It never runs unless
11286
+ * the caller passes `connect: true` explicitly — there is no default-on path.
11287
+ */
11288
+ const THIRTY_DAYS_MS = 720 * 60 * 60 * 1e3;
11289
+ const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024;
11290
+ /** Whether a transcript file is worth streaming: fresh enough and small enough. */
11291
+ function shouldScanTranscript(stat, now) {
11292
+ if (stat.size > MAX_TRANSCRIPT_BYTES) return false;
11293
+ if (now - stat.mtimeMs > THIRTY_DAYS_MS) return false;
11294
+ return true;
11295
+ }
11296
+ /**
11297
+ * Whether a live process's resolved server set includes `serverName`.
11298
+ * `servers === null` means "no --mcp-config flag", i.e. every configured
11299
+ * server loads; an array (possibly empty) means only those names load.
11300
+ */
11301
+ function liveProcessLoadsServer(proc, serverName, allConfiguredNames) {
11302
+ if (proc.servers === null) return allConfiguredNames.includes(serverName);
11303
+ return proc.servers.includes(serverName);
11304
+ }
11305
+ function resolveHomePath(path, homeDir) {
11306
+ return path.startsWith("~") ? join(homeDir, path.slice(1)) : path;
11307
+ }
11308
+ function readJsonFile(path) {
11309
+ try {
11310
+ return JSON.parse(readFileSync(path, "utf8"));
11311
+ } catch {
11312
+ return null;
11313
+ }
11314
+ }
11315
+ function gatherConfiguredSources(cwd, homeDir) {
11316
+ const configuredIn = /* @__PURE__ */ new Map();
11317
+ const disabled = /* @__PURE__ */ new Set();
11318
+ const serverDefs = /* @__PURE__ */ new Map();
11319
+ const addTag = (name, tag) => {
11320
+ if (!configuredIn.has(name)) configuredIn.set(name, /* @__PURE__ */ new Set());
11321
+ configuredIn.get(name).add(tag);
11322
+ };
11323
+ const claudeJson = readJsonFile(join(homeDir, ".claude.json"));
11324
+ if (claudeJson) {
11325
+ for (const [name, def] of Object.entries(claudeJson.mcpServers ?? {})) {
11326
+ addTag(name, "global");
11327
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11328
+ }
11329
+ const projectEntry = claudeJson.projects?.[resolve(cwd)];
11330
+ if (projectEntry) {
11331
+ for (const [name, def] of Object.entries(projectEntry.mcpServers ?? {})) {
11332
+ addTag(name, "project");
11333
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11334
+ }
11335
+ for (const name of projectEntry.disabledMcpServers ?? []) {
11336
+ disabled.add(name);
11337
+ if (!configuredIn.has(name)) configuredIn.set(name, /* @__PURE__ */ new Set());
11338
+ }
11339
+ }
11340
+ }
11341
+ const mcpJsonPath = join(cwd, ".mcp.json");
11342
+ if (existsSync(mcpJsonPath)) {
11343
+ const mcpJson = readJsonFile(mcpJsonPath);
11344
+ if (mcpJson) for (const [name, def] of Object.entries(mcpJson.mcpServers ?? {})) {
11345
+ addTag(name, "project .mcp.json");
11346
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11347
+ }
11348
+ }
11349
+ return {
11350
+ configuredIn,
11351
+ disabled,
11352
+ serverDefs
11353
+ };
11354
+ }
11355
+ function gatherLiveProcesses(homeDir) {
11356
+ const procs = [];
11357
+ for (const pid of findClaudePids()) {
11358
+ const line = readProcessEnvLine(pid);
11359
+ if (!line) continue;
11360
+ const mcpConfigArg = extractMcpConfigArg(line);
11361
+ if (!mcpConfigArg) {
11362
+ procs.push({
11363
+ pid,
11364
+ mcpConfigPath: null,
11365
+ servers: null
11366
+ });
11367
+ continue;
11368
+ }
11369
+ const resolved = resolveHomePath(mcpConfigArg, homeDir);
11370
+ const parsed = readJsonFile(resolved);
11371
+ const servers = parsed ? Object.keys(parsed.mcpServers ?? {}) : [];
11372
+ procs.push({
11373
+ pid,
11374
+ mcpConfigPath: resolved,
11375
+ servers
11376
+ });
11377
+ }
11378
+ return procs;
11379
+ }
11380
+ /** Recursively find every file under `dir` ending in `.jsonl`. */
11381
+ function findJsonlFiles(dir) {
11382
+ let entries;
11383
+ try {
11384
+ entries = readdirSync(dir, { withFileTypes: true });
11385
+ } catch {
11386
+ return [];
11387
+ }
11388
+ const found = [];
11389
+ for (const entry of entries) {
11390
+ const full = join(dir, entry.name);
11391
+ if (entry.isDirectory()) found.push(...findJsonlFiles(full));
11392
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(full);
11393
+ }
11394
+ return found;
11395
+ }
11396
+ function foldToolUseBlock(block, usage) {
11397
+ if (block.type !== "tool_use") return;
11398
+ const name = block.name;
11399
+ if (!name || !name.startsWith("mcp__")) return;
11400
+ const parts = name.split("__");
11401
+ const server = parts[1];
11402
+ const tool = parts.slice(2).join("__");
11403
+ if (!server) return;
11404
+ if (!usage.has(server)) usage.set(server, {
11405
+ tools: /* @__PURE__ */ new Set(),
11406
+ calls: 0
11407
+ });
11408
+ const entry = usage.get(server);
11409
+ entry.tools.add(tool);
11410
+ entry.calls += 1;
11411
+ }
11412
+ async function gatherUsage(transcriptsDir, now) {
11413
+ const usage = /* @__PURE__ */ new Map();
11414
+ for (const file of findJsonlFiles(transcriptsDir)) {
11415
+ let stat;
11416
+ try {
11417
+ stat = statSync(file);
11418
+ } catch {
11419
+ continue;
11420
+ }
11421
+ if (!shouldScanTranscript(stat, now)) continue;
11422
+ const rl = createInterface({
11423
+ input: createReadStream(file, "utf8"),
11424
+ crlfDelay: Infinity
11425
+ });
11426
+ for await (const raw of rl) {
11427
+ if (!raw.includes("\"mcp__")) continue;
11428
+ let parsed;
11429
+ try {
11430
+ parsed = JSON.parse(raw);
11431
+ } catch {
11432
+ continue;
11433
+ }
11434
+ if (parsed.type === "tool_use") foldToolUseBlock(parsed, usage);
11435
+ if (Array.isArray(parsed.message?.content)) for (const block of parsed.message.content) foldToolUseBlock(block, usage);
11436
+ }
11437
+ }
11438
+ const result = /* @__PURE__ */ new Map();
11439
+ for (const [server, entry] of usage) result.set(server, {
11440
+ distinctTools: entry.tools.size,
11441
+ calls: entry.calls
11442
+ });
11443
+ return result;
11444
+ }
11445
+ function isStdioDef(def) {
11446
+ return typeof def === "object" && def !== null && typeof def.command === "string";
11447
+ }
11448
+ /** Connect to every stdio MCP server row and record how many tools it exposes. Side-effecting: only called when `opts.connect === true`. */
11449
+ async function connectAndCountTools(rows, serverDefs) {
11450
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
11451
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
11452
+ for (const row of rows) {
11453
+ const def = serverDefs.get(row.server);
11454
+ if (!isStdioDef(def)) continue;
11455
+ const transport = new StdioClientTransport({
11456
+ command: def.command,
11457
+ args: def.args ?? [],
11458
+ env: def.env
11459
+ });
11460
+ const client = new Client({
11461
+ name: "pai-audit-mcp",
11462
+ version: "1.0.0"
11463
+ }, { capabilities: {} });
11464
+ let settled = false;
11465
+ const timeout = new Promise((res) => {
11466
+ setTimeout(() => {
11467
+ if (!settled) res("UNKNOWN");
11468
+ }, 1e4);
11469
+ });
11470
+ try {
11471
+ const attempt = (async () => {
11472
+ await client.connect(transport);
11473
+ return (await client.listTools()).tools.length;
11474
+ })();
11475
+ const outcome = await Promise.race([attempt, timeout]);
11476
+ settled = true;
11477
+ row.toolsExposed = outcome;
11478
+ } catch {
11479
+ settled = true;
11480
+ row.toolsExposed = "UNKNOWN";
11481
+ } finally {
11482
+ try {
11483
+ await client.close();
11484
+ } catch {}
11485
+ }
11486
+ }
11487
+ }
11488
+ async function auditMcp(opts) {
11489
+ const cwd = opts.cwd;
11490
+ const homeDir = opts.homeDir;
11491
+ const transcriptsDir = opts.transcriptsDir ?? join(homeDir, ".claude", "projects");
11492
+ const { configuredIn, disabled, serverDefs } = gatherConfiguredSources(cwd, homeDir);
11493
+ const liveProcesses = gatherLiveProcesses(homeDir);
11494
+ const usage = await gatherUsage(transcriptsDir, Date.now());
11495
+ const pinned = opts.projectsDbPath ? projectLaunchConfig(resolve(cwd), opts.projectsDbPath)?.mcp ?? null : projectLaunchConfig(resolve(cwd))?.mcp ?? null;
11496
+ const allNames = new Set([...configuredIn.keys(), ...usage.keys()]);
11497
+ const allConfiguredNames = [...configuredIn.keys()];
11498
+ const rows = [];
11499
+ for (const name of allNames) {
11500
+ const liveMatches = liveProcesses.filter((p) => liveProcessLoadsServer(p, name, allConfiguredNames));
11501
+ const serverUsage = usage.get(name);
11502
+ rows.push({
11503
+ server: name,
11504
+ configuredIn: [...configuredIn.get(name) ?? /* @__PURE__ */ new Set()],
11505
+ disabled: disabled.has(name),
11506
+ pinned: pinned === null ? null : pinned.includes(name) ? "yes" : "-",
11507
+ loadedLive: liveMatches.length ? liveMatches.map((p) => `pid ${p.pid}`).join(", ") : "-",
11508
+ toolsExposed: "UNKNOWN",
11509
+ toolsUsed30d: serverUsage?.distinctTools ?? 0,
11510
+ calls30d: serverUsage?.calls ?? 0
11511
+ });
11512
+ }
11513
+ rows.sort((a, b) => a.server.localeCompare(b.server));
11514
+ if (opts.connect === true) await connectAndCountTools(rows, serverDefs);
11515
+ return {
11516
+ servers: rows,
11517
+ liveProcesses
11518
+ };
11519
+ }
11520
+
11125
11521
  //#endregion
11126
11522
  //#region src/cli/commands/audit.ts
11127
11523
  function severityColor(sev, text) {
@@ -11190,9 +11586,12 @@ function printSession(report) {
11190
11586
  console.log(`context on last turn: ${report.lastTurnContext ?? "n/a"}`);
11191
11587
  console.log(`cache_creation split: ephemeral_5m=${report.cacheCreationSplit.ephemeral5m}, ephemeral_1h=${report.cacheCreationSplit.ephemeral1h}`);
11192
11588
  console.log(dim$1(`models: ${JSON.stringify(report.models)}`));
11589
+ console.log(`avg context: ${report.avgContext ?? "n/a"}, max context: ${report.maxContext ?? "n/a"}, turns above threshold: ${report.turnsAboveThreshold}, cache-rebuild turns: ${report.cacheRebuildTurns}, user prompts: ${report.userPrompts}`);
11590
+ console.log(`compactions: ${report.compactions.length}`);
11591
+ for (const c of report.compactions) console.log(dim$1(` ${c.trigger} at turn ${c.turnIndex}: preTokens=${c.preTokens}`));
11193
11592
  }
11194
11593
  async function cmdSession(path, opts) {
11195
- const report = await auditSession(path);
11594
+ const report = await auditSession(path, opts.ctxThreshold ? parseInt(opts.ctxThreshold, 10) : void 0);
11196
11595
  if (!report) {
11197
11596
  console.error(err(" No session transcript found."));
11198
11597
  process.exitCode = 1;
@@ -11434,27 +11833,90 @@ async function cmdLadder(opts) {
11434
11833
  if (opts.json) return printJson(report);
11435
11834
  printLadder(report);
11436
11835
  }
11437
- async function cmdCombined(opts) {
11836
+ function printSubagents(report) {
11837
+ console.log(header(`Subagent definitions (encoding: ${report.encoding})`));
11838
+ if (report.entries.length === 0) {
11839
+ console.log(dim$1("no agent files"));
11840
+ return;
11841
+ }
11842
+ const rows = report.entries.map((e) => [
11843
+ String(e.tokens),
11844
+ e.model === "inherits" ? dim$1("inherits") : e.model,
11845
+ e.path
11846
+ ]);
11847
+ console.log(renderTable([
11848
+ "tokens",
11849
+ "model",
11850
+ "path"
11851
+ ], rows));
11852
+ const inheriting = report.entries.filter((e) => e.model === "inherits").length;
11853
+ console.log(dim$1(`${inheriting} of ${report.entries.length} inherit the caller's model`));
11854
+ }
11855
+ function cmdSubagents(opts) {
11856
+ const report = auditSubagents(homedir(), process.cwd());
11857
+ if (opts.json) return printJson(report);
11858
+ printSubagents(report);
11859
+ }
11860
+ function printMcp(report) {
11861
+ console.log(header("MCP servers: configured vs. loaded vs. used"));
11862
+ const rows = report.servers.map((s) => [
11863
+ s.server + (s.disabled ? dim$1(" (disabled)") : ""),
11864
+ s.configuredIn.join(", ") || dim$1("-"),
11865
+ s.pinned ?? dim$1("all"),
11866
+ s.loadedLive,
11867
+ String(s.toolsExposed),
11868
+ String(s.toolsUsed30d),
11869
+ String(s.calls30d)
11870
+ ]);
11871
+ console.log(renderTable([
11872
+ "server",
11873
+ "configured-in",
11874
+ "pinned",
11875
+ "loaded-live",
11876
+ "tools-exposed",
11877
+ "tools-used-30d",
11878
+ "calls-30d"
11879
+ ], rows));
11880
+ console.log(dim$1(`live claude processes: ${report.liveProcesses.length}`));
11881
+ }
11882
+ async function cmdMcp(opts) {
11883
+ const report = await auditMcp({
11884
+ cwd: process.cwd(),
11885
+ homeDir: homedir(),
11886
+ connect: opts.connect
11887
+ });
11888
+ if (opts.json) return printJson(report);
11889
+ printMcp(report);
11890
+ }
11891
+ const DEFAULT_CTX_THRESHOLD = 2e5;
11892
+ async function gatherCombined() {
11438
11893
  const cwd = process.cwd();
11439
11894
  const files = auditFiles(defaultFileSet(cwd));
11440
11895
  const hooks = auditHooks(cwd);
11441
- const session = await auditSession(newestSessionLog() ?? void 0).catch(() => null);
11896
+ const session = await auditSession(newestSessionLog() ?? void 0, DEFAULT_CTX_THRESHOLD).catch(() => null);
11442
11897
  const daemon = auditDaemon();
11443
11898
  const env = auditEnv();
11444
11899
  const spawn = await auditSpawn();
11445
11900
  const schedule = auditSchedule(cacheTtlSeconds(session?.cacheCreationSplit));
11446
11901
  const skills = auditSkills();
11447
- const findings = buildFindings({
11448
- files,
11449
- hooks,
11450
- session,
11451
- daemon,
11452
- env,
11453
- skills
11902
+ const subagents = auditSubagents(homedir(), cwd);
11903
+ const mcp = await auditMcp({
11904
+ cwd,
11905
+ homeDir: homedir()
11454
11906
  });
11455
- if (opts.json) return printJson({
11907
+ return {
11456
11908
  encoding: TOKEN_ENCODING,
11457
- findings,
11909
+ findings: buildFindings({
11910
+ files,
11911
+ hooks,
11912
+ session,
11913
+ daemon,
11914
+ env,
11915
+ skills,
11916
+ subagents,
11917
+ mcp,
11918
+ ctxThreshold: DEFAULT_CTX_THRESHOLD
11919
+ }),
11458
11920
  files,
11459
11921
  hooks,
11460
11922
  session,
@@ -11462,20 +11924,138 @@ async function cmdCombined(opts) {
11462
11924
  daemon,
11463
11925
  env,
11464
11926
  schedule,
11465
- skills
11466
- });
11467
- console.log(header("pai audit tokens — combined report"));
11468
- console.log(dim$1(`token encoding: ${TOKEN_ENCODING}`));
11469
- const rows = findings.map((f) => [
11470
- severityColor(f.severity, f.finding),
11471
- severityColor(f.severity, f.severity),
11472
- f.evidence
11473
- ]);
11474
- console.log(renderTable([
11475
- "FINDING",
11476
- "SEVERITY",
11477
- "EVIDENCE"
11478
- ], rows));
11927
+ skills,
11928
+ subagents,
11929
+ mcp
11930
+ };
11931
+ }
11932
+ /** Strip ANSI escape sequences (chalk colour codes) from captured console output. */
11933
+ function stripAnsi$1(text) {
11934
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
11935
+ }
11936
+ /** Run `fn`, capturing everything it sends through `console.log` as plain text. */
11937
+ function captureOutput(fn) {
11938
+ const original = console.log;
11939
+ const lines = [];
11940
+ console.log = (...args) => {
11941
+ lines.push(stripAnsi$1(args.map((a) => String(a)).join(" ")));
11942
+ };
11943
+ try {
11944
+ fn();
11945
+ } finally {
11946
+ console.log = original;
11947
+ }
11948
+ return lines.join("\n");
11949
+ }
11950
+ function combinedSectionOutputs(data) {
11951
+ const sections = [];
11952
+ sections.push({
11953
+ name: "files",
11954
+ text: captureOutput(() => printFiles(data.files))
11955
+ });
11956
+ sections.push({
11957
+ name: "hooks",
11958
+ text: captureOutput(() => printHooks(data.hooks))
11959
+ });
11960
+ if (data.session) sections.push({
11961
+ name: "session",
11962
+ text: captureOutput(() => printSession(data.session))
11963
+ });
11964
+ sections.push({
11965
+ name: "spawn",
11966
+ text: captureOutput(() => printSpawn(data.spawn))
11967
+ });
11968
+ sections.push({
11969
+ name: "daemon",
11970
+ text: captureOutput(() => printDaemon(data.daemon))
11971
+ });
11972
+ sections.push({
11973
+ name: "env",
11974
+ text: captureOutput(() => printEnv(data.env))
11975
+ });
11976
+ sections.push({
11977
+ name: "schedule",
11978
+ text: captureOutput(() => printSchedule(data.schedule))
11979
+ });
11980
+ sections.push({
11981
+ name: "skills",
11982
+ text: captureOutput(() => printSkills(data.skills, 15))
11983
+ });
11984
+ sections.push({
11985
+ name: "subagents",
11986
+ text: captureOutput(() => printSubagents(data.subagents))
11987
+ });
11988
+ sections.push({
11989
+ name: "mcp",
11990
+ text: captureOutput(() => printMcp(data.mcp))
11991
+ });
11992
+ return sections;
11993
+ }
11994
+ /**
11995
+ * Replace the home directory with `~` in both its plain form and the encoded
11996
+ * form Claude Code uses for transcript directories (`/Users/name/x` →
11997
+ * `-Users-name-x`), so a recorded run carries no account name.
11998
+ */
11999
+ function redactHome(text, home = homedir()) {
12000
+ const encoded = home.replace(/[\/\\]/g, "-");
12001
+ return text.split(home).join("~").split(encoded).join("~");
12002
+ }
12003
+ /** `--record <dir>`: write a dated, numbered markdown+JSON snapshot of the combined report, plus one summary line in `runs.md`. */
12004
+ function recordCombinedReport(dir, data) {
12005
+ mkdirSync(dir, { recursive: true });
12006
+ const existingNumbers = existsSync(dir) ? readdirSync(dir).map((f) => /-run(\d+)\.md$/.exec(f)?.[1]).filter((n) => n !== void 0).map(Number) : [];
12007
+ const runNumber = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1;
12008
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
12009
+ const baseName = `${date}-run${runNumber}`;
12010
+ const findingsTable = [
12011
+ "| FINDING | SEVERITY | EVIDENCE |",
12012
+ "|---------|----------|----------|",
12013
+ ...data.findings.map((f) => `| ${f.finding} | ${f.severity} | ${f.evidence} |`)
12014
+ ].join("\n");
12015
+ const sectionBlocks = combinedSectionOutputs(data).map((s) => `### ${s.name}\n\n\`\`\`\n${s.text}\n\`\`\`\n`).join("\n");
12016
+ const md = [
12017
+ "# pai audit tokens — recorded run",
12018
+ "",
12019
+ `date: ${date}`,
12020
+ `encoding: ${data.encoding}`,
12021
+ "",
12022
+ findingsTable,
12023
+ "",
12024
+ sectionBlocks
12025
+ ].join("\n");
12026
+ const json = JSON.stringify(data, null, 2);
12027
+ const mdRedacted = redactHome(md);
12028
+ const jsonRedacted = redactHome(json);
12029
+ writeFileSync(join(dir, `${baseName}.md`), mdRedacted, "utf8");
12030
+ writeFileSync(join(dir, `${baseName}.json`), jsonRedacted, "utf8");
12031
+ const runsPath = join(dir, "runs.md");
12032
+ if (!existsSync(runsPath)) writeFileSync(runsPath, "# Audit token-waste runs\n\n", "utf8");
12033
+ const counts = {
12034
+ RED: 0,
12035
+ AMBER: 0,
12036
+ GREEN: 0
12037
+ };
12038
+ for (const f of data.findings) counts[f.severity]++;
12039
+ appendFileSync(runsPath, `- ${date} run${runNumber}: ${counts.RED} RED, ${counts.AMBER} AMBER, ${counts.GREEN} GREEN — ${baseName}.md\n`, "utf8");
12040
+ }
12041
+ async function cmdCombined(opts) {
12042
+ const data = await gatherCombined();
12043
+ if (opts.json) printJson(data);
12044
+ else {
12045
+ console.log(header("pai audit tokens — combined report"));
12046
+ console.log(dim$1(`token encoding: ${data.encoding}`));
12047
+ const rows = data.findings.map((f) => [
12048
+ severityColor(f.severity, f.finding),
12049
+ severityColor(f.severity, f.severity),
12050
+ f.evidence
12051
+ ]);
12052
+ console.log(renderTable([
12053
+ "FINDING",
12054
+ "SEVERITY",
12055
+ "EVIDENCE"
12056
+ ], rows));
12057
+ }
12058
+ if (opts.record) recordCombinedReport(opts.record, data);
11479
12059
  }
11480
12060
  /**
11481
12061
  * `--json` is declared once, on `tokensCmd` itself, and read back from there
@@ -11486,7 +12066,7 @@ async function cmdCombined(opts) {
11486
12066
  * `--json` on `pai audit tokens files` silently produced the table output.
11487
12067
  */
11488
12068
  function registerAuditCommands(auditCmd) {
11489
- const tokensCmd = auditCmd.command("tokens").description("Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule, skill catalogue").option("--json", "Print JSON instead of a table").action(async () => {
12069
+ const tokensCmd = auditCmd.command("tokens").description("Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule, skill catalogue, subagents, MCP").option("--json", "Print JSON instead of a table").option("--record <dir>", "Write a dated, numbered markdown+JSON snapshot of the combined report to <dir>").action(async () => {
11490
12070
  await cmdCombined(jsonOpt());
11491
12071
  });
11492
12072
  const jsonOpt = () => tokensCmd.opts();
@@ -11494,8 +12074,11 @@ function registerAuditCommands(auditCmd) {
11494
12074
  await cmdFiles(paths, jsonOpt());
11495
12075
  });
11496
12076
  tokensCmd.command("hooks").description("Token cost of every SessionStart / UserPromptSubmit hook").action(() => cmdHooks(jsonOpt()));
11497
- tokensCmd.command("session").description("Cache/input/output token split for a session transcript (default: newest)").argument("[path]", "Session JSONL path (default: newest under ~/.claude/projects)").action(async (path) => {
11498
- await cmdSession(path, jsonOpt());
12077
+ tokensCmd.command("session").description("Cache/input/output token split for a session transcript (default: newest)").argument("[path]", "Session JSONL path (default: newest under ~/.claude/projects)").option("--ctx-threshold <n>", "Per-turn context size above which a turn counts as 'above threshold' (default 200000)").action(async (path, opts) => {
12078
+ await cmdSession(path, {
12079
+ ...jsonOpt(),
12080
+ ...opts
12081
+ });
11499
12082
  });
11500
12083
  tokensCmd.command("spawn").description("Spawn-overhead comparison: Agent-tool subagents vs. pai workers").option("-n, --n <count>", "How many of the newest logs per group to read (default 10)").option("--detail", "Print one row per log (first-turn ctx, prompt tokens, overhead, model, path)").action(async (opts) => {
11501
12084
  await cmdSpawn({
@@ -11514,6 +12097,13 @@ function registerAuditCommands(auditCmd) {
11514
12097
  ...opts
11515
12098
  });
11516
12099
  });
12100
+ tokensCmd.command("subagents").description("Token cost of Claude Code subagent definitions (~/.claude/agents, <cwd>/.claude/agents) and their model pinning").action(() => cmdSubagents(jsonOpt()));
12101
+ tokensCmd.command("mcp").description("MCP servers: configured vs. pinned vs. loaded-live vs. used in the last 30 days").option("--connect", "Actually connect to every stdio MCP server to count exposed tools (side-effecting; never on by default)").action(async (opts) => {
12102
+ await cmdMcp({
12103
+ ...jsonOpt(),
12104
+ ...opts
12105
+ });
12106
+ });
11517
12107
  tokensCmd.command("ladder").description("LIVE: first-turn context at increasing headless-worker configuration (spawns real claude -p calls)").option("--live", "Actually spawn claude -p (billed API traffic); refuses without this flag").option("--model <model>", "Model to probe with (default haiku)").option("--mcp-config <path>", "MCP config for the L2 rung (default: newest *.mcp.json in the workers log dir)").action(async (opts) => {
11518
12108
  await cmdLadder({
11519
12109
  ...jsonOpt(),
@@ -19354,4 +19944,4 @@ claude() {
19354
19944
 
19355
19945
  //#endregion
19356
19946
  export { drainStdio as n, buildProgram as t };
19357
- //# sourceMappingURL=program-C5jRQ1kg.mjs.map
19947
+ //# sourceMappingURL=program-DvuMelhj.mjs.map