@tekmidian/pai 0.42.6 → 0.44.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 (50) hide show
  1. package/dist/{chain-BbcfP77K.mjs → chain-DAEC9AU7.mjs} +2 -2
  2. package/dist/{chain-BbcfP77K.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-BNjbVsQU.mjs → daemon-BVbvL8IA.mjs} +4 -4
  7. package/dist/{daemon-BppziPi6.mjs → daemon-ebLDoKM3.mjs} +6 -6
  8. package/dist/{daemon-BppziPi6.mjs.map → daemon-ebLDoKM3.mjs.map} +1 -1
  9. package/dist/daemon-mcp/index.mjs +3 -3
  10. package/dist/{fallback-Cwt4hmh5.mjs → fallback-CxGDTXWt.mjs} +6 -4
  11. package/dist/fallback-CxGDTXWt.mjs.map +1 -0
  12. package/dist/hooks/load-project-context.mjs +51 -48
  13. package/dist/hooks/load-project-context.mjs.map +4 -4
  14. package/dist/hooks/mcp-deferred-reminder.mjs.map +1 -1
  15. package/dist/hooks/whisper-reinject.mjs +8 -0
  16. package/dist/hooks/whisper-reinject.mjs.map +3 -3
  17. package/dist/hooks/worker-status-line.mjs +108 -41
  18. package/dist/hooks/worker-status-line.mjs.map +3 -3
  19. package/dist/{planner-pXRc-8yt.mjs → planner-B4DvNpXd.mjs} +3 -3
  20. package/dist/{planner-pXRc-8yt.mjs.map → planner-B4DvNpXd.mjs.map} +1 -1
  21. package/dist/{program-LLNPpxlj.mjs → program-cr5U1-DK.mjs} +1347 -65
  22. package/dist/program-cr5U1-DK.mjs.map +1 -0
  23. package/dist/{run-DDdT9teG.mjs → run-BgExF8Xg.mjs} +485 -60
  24. package/dist/run-BgExF8Xg.mjs.map +1 -0
  25. package/dist/{tools-BradfQk7.mjs → tools-CAG2sUjf.mjs} +2 -2
  26. package/dist/{tools-UrSHIe29.mjs → tools-DiRC5glA.mjs} +2 -2
  27. package/dist/{tools-UrSHIe29.mjs.map → tools-DiRC5glA.mjs.map} +1 -1
  28. package/dist/{wakeup-TnGjJY3I.mjs → wakeup-BfElduEU.mjs} +21 -8
  29. package/dist/wakeup-BfElduEU.mjs.map +1 -0
  30. package/docs/commands/README.md +5 -1
  31. package/docs/commands/audit.md +50 -2
  32. package/docs/commands/worker.md +2 -0
  33. package/package.json +1 -1
  34. package/scripts/build-hooks.mjs +2 -0
  35. package/src/hooks/ts/lib/context-fill.test.ts +40 -0
  36. package/src/hooks/ts/post-tool-use/whisper-reinject.test.ts +55 -0
  37. package/src/hooks/ts/post-tool-use/whisper-reinject.ts +3 -0
  38. package/src/hooks/ts/session-start/load-project-context.ts +3 -3
  39. package/src/hooks/ts/session-start/mcp-deferred-reminder.ts +2 -2
  40. package/src/hooks/ts/session-start/session-start-worker-guard.test.ts +0 -1
  41. package/src/hooks/ztk-auto.sh +28 -0
  42. package/src/hooks/ztk-auto.test.ts +72 -0
  43. package/src/hooks/ztk-passthrough.mjs +71 -0
  44. package/dist/fallback-Cwt4hmh5.mjs.map +0 -1
  45. package/dist/hooks/session-commands.mjs +0 -26
  46. package/dist/hooks/session-commands.mjs.map +0 -7
  47. package/dist/program-LLNPpxlj.mjs.map +0 -1
  48. package/dist/run-DDdT9teG.mjs.map +0 -1
  49. package/dist/wakeup-TnGjJY3I.mjs.map +0 -1
  50. package/src/hooks/ts/session-start/session-commands.ts +0 -30
@@ -12,16 +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 { D as isChatPane, F as longInlinePromptHint, I as parseRunnerArgs, M as saveStatus, N as setWorkerLabel, O as loadStatus, P as waitForTerminalStatus, R as describeMcp, S as UNLABELED, b as appendLedger, c as handoffFromInside, f as checkPaneForWorker, i as parseWorkerReport, k as loadStatuses, m as openPaneForWorker, n as runWorker, o as discardWorker, p as openFollowPane, r as testProvider, s as mergeWorker, u as sayToWorker, z as expandMcpNames } from "./run-DDdT9teG.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
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";
19
20
  import { n as queriesDirPath, t as migrateQueriesDir } from "./query-feedback-CYyOGi3y.mjs";
20
21
  import { s as kgQuery } from "./kg-entity-BSjM5xZh.mjs";
21
- import { _ as workersLogDir, i as buildRunEnv, m as ledgerPath, n as ensureProxyRunning, p as eventsPath, r as stopProxy, t as DEFAULT_PROXY_PORT } from "./server-Ch99xt5K.mjs";
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";
22
23
  import { i as sendToSession, n as fetchLiveSessions, r as revealItermSession, t as callAiBroker } from "./aibroker-client-Bn9SnGMa.mjs";
23
- import { t as runChain } from "./chain-BbcfP77K.mjs";
24
- 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-Cwt4hmh5.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";
25
26
  import { appendFileSync, chmodSync, copyFileSync, cpSync, createReadStream, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, realpathSync, renameSync, statSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
26
27
  import { homedir, platform, tmpdir } from "node:os";
27
28
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
@@ -1281,7 +1282,7 @@ function cmdConfig(db, identifier, opts) {
1281
1282
  * configured server loads, today's behavior. Names are validated against
1282
1283
  * ~/.claude.json's mcpServers / workers.mcpSets the same way `--mcp` is.
1283
1284
  */
1284
- function cmdMcp(db, names, opts = {}) {
1285
+ function cmdMcp$1(db, names, opts = {}) {
1285
1286
  const project = currentProject(db, opts.cwd);
1286
1287
  if (!project) {
1287
1288
  console.error(err("No registered project matches the current directory."));
@@ -2076,7 +2077,7 @@ function registerProjectsCommands(projectsCmd, getDb) {
2076
2077
  cmdConfig(getDb(), identifier, opts);
2077
2078
  });
2078
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) => {
2079
- cmdMcp(getDb(), names, opts);
2080
+ cmdMcp$1(getDb(), names, opts);
2080
2081
  });
2081
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) => {
2082
2083
  cmdTools(getDb(), names, opts);
@@ -5905,7 +5906,7 @@ function cmdLogs(opts) {
5905
5906
  }
5906
5907
  function registerDaemonCommands(daemonCmd) {
5907
5908
  daemonCmd.command("serve").description("Start the PAI daemon in the foreground").action(async () => {
5908
- const { serve } = await import("./daemon-BNjbVsQU.mjs");
5909
+ const { serve } = await import("./daemon-BVbvL8IA.mjs");
5909
5910
  const { loadConfig: lc, ensureConfigDir } = await import("./config-DeqFqa1N.mjs");
5910
5911
  ensureConfigDir();
5911
5912
  await serve(lc());
@@ -7172,10 +7173,6 @@ async function stepSettings(rl, daName) {
7172
7173
  DA: daName
7173
7174
  },
7174
7175
  hooks: [
7175
- {
7176
- hookType: "SessionStart",
7177
- command: "${ADAPTER_DIR}/Hooks/session-commands.mjs"
7178
- },
7179
7176
  {
7180
7177
  hookType: "SessionStart",
7181
7178
  command: "${ADAPTER_DIR}/Hooks/load-core-context.mjs"
@@ -9972,15 +9969,17 @@ function emptyTotals() {
9972
9969
  * Fold one already-parsed JSONL line into a report being accumulated.
9973
9970
  * Exported separately so both the streaming file reader below and tests
9974
9971
  * (which build fixtures as arrays of objects, not files) share one path.
9972
+ * Returns the turn's context value when a new turn was counted, else null
9973
+ * (non-assistant line, no usage, or a duplicate message.id already seen).
9975
9974
  */
9976
- function foldAssistantLine(report, seenIds, line) {
9977
- if (line.type !== "assistant") return;
9975
+ function foldAssistantLine(report, seenIds, line, threshold) {
9976
+ if (line.type !== "assistant") return null;
9978
9977
  const message = line.message;
9979
9978
  const usage = message?.usage;
9980
- if (!usage) return;
9979
+ if (!usage) return null;
9981
9980
  const id = message?.id ?? line.uuid;
9982
9981
  if (id) {
9983
- if (seenIds.has(id)) return;
9982
+ if (seenIds.has(id)) return null;
9984
9983
  seenIds.add(id);
9985
9984
  }
9986
9985
  report.turns++;
@@ -9991,6 +9990,9 @@ function foldAssistantLine(report, seenIds, line) {
9991
9990
  const context = (Number(usage.cache_read_input_tokens) || 0) + (Number(usage.cache_creation_input_tokens) || 0) + (Number(usage.input_tokens) || 0);
9992
9991
  if (report.firstTurnContext === null) report.firstTurnContext = context;
9993
9992
  report.lastTurnContext = context;
9993
+ report.maxContext = report.maxContext === null ? context : Math.max(report.maxContext, context);
9994
+ if (context > threshold) report.turnsAboveThreshold++;
9995
+ if ((Number(usage.cache_creation_input_tokens) || 0) > 2e4) report.cacheRebuildTurns++;
9994
9996
  const model = message?.model ?? "unknown";
9995
9997
  report.models[model] = (report.models[model] ?? 0) + 1;
9996
9998
  const split = usage.cache_creation;
@@ -9998,9 +10000,23 @@ function foldAssistantLine(report, seenIds, line) {
9998
10000
  report.cacheCreationSplit.ephemeral5m += Number(split.ephemeral_5m_input_tokens) || 0;
9999
10001
  report.cacheCreationSplit.ephemeral1h += Number(split.ephemeral_1h_input_tokens) || 0;
10000
10002
  }
10003
+ return context;
10004
+ }
10005
+ /**
10006
+ * True for a real human-authored `type:"user"` prompt line: content is a
10007
+ * plain string, or a content array with no `tool_result` block. Claude Code
10008
+ * encodes tool results as `type:"user"` messages whose content array is
10009
+ * entirely (or partly) tool_result blocks — those must not count as prompts.
10010
+ */
10011
+ function isRealUserPrompt(line) {
10012
+ if (line.type !== "user") return false;
10013
+ const content = line.message?.content;
10014
+ if (typeof content === "string") return true;
10015
+ if (Array.isArray(content)) return !content.some((block) => block?.type === "tool_result");
10016
+ return false;
10001
10017
  }
10002
10018
  /** Parse a session/subagent/worker-event JSONL file into a usage report. */
10003
- async function parseSessionUsage(path) {
10019
+ async function parseSessionUsage(path, threshold = 2e5) {
10004
10020
  const report = {
10005
10021
  path,
10006
10022
  sizeBytes: existsSync(path) ? readFileSync(path).byteLength : 0,
@@ -10012,9 +10028,15 @@ async function parseSessionUsage(path) {
10012
10028
  cacheCreationSplit: {
10013
10029
  ephemeral5m: 0,
10014
10030
  ephemeral1h: 0
10015
- }
10031
+ },
10032
+ avgContext: null,
10033
+ maxContext: null,
10034
+ turnsAboveThreshold: 0,
10035
+ cacheRebuildTurns: 0,
10036
+ userPrompts: 0
10016
10037
  };
10017
10038
  const seenIds = /* @__PURE__ */ new Set();
10039
+ let contextSum = 0;
10018
10040
  const rl = createInterface({
10019
10041
  input: createReadStream(path, "utf8"),
10020
10042
  crlfDelay: Infinity
@@ -10027,8 +10049,14 @@ async function parseSessionUsage(path) {
10027
10049
  } catch {
10028
10050
  continue;
10029
10051
  }
10030
- foldAssistantLine(report, seenIds, obj);
10052
+ if (obj.type === "user") {
10053
+ if (isRealUserPrompt(obj)) report.userPrompts++;
10054
+ continue;
10055
+ }
10056
+ const context = foldAssistantLine(report, seenIds, obj, threshold);
10057
+ if (context !== null) contextSum += context;
10031
10058
  }
10059
+ report.avgContext = report.turns > 0 ? Math.round(contextSum / report.turns) : null;
10032
10060
  return report;
10033
10061
  }
10034
10062
  function totalUsageTokens(totals) {
@@ -10078,10 +10106,10 @@ function newestSessionLog() {
10078
10106
  }
10079
10107
  return best?.path ?? null;
10080
10108
  }
10081
- async function auditSession(path) {
10109
+ async function auditSession(path, threshold) {
10082
10110
  const target = path ?? newestSessionLog();
10083
10111
  if (!target) return null;
10084
- const report = await parseSessionUsage(target);
10112
+ const report = await parseSessionUsage(target, threshold);
10085
10113
  const total = totalUsageTokens(report.totals) || 1;
10086
10114
  const percentages = {};
10087
10115
  for (const [key, value] of Object.entries(report.totals)) percentages[key] = 100 * value / total;
@@ -10159,32 +10187,124 @@ function readWorkerStatus(logDir, id) {
10159
10187
  return null;
10160
10188
  }
10161
10189
  }
10162
- async function readOne(path) {
10163
- const report = await parseSessionUsage(path);
10190
+ /** Token count of the first `type:"user"` message's text, or null if none found. */
10191
+ async function firstUserPromptTokens(path) {
10192
+ const rl = createInterface({
10193
+ input: createReadStream(path, "utf8"),
10194
+ crlfDelay: Infinity
10195
+ });
10196
+ try {
10197
+ for await (const raw of rl) {
10198
+ if (!raw.trim()) continue;
10199
+ let obj;
10200
+ try {
10201
+ obj = JSON.parse(raw);
10202
+ } catch {
10203
+ continue;
10204
+ }
10205
+ if (obj.type !== "user") continue;
10206
+ const content = obj.message?.content;
10207
+ return countTokens(typeof content === "string" ? content : Array.isArray(content) ? content.map((part) => part?.text ?? "").join(" ") : "");
10208
+ }
10209
+ } finally {
10210
+ rl.close();
10211
+ }
10212
+ return null;
10213
+ }
10214
+ /** `session_id` off the first record that carries one, or null if none do. */
10215
+ async function firstSessionId(path) {
10216
+ const rl = createInterface({
10217
+ input: createReadStream(path, "utf8"),
10218
+ crlfDelay: Infinity
10219
+ });
10220
+ try {
10221
+ for await (const raw of rl) {
10222
+ if (!raw.trim()) continue;
10223
+ let obj;
10224
+ try {
10225
+ obj = JSON.parse(raw);
10226
+ } catch {
10227
+ continue;
10228
+ }
10229
+ if (obj.session_id) return obj.session_id;
10230
+ }
10231
+ } finally {
10232
+ rl.close();
10233
+ }
10234
+ return null;
10235
+ }
10236
+ /** `~/.claude/projects/**\/<sessionId>.jsonl`, searched only under the projects dir. */
10237
+ function findSessionTranscript(sessionId, projectsDir = join(homedir(), ".claude", "projects")) {
10238
+ const target = `${sessionId}.jsonl`;
10239
+ const stack = [projectsDir];
10240
+ while (stack.length > 0) {
10241
+ const dir = stack.pop();
10242
+ let entries;
10243
+ try {
10244
+ entries = readdirSync(dir, { withFileTypes: true });
10245
+ } catch {
10246
+ continue;
10247
+ }
10248
+ for (const entry of entries) {
10249
+ const full = join(dir, entry.name);
10250
+ if (entry.isDirectory()) stack.push(full);
10251
+ else if (entry.name === target) return full;
10252
+ }
10253
+ }
10254
+ return null;
10255
+ }
10256
+ /**
10257
+ * The mirror log has no `type:"user"` record (worker event mirrors carry
10258
+ * system/assistant/result records only, never the -p prompt itself), so
10259
+ * fall back to the session's own transcript under ~/.claude/projects,
10260
+ * located via whichever record in the mirror log carries a `session_id`.
10261
+ */
10262
+ async function resolvePromptTokens(mirrorPath) {
10263
+ const direct = await firstUserPromptTokens(mirrorPath);
10264
+ if (direct !== null) return direct;
10265
+ const sessionId = await firstSessionId(mirrorPath);
10266
+ if (!sessionId) return null;
10267
+ const transcript = findSessionTranscript(sessionId);
10268
+ if (!transcript) return null;
10269
+ return firstUserPromptTokens(transcript);
10270
+ }
10271
+ async function readOne(path, group) {
10272
+ const [report, promptTokens] = await Promise.all([parseSessionUsage(path), resolvePromptTokens(path)]);
10164
10273
  const model = Object.keys(report.models)[0] ?? null;
10274
+ const overhead = report.firstTurnContext !== null && promptTokens !== null ? report.firstTurnContext - promptTokens : null;
10165
10275
  return {
10166
10276
  path,
10167
10277
  firstTurnContext: report.firstTurnContext,
10168
- model
10278
+ model,
10279
+ promptTokens,
10280
+ overhead,
10281
+ group
10169
10282
  };
10170
10283
  }
10284
+ function median(values) {
10285
+ if (values.length === 0) return null;
10286
+ const sorted = [...values].sort((a, b) => a - b);
10287
+ const mid = Math.floor(sorted.length / 2);
10288
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
10289
+ }
10171
10290
  function summarize(readings) {
10172
10291
  const contexts = readings.map((r) => r.firstTurnContext).filter((v) => v !== null).sort((a, b) => a - b);
10292
+ const overheads = readings.map((r) => r.overhead).filter((v) => v !== null);
10173
10293
  const models = [...new Set(readings.map((r) => r.model).filter((m) => !!m))];
10174
10294
  if (contexts.length === 0) return {
10175
10295
  count: readings.length,
10176
10296
  min: null,
10177
10297
  median: null,
10178
10298
  max: null,
10299
+ medianOverhead: median(overheads),
10179
10300
  models
10180
10301
  };
10181
- const mid = Math.floor(contexts.length / 2);
10182
- const median = contexts.length % 2 ? contexts[mid] : (contexts[mid - 1] + contexts[mid]) / 2;
10183
10302
  return {
10184
10303
  count: readings.length,
10185
10304
  min: contexts[0],
10186
- median,
10305
+ median: median(contexts),
10187
10306
  max: contexts[contexts.length - 1],
10307
+ medianOverhead: median(overheads),
10188
10308
  models
10189
10309
  };
10190
10310
  }
@@ -10192,17 +10312,25 @@ async function auditSpawn(n = 10) {
10192
10312
  const { workers } = readWorkersSection();
10193
10313
  const logDir = workersLogDir(workers);
10194
10314
  const agentLogs = newestAgentSubagentLogs(n);
10195
- const agentReadings = await Promise.all(agentLogs.map(readOne));
10315
+ const agentReadings = await Promise.all(agentLogs.map((path) => readOne(path, "agentSubagents")));
10196
10316
  const workerLogs = newestWorkerLogs(logDir, n);
10197
- const workerReadings = await Promise.all(workerLogs.map(readOne));
10317
+ const workerReadings = await Promise.all(workerLogs.map((path) => readOne(path, "workersInteractive")));
10198
10318
  const interactive = [];
10199
10319
  const headless = [];
10200
- for (const reading of workerReadings) if (readWorkerStatus(logDir, workerIdFromLogPath(reading.path))?.outputFormat) headless.push(reading);
10320
+ for (const reading of workerReadings) if (readWorkerStatus(logDir, workerIdFromLogPath(reading.path))?.outputFormat) headless.push({
10321
+ ...reading,
10322
+ group: "workersHeadless"
10323
+ });
10201
10324
  else interactive.push(reading);
10202
10325
  return {
10203
10326
  agentSubagents: summarize(agentReadings),
10204
10327
  workersInteractive: summarize(interactive),
10205
- workersHeadless: summarize(headless)
10328
+ workersHeadless: summarize(headless),
10329
+ readings: [
10330
+ ...agentReadings,
10331
+ ...interactive,
10332
+ ...headless
10333
+ ]
10206
10334
  };
10207
10335
  }
10208
10336
 
@@ -10332,6 +10460,45 @@ function extract(pattern, text) {
10332
10460
  const match = text.match(pattern);
10333
10461
  return match ? match[1] : null;
10334
10462
  }
10463
+ /**
10464
+ * `--tools A,B` and `--tools=A,B` are both valid on the argv this process was
10465
+ * launched with; an explicit but empty value (`--tools ""` / `--tools=`)
10466
+ * means "no tools at all", distinct from the flag being absent entirely.
10467
+ */
10468
+ function extractToolsArg(line) {
10469
+ const eq = line.match(/--tools=(?:"([^"]*)"|(\S*))/);
10470
+ if (eq) return eq[1] !== void 0 ? eq[1] : eq[2];
10471
+ const sp = line.match(/--tools\s+(?:"([^"]*)"|(\S+))/);
10472
+ if (sp) return sp[1] !== void 0 ? sp[1] : sp[2];
10473
+ return null;
10474
+ }
10475
+ function extractMcpConfigArg(line) {
10476
+ const eq = line.match(/--mcp-config=(?:"([^"]*)"|(\S*))/);
10477
+ if (eq) return eq[1] !== void 0 ? eq[1] : eq[2];
10478
+ const sp = line.match(/--mcp-config\s+(?:"([^"]*)"|(\S+))/);
10479
+ if (sp) return sp[1] !== void 0 ? sp[1] : sp[2];
10480
+ return null;
10481
+ }
10482
+ /**
10483
+ * No `--tools` flag: the CLI default tool set applies, which includes
10484
+ * ToolSearch, so deferral is on. An explicit but empty list means no tools
10485
+ * at all, so deferral doesn't apply. Otherwise deferral is on only if the
10486
+ * explicit list still names ToolSearch.
10487
+ */
10488
+ function classifyDeferral(toolsArg) {
10489
+ if (toolsArg === null) return "on";
10490
+ if (toolsArg.trim() === "") return "n/a";
10491
+ return toolsArg.split(",").map((t) => t.trim()).includes("ToolSearch") ? "on" : "OFF";
10492
+ }
10493
+ function countMcpServersInFile(path) {
10494
+ try {
10495
+ const resolved = path.startsWith("~") ? join(homedir(), path.slice(1)) : path;
10496
+ const parsed = JSON.parse(readFileSync(resolved, "utf8"));
10497
+ return Object.keys(parsed.mcpServers ?? {}).length;
10498
+ } catch {
10499
+ return 0;
10500
+ }
10501
+ }
10335
10502
  function readProcessEnvLine(pid) {
10336
10503
  try {
10337
10504
  return execFileSync("ps", [
@@ -10366,6 +10533,8 @@ function auditEnv() {
10366
10533
  const modelPattern = /ANTHROPIC_DEFAULT_(\w+?)_MODEL=(\S*)/g;
10367
10534
  let modelMatch;
10368
10535
  while (modelMatch = modelPattern.exec(line)) defaultModels[modelMatch[1]] = modelMatch[2];
10536
+ const toolsArg = extractToolsArg(line);
10537
+ const mcpConfigPath = extractMcpConfigArg(line);
10369
10538
  processes.push({
10370
10539
  pid,
10371
10540
  baseUrl: extract(/ANTHROPIC_BASE_URL=(\S*)/, line),
@@ -10373,7 +10542,10 @@ function auditEnv() {
10373
10542
  defaultModels,
10374
10543
  enableToolSearch: extract(/ENABLE_TOOL_SEARCH=(\S*)/, line),
10375
10544
  workerId: extract(/PAI_WORKER_ID=(\S*)/, line),
10376
- age: readProcessAge(pid)
10545
+ age: readProcessAge(pid),
10546
+ toolsArg,
10547
+ mcpServers: mcpConfigPath ? countMcpServersInFile(mcpConfigPath) : "default",
10548
+ deferral: classifyDeferral(toolsArg)
10377
10549
  });
10378
10550
  }
10379
10551
  let mcpServerCount = 0;
@@ -10467,11 +10639,271 @@ function auditSchedule(ttlSeconds = DEFAULT_TTL_SECONDS) {
10467
10639
  };
10468
10640
  }
10469
10641
 
10642
+ //#endregion
10643
+ //#region src/audit/skills.ts
10644
+ /**
10645
+ * `pai audit tokens skills` — token cost of the whole skill/command/plugin
10646
+ * catalogue: every `SKILL.md` under ~/.claude/skills and ~/.claude/plugins,
10647
+ * plus every command under ~/.claude/commands, rendered as the one-line
10648
+ * `- name: description` entry Claude Code loads into context for each.
10649
+ *
10650
+ * Two failure modes matter more than the raw total: the same real file
10651
+ * reachable through two paths (a plugin's marketplaces/ checkout and its
10652
+ * cache/ install both point at one file), and two different real files that
10653
+ * collide on name (a skill and a command both called "Sessions") — the first
10654
+ * wastes nothing but must not be double-counted, the second silently shadows
10655
+ * one of the two in whichever catalogue order Claude Code loads them.
10656
+ */
10657
+ /** Recursively collect every file under `dir` whose basename satisfies `matches`. */
10658
+ function findFilesRecursive(dir, matches) {
10659
+ let entries;
10660
+ try {
10661
+ entries = readdirSync(dir, { withFileTypes: true });
10662
+ } catch {
10663
+ return [];
10664
+ }
10665
+ const found = [];
10666
+ for (const entry of entries) {
10667
+ const full = join(dir, entry.name);
10668
+ if (entry.isDirectory()) found.push(...findFilesRecursive(full, matches));
10669
+ else if (entry.isFile() && matches(entry.name)) found.push(full);
10670
+ }
10671
+ return found;
10672
+ }
10673
+ /**
10674
+ * `<skillsDir>/*\/SKILL.md`, one level deep only — this is what Claude Code
10675
+ * actually loads. `readdirSync(withFileTypes)` reports a symlinked skill
10676
+ * directory as neither file nor directory, so entries are resolved with
10677
+ * `statSync` (which follows symlinks) instead of trusting `Dirent`.
10678
+ */
10679
+ function findTopLevelSkillFiles(skillsDir) {
10680
+ let entries;
10681
+ try {
10682
+ entries = readdirSync(skillsDir);
10683
+ } catch {
10684
+ return [];
10685
+ }
10686
+ const found = [];
10687
+ for (const name of entries) {
10688
+ const dir = join(skillsDir, name);
10689
+ let isDir = false;
10690
+ try {
10691
+ isDir = statSync(dir).isDirectory();
10692
+ } catch {
10693
+ continue;
10694
+ }
10695
+ if (!isDir) continue;
10696
+ const skillFile = join(dir, "SKILL.md");
10697
+ if (existsSync(skillFile)) found.push(skillFile);
10698
+ }
10699
+ return found;
10700
+ }
10701
+ /** Every SKILL.md/*.md the three catalogue roots contribute, source-tagged. */
10702
+ function findCatalogueFiles(claudeDir) {
10703
+ const found = [];
10704
+ for (const path of findTopLevelSkillFiles(join(claudeDir, "skills"))) found.push({
10705
+ path,
10706
+ source: "skills"
10707
+ });
10708
+ for (const path of findFilesRecursive(join(claudeDir, "commands"), (n) => n.endsWith(".md"))) found.push({
10709
+ path,
10710
+ source: "commands"
10711
+ });
10712
+ for (const path of findFilesRecursive(join(claudeDir, "plugins"), (n) => n === "SKILL.md")) found.push({
10713
+ path,
10714
+ source: "plugins"
10715
+ });
10716
+ return found;
10717
+ }
10718
+ /** Strip a single layer of matching quotes ("..." or '...') from a trimmed value. */
10719
+ function unquote$1(value) {
10720
+ if (value.length >= 2) {
10721
+ const first = value[0];
10722
+ const last = value[value.length - 1];
10723
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
10724
+ }
10725
+ return value;
10726
+ }
10727
+ /** Parse the `name:`/`description:` frontmatter fields out of a skill/command file. */
10728
+ function parseFrontmatter(text) {
10729
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
10730
+ const fm = match ? match[1] : "";
10731
+ const nameMatch = /^name:\s*(.+)$/m.exec(fm);
10732
+ const descMatch = /(?:^|\n)description:\s*(.+?)(?=\n\w[\w-]*:|$)/s.exec(fm);
10733
+ return {
10734
+ name: nameMatch ? unquote$1(nameMatch[1].trim()) : null,
10735
+ description: descMatch ? unquote$1(descMatch[1].trim()) : ""
10736
+ };
10737
+ }
10738
+ function nameFallback(path, source) {
10739
+ if (source === "commands") return basename(path).replace(/\.md$/, "");
10740
+ return basename(dirname(path));
10741
+ }
10742
+ /** Build one catalogue entry from a file, deriving its name/description/tokens. */
10743
+ function readEntry$1(path, source) {
10744
+ let text = "";
10745
+ try {
10746
+ text = readFileSync(path, "utf8");
10747
+ } catch {}
10748
+ const fm = parseFrontmatter(text);
10749
+ const name = fm.name ?? nameFallback(path, source);
10750
+ const line = `- ${name}: ${fm.description}`;
10751
+ return {
10752
+ name,
10753
+ description: fm.description,
10754
+ source,
10755
+ path,
10756
+ tokens: countTokens(line),
10757
+ enabled: true
10758
+ };
10759
+ }
10760
+ /** `enabledPlugins` map from settings.json, or `{}` if the file is missing/unreadable. */
10761
+ function readEnabledPlugins(settingsPath) {
10762
+ try {
10763
+ return JSON.parse(readFileSync(settingsPath, "utf8")).enabledPlugins ?? {};
10764
+ } catch {
10765
+ return {};
10766
+ }
10767
+ }
10768
+ /** `<relative-to-root>` split on the platform separator, or null if `path` is not under `root`. */
10769
+ function pathUnder(root, path) {
10770
+ const rel = relative(root, path);
10771
+ if (rel.startsWith("..")) return null;
10772
+ return rel.split(sep);
10773
+ }
10774
+ /**
10775
+ * A cache install is `plugins/cache/<marketplace>/<plugin>/<version>/...`, so its id is exact.
10776
+ * A marketplace checkout is `plugins/marketplaces/<marketplace>/...` with no plugin segment, so
10777
+ * it can only be matched by marketplace name against whichever `enabledPlugins` key ends in
10778
+ * `@<marketplace>` — good enough for the single-plugin-per-marketplace installs this audits today.
10779
+ */
10780
+ function resolvePluginStatus(claudeDir, path, enabledPlugins) {
10781
+ const cacheParts = pathUnder(join(claudeDir, "plugins", "cache"), path);
10782
+ if (cacheParts && cacheParts.length >= 4) {
10783
+ const [marketplace, plugin, , ...suffix] = cacheParts;
10784
+ const id = `${plugin}@${marketplace}`;
10785
+ const known = id in enabledPlugins;
10786
+ return {
10787
+ id,
10788
+ enabled: known && enabledPlugins[id] === true,
10789
+ status: known ? void 0 : "unknown",
10790
+ dedupeKey: `${id}::${suffix.join("/")}`
10791
+ };
10792
+ }
10793
+ const marketplaceParts = pathUnder(join(claudeDir, "plugins", "marketplaces"), path);
10794
+ if (marketplaceParts && marketplaceParts.length >= 2) {
10795
+ const [marketplace, ...suffix] = marketplaceParts;
10796
+ const matchKey = Object.keys(enabledPlugins).find((k) => k.endsWith(`@${marketplace}`));
10797
+ if (matchKey) return {
10798
+ id: matchKey,
10799
+ enabled: enabledPlugins[matchKey] === true,
10800
+ dedupeKey: `${matchKey}::${suffix.join("/")}`
10801
+ };
10802
+ return {
10803
+ id: `unknown@${marketplace}`,
10804
+ enabled: false,
10805
+ status: "unknown",
10806
+ dedupeKey: `unknown@${marketplace}::${suffix.join("/")}`
10807
+ };
10808
+ }
10809
+ return {
10810
+ id: path,
10811
+ enabled: false,
10812
+ status: "unknown",
10813
+ dedupeKey: path
10814
+ };
10815
+ }
10816
+ /** Group entries by case-insensitive name; only groups with >1 real path. */
10817
+ function findDuplicates(entries) {
10818
+ const byName = /* @__PURE__ */ new Map();
10819
+ for (const entry of entries) {
10820
+ const key = entry.name.toLowerCase();
10821
+ if (!byName.has(key)) byName.set(key, /* @__PURE__ */ new Set());
10822
+ byName.get(key).add(entry.path);
10823
+ }
10824
+ const duplicates = [];
10825
+ for (const [name, paths] of byName) if (paths.size > 1) duplicates.push({
10826
+ name,
10827
+ paths: [...paths]
10828
+ });
10829
+ return duplicates.sort((a, b) => a.name.localeCompare(b.name));
10830
+ }
10831
+ function auditSkills(claudeDir = join(homedir(), ".claude"), settingsPath = join(claudeDir, "settings.json")) {
10832
+ const rawFiles = findCatalogueFiles(claudeDir);
10833
+ const seenReal = /* @__PURE__ */ new Set();
10834
+ const deduped = [];
10835
+ for (const file of rawFiles) {
10836
+ let real = file.path;
10837
+ try {
10838
+ real = realpathSync(file.path);
10839
+ } catch {}
10840
+ if (seenReal.has(real)) continue;
10841
+ seenReal.add(real);
10842
+ deduped.push(file);
10843
+ }
10844
+ const enabledPlugins = readEnabledPlugins(settingsPath);
10845
+ const entries = deduped.map((f) => readEntry$1(f.path, f.source));
10846
+ const dedupeKeys = /* @__PURE__ */ new Map();
10847
+ for (const entry of entries) {
10848
+ if (entry.source !== "plugins") continue;
10849
+ const status = resolvePluginStatus(claudeDir, entry.path, enabledPlugins);
10850
+ entry.enabled = status.enabled;
10851
+ entry.status = status.status;
10852
+ dedupeKeys.set(entry.path, status.dedupeKey);
10853
+ }
10854
+ const totalsBySource = {
10855
+ skills: 0,
10856
+ commands: 0,
10857
+ plugins: 0
10858
+ };
10859
+ const countsBySource = {
10860
+ skills: 0,
10861
+ commands: 0,
10862
+ plugins: 0
10863
+ };
10864
+ const enabledTotalsBySource = {
10865
+ skills: 0,
10866
+ commands: 0,
10867
+ plugins: 0
10868
+ };
10869
+ const seenEnabledPluginKeys = /* @__PURE__ */ new Set();
10870
+ for (const entry of entries) {
10871
+ totalsBySource[entry.source] += entry.tokens;
10872
+ countsBySource[entry.source] += 1;
10873
+ if (!entry.enabled) continue;
10874
+ if (entry.source !== "plugins") {
10875
+ enabledTotalsBySource[entry.source] += entry.tokens;
10876
+ continue;
10877
+ }
10878
+ const dedupeKey = dedupeKeys.get(entry.path);
10879
+ if (seenEnabledPluginKeys.has(dedupeKey)) continue;
10880
+ seenEnabledPluginKeys.add(dedupeKey);
10881
+ enabledTotalsBySource.plugins += entry.tokens;
10882
+ }
10883
+ return {
10884
+ encoding: TOKEN_ENCODING,
10885
+ entries,
10886
+ total: entries.reduce((sum, e) => sum + e.tokens, 0),
10887
+ totalsBySource,
10888
+ countsBySource,
10889
+ enabledTotal: enabledTotalsBySource.skills + enabledTotalsBySource.commands + enabledTotalsBySource.plugins,
10890
+ enabledTotalsBySource,
10891
+ duplicates: findDuplicates(entries)
10892
+ };
10893
+ }
10894
+ function topByTokens(report, n) {
10895
+ return [...report.entries].sort((a, b) => b.tokens - a.tokens).slice(0, n);
10896
+ }
10897
+ const SKILL_CATALOGUE_AMBER = 3e3;
10898
+ const SKILL_CATALOGUE_RED = 6e3;
10899
+
10470
10900
  //#endregion
10471
10901
  //#region src/audit/severity.ts
10472
10902
  const HOOK_TOKEN_LIMIT = 1e3;
10473
10903
  const FIRST_TURN_CONTEXT_LIMIT = 3e4;
10474
10904
  const DAEMON_FAILURE_RATE_LIMIT = .05;
10905
+ const CONTEXT_GROWTH_AVG_RED = 1e5;
10906
+ const CONTEXT_GROWTH_MAX_AMBER = 15e4;
10475
10907
  const SEVERITY_RANK = {
10476
10908
  RED: 0,
10477
10909
  AMBER: 1,
@@ -10528,9 +10960,534 @@ function buildFindings(input) {
10528
10960
  severity: "GREEN",
10529
10961
  evidence: `${input.env.processes.length} claude process(es), none proxied`
10530
10962
  });
10963
+ const deferralOff = input.env.processes.filter((p) => {
10964
+ const mcpCount = p.mcpServers === "default" ? input.env.mcpServerCount : p.mcpServers;
10965
+ return p.deferral === "OFF" && mcpCount > 0;
10966
+ });
10967
+ findings.push({
10968
+ finding: "tool deferral on live processes",
10969
+ severity: deferralOff.length > 0 ? "RED" : "GREEN",
10970
+ evidence: deferralOff.length > 0 ? deferralOff.map((p) => `pid ${p.pid}: ${p.mcpServers === "default" ? input.env.mcpServerCount : p.mcpServers} mcp servers, tools=${p.toolsArg}`).join("; ") : `${input.env.processes.length} claude process(es) checked, none with an explicit --tools list missing ToolSearch and MCP servers registered`
10971
+ });
10972
+ const skillTokens = input.skills.enabledTotal;
10973
+ findings.push({
10974
+ finding: "skill catalogue tokens",
10975
+ severity: skillTokens > SKILL_CATALOGUE_RED ? "RED" : skillTokens > SKILL_CATALOGUE_AMBER ? "AMBER" : "GREEN",
10976
+ 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)` : "")
10977
+ });
10978
+ const inheriting = input.subagents.entries.filter((e) => e.model === "inherits");
10979
+ if (input.subagents.entries.length === 0) findings.push({
10980
+ finding: "subagent model pinning",
10981
+ severity: "GREEN",
10982
+ evidence: "no agent files"
10983
+ });
10984
+ else findings.push({
10985
+ finding: "subagent model pinning",
10986
+ severity: inheriting.length > 0 ? "AMBER" : "GREEN",
10987
+ evidence: `${inheriting.length} of ${input.subagents.entries.length} inherit`
10988
+ });
10989
+ const configuredCount = input.mcp.servers.filter((s) => s.configuredIn.length > 0).length;
10990
+ const plainLaunch = input.mcp.liveProcesses.find((p) => p.mcpConfigPath === null);
10991
+ if (!plainLaunch) findings.push({
10992
+ finding: "MCP servers loaded vs. used",
10993
+ severity: "GREEN",
10994
+ evidence: `${configuredCount} configured, no plain launch found`
10995
+ });
10996
+ else {
10997
+ const zeroCallServers = input.mcp.servers.filter((s) => s.configuredIn.length > 0 && s.calls30d === 0);
10998
+ findings.push({
10999
+ finding: "MCP servers loaded vs. used",
11000
+ severity: zeroCallServers.length > 0 ? "AMBER" : "GREEN",
11001
+ evidence: `${configuredCount} configured, ${zeroCallServers.length} loaded by pid ${plainLaunch.pid}`
11002
+ });
11003
+ }
11004
+ if (input.session) {
11005
+ const avg = input.session.avgContext ?? 0;
11006
+ const max = input.session.maxContext ?? 0;
11007
+ const severity = avg > CONTEXT_GROWTH_AVG_RED || input.session.turnsAboveThreshold > 0 ? "RED" : max > CONTEXT_GROWTH_MAX_AMBER ? "AMBER" : "GREEN";
11008
+ findings.push({
11009
+ finding: "context growth",
11010
+ severity,
11011
+ evidence: `avg ${avg}, max ${max}, ${input.session.turnsAboveThreshold} turns > ${input.ctxThreshold}, ${input.session.turns} turns`
11012
+ });
11013
+ }
10531
11014
  return sortFindings(findings);
10532
11015
  }
10533
11016
 
11017
+ //#endregion
11018
+ //#region src/audit/ladder.ts
11019
+ /**
11020
+ * `pai audit tokens ladder` — LIVE: what each layer of headless-worker
11021
+ * scaffolding actually costs in first-turn context, measured by spawning the
11022
+ * real `claude -p` binary at increasing configuration and reading the
11023
+ * `usage` block of its `--output-format json` result. No section above this
11024
+ * one makes a network call; this is the one exception, gated behind `--live`.
11025
+ *
11026
+ * Rungs (each a superset of the last, except L3 which drops every flag):
11027
+ * L0 empty MCP config, no tool schemas at all
11028
+ * L1 same, plus the core file/shell tool schemas (`--tools`)
11029
+ * L2 same as L1, but the empty MCP config is swapped for a real one
11030
+ * L3 plain: none of --strict-mcp-config / --mcp-config / --tools
11031
+ */
11032
+ const PROBE_PROMPT = "Reply with exactly the single word OK and nothing else. Do not use any tool.";
11033
+ const LADDER_TOOLS = "Bash,Read,Edit,Write,Grep,Glob,Skill,ToolSearch";
11034
+ const LADDER_TIMEOUT_MS = 18e4;
11035
+ const DEFAULT_LADDER_MODEL = "haiku";
11036
+ /** Build the four rungs' argv. Pure — no spawning, so it's unit-testable. */
11037
+ function buildLadderRungs(model, emptyMcpPath, realMcpPath) {
11038
+ const base = [
11039
+ "-p",
11040
+ PROBE_PROMPT,
11041
+ "--model",
11042
+ model,
11043
+ "--output-format",
11044
+ "json"
11045
+ ];
11046
+ return [
11047
+ {
11048
+ id: "L0",
11049
+ description: "empty MCP config, no tools",
11050
+ args: [
11051
+ ...base,
11052
+ "--strict-mcp-config",
11053
+ "--mcp-config",
11054
+ emptyMcpPath,
11055
+ "--tools",
11056
+ ""
11057
+ ]
11058
+ },
11059
+ {
11060
+ id: "L1",
11061
+ description: `empty MCP config, --tools ${LADDER_TOOLS}`,
11062
+ args: [
11063
+ ...base,
11064
+ "--strict-mcp-config",
11065
+ "--mcp-config",
11066
+ emptyMcpPath,
11067
+ "--tools",
11068
+ LADDER_TOOLS
11069
+ ]
11070
+ },
11071
+ {
11072
+ id: "L2",
11073
+ description: `real MCP config, --tools ${LADDER_TOOLS}`,
11074
+ args: [
11075
+ ...base,
11076
+ "--strict-mcp-config",
11077
+ "--mcp-config",
11078
+ realMcpPath,
11079
+ "--tools",
11080
+ LADDER_TOOLS
11081
+ ]
11082
+ },
11083
+ {
11084
+ id: "L3",
11085
+ description: "plain (no --strict-mcp-config, no --mcp-config, no --tools)",
11086
+ args: [...base]
11087
+ }
11088
+ ];
11089
+ }
11090
+ /** Newest `*.mcp.json` in a worker log dir, or null if none exist. */
11091
+ function newestMcpConfig(logDir) {
11092
+ let files;
11093
+ try {
11094
+ files = readdirSync(logDir);
11095
+ } catch {
11096
+ return null;
11097
+ }
11098
+ const candidates = files.filter((f) => f.endsWith(".mcp.json")).map((f) => join(logDir, f));
11099
+ if (candidates.length === 0) return null;
11100
+ return candidates.map((path) => ({
11101
+ path,
11102
+ mtime: statSync(path).mtimeMs
11103
+ })).sort((a, b) => b.mtime - a.mtime)[0].path;
11104
+ }
11105
+ /** Spawn one rung of `claude -p ...` and read its usage-derived first-turn context. */
11106
+ function runRung(rung, cwd) {
11107
+ return new Promise((resolvePromise) => {
11108
+ const env = { ...process.env };
11109
+ delete env.PAI_WORKER;
11110
+ const proc = spawn("claude", rung.args, {
11111
+ cwd,
11112
+ env,
11113
+ stdio: [
11114
+ "ignore",
11115
+ "pipe",
11116
+ "pipe"
11117
+ ]
11118
+ });
11119
+ let out = "";
11120
+ let timedOut = false;
11121
+ const timer = setTimeout(() => {
11122
+ timedOut = true;
11123
+ try {
11124
+ proc.kill("SIGKILL");
11125
+ } catch {}
11126
+ }, LADDER_TIMEOUT_MS);
11127
+ proc.stdout.on("data", (chunk) => {
11128
+ out += chunk.toString("utf8");
11129
+ });
11130
+ proc.on("error", (e) => {
11131
+ clearTimeout(timer);
11132
+ resolvePromise({
11133
+ firstTurnContext: null,
11134
+ timedOut: false,
11135
+ error: e.message
11136
+ });
11137
+ });
11138
+ proc.on("close", () => {
11139
+ clearTimeout(timer);
11140
+ if (timedOut) {
11141
+ resolvePromise({
11142
+ firstTurnContext: null,
11143
+ timedOut: true
11144
+ });
11145
+ return;
11146
+ }
11147
+ try {
11148
+ resolvePromise({
11149
+ firstTurnContext: usageContextTokens(JSON.parse(out.trim()).usage),
11150
+ timedOut: false
11151
+ });
11152
+ } catch (e) {
11153
+ resolvePromise({
11154
+ firstTurnContext: null,
11155
+ timedOut: false,
11156
+ error: e.message
11157
+ });
11158
+ }
11159
+ });
11160
+ });
11161
+ }
11162
+ async function auditLadder(opts) {
11163
+ const model = opts.model ?? DEFAULT_LADDER_MODEL;
11164
+ const emptyMcpPath = ensureNoMcpConfig(opts.logDir);
11165
+ const realMcpPath = opts.mcpConfigPath ?? newestMcpConfig(opts.logDir) ?? emptyMcpPath;
11166
+ const cwd = opts.cwd ?? process.cwd();
11167
+ const rungs = buildLadderRungs(model, emptyMcpPath, realMcpPath);
11168
+ const readings = [];
11169
+ let previous = null;
11170
+ for (const rung of rungs) {
11171
+ const result = await runRung(rung, cwd);
11172
+ const delta = result.firstTurnContext !== null && previous !== null ? result.firstTurnContext - previous : null;
11173
+ readings.push({
11174
+ id: rung.id,
11175
+ description: rung.description,
11176
+ firstTurnContext: result.firstTurnContext,
11177
+ timedOut: result.timedOut,
11178
+ error: result.error,
11179
+ delta
11180
+ });
11181
+ if (result.firstTurnContext !== null) previous = result.firstTurnContext;
11182
+ }
11183
+ return {
11184
+ model,
11185
+ mcpConfigPath: realMcpPath,
11186
+ readings
11187
+ };
11188
+ }
11189
+
11190
+ //#endregion
11191
+ //#region src/audit/subagents.ts
11192
+ /**
11193
+ * `pai audit tokens subagents` — token cost of every Claude Code subagent
11194
+ * definition: `.claude/agents/*.md` under the user's home and (separately)
11195
+ * under the current project. A subagent with no `model:` frontmatter field
11196
+ * inherits the caller's model at runtime rather than costing a fixed model
11197
+ * choice, so it is reported as `"inherits"` rather than left blank.
11198
+ */
11199
+ /** Strip a single layer of matching quotes ("..." or '...') from a trimmed value. */
11200
+ function unquote(value) {
11201
+ if (value.length >= 2) {
11202
+ const first = value[0];
11203
+ const last = value[value.length - 1];
11204
+ if (first === "\"" && last === "\"" || first === "'" && last === "'") return value.slice(1, -1);
11205
+ }
11206
+ return value;
11207
+ }
11208
+ /** The top-level `model:` frontmatter field, or `"inherits"` if absent/empty. */
11209
+ function parseModel(text) {
11210
+ const match = /^---\n([\s\S]*?)\n---/.exec(text);
11211
+ if (!match) return "inherits";
11212
+ const modelMatch = /^model:\s*(.+)$/m.exec(match[1]);
11213
+ if (!modelMatch) return "inherits";
11214
+ const value = unquote(modelMatch[1].trim());
11215
+ return value === "" ? "inherits" : value;
11216
+ }
11217
+ /** Every top-level `.md` file directly under `agentsDir` (not recursive). */
11218
+ function findAgentFiles(agentsDir) {
11219
+ let names;
11220
+ try {
11221
+ names = readdirSync(agentsDir);
11222
+ } catch {
11223
+ return [];
11224
+ }
11225
+ return names.filter((n) => n.endsWith(".md")).map((n) => join(agentsDir, n));
11226
+ }
11227
+ function readEntry(path) {
11228
+ let text = "";
11229
+ try {
11230
+ text = readFileSync(path, "utf8");
11231
+ } catch {}
11232
+ return {
11233
+ path,
11234
+ tokens: countTokens(text),
11235
+ model: parseModel(text)
11236
+ };
11237
+ }
11238
+ function auditSubagents(homeDir, cwd) {
11239
+ return {
11240
+ encoding: TOKEN_ENCODING,
11241
+ entries: [...findAgentFiles(join(homeDir, ".claude", "agents")), ...findAgentFiles(join(cwd, ".claude", "agents"))].map(readEntry)
11242
+ };
11243
+ }
11244
+
11245
+ //#endregion
11246
+ //#region src/audit/mcp.ts
11247
+ /**
11248
+ * `pai audit mcp` — cross-reference every MCP server name found anywhere
11249
+ * (global/project config, project `.mcp.json`, live process argv, 30-day
11250
+ * transcript usage) against where it's configured, whether it's pinned via
11251
+ * `pai project mcp`, whether a live process actually loaded it, and how much
11252
+ * it was used in the last 30 days.
11253
+ *
11254
+ * The `--connect` path is the only side-effecting one: it launches every
11255
+ * configured stdio MCP server to call `listTools()`. It never runs unless
11256
+ * the caller passes `connect: true` explicitly — there is no default-on path.
11257
+ */
11258
+ const THIRTY_DAYS_MS = 720 * 60 * 60 * 1e3;
11259
+ const MAX_TRANSCRIPT_BYTES = 50 * 1024 * 1024;
11260
+ /** Whether a transcript file is worth streaming: fresh enough and small enough. */
11261
+ function shouldScanTranscript(stat, now) {
11262
+ if (stat.size > MAX_TRANSCRIPT_BYTES) return false;
11263
+ if (now - stat.mtimeMs > THIRTY_DAYS_MS) return false;
11264
+ return true;
11265
+ }
11266
+ /**
11267
+ * Whether a live process's resolved server set includes `serverName`.
11268
+ * `servers === null` means "no --mcp-config flag", i.e. every configured
11269
+ * server loads; an array (possibly empty) means only those names load.
11270
+ */
11271
+ function liveProcessLoadsServer(proc, serverName, allConfiguredNames) {
11272
+ if (proc.servers === null) return allConfiguredNames.includes(serverName);
11273
+ return proc.servers.includes(serverName);
11274
+ }
11275
+ function resolveHomePath(path, homeDir) {
11276
+ return path.startsWith("~") ? join(homeDir, path.slice(1)) : path;
11277
+ }
11278
+ function readJsonFile(path) {
11279
+ try {
11280
+ return JSON.parse(readFileSync(path, "utf8"));
11281
+ } catch {
11282
+ return null;
11283
+ }
11284
+ }
11285
+ function gatherConfiguredSources(cwd, homeDir) {
11286
+ const configuredIn = /* @__PURE__ */ new Map();
11287
+ const disabled = /* @__PURE__ */ new Set();
11288
+ const serverDefs = /* @__PURE__ */ new Map();
11289
+ const addTag = (name, tag) => {
11290
+ if (!configuredIn.has(name)) configuredIn.set(name, /* @__PURE__ */ new Set());
11291
+ configuredIn.get(name).add(tag);
11292
+ };
11293
+ const claudeJson = readJsonFile(join(homeDir, ".claude.json"));
11294
+ if (claudeJson) {
11295
+ for (const [name, def] of Object.entries(claudeJson.mcpServers ?? {})) {
11296
+ addTag(name, "global");
11297
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11298
+ }
11299
+ const projectEntry = claudeJson.projects?.[resolve(cwd)];
11300
+ if (projectEntry) {
11301
+ for (const [name, def] of Object.entries(projectEntry.mcpServers ?? {})) {
11302
+ addTag(name, "project");
11303
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11304
+ }
11305
+ for (const name of projectEntry.disabledMcpServers ?? []) {
11306
+ disabled.add(name);
11307
+ if (!configuredIn.has(name)) configuredIn.set(name, /* @__PURE__ */ new Set());
11308
+ }
11309
+ }
11310
+ }
11311
+ const mcpJsonPath = join(cwd, ".mcp.json");
11312
+ if (existsSync(mcpJsonPath)) {
11313
+ const mcpJson = readJsonFile(mcpJsonPath);
11314
+ if (mcpJson) for (const [name, def] of Object.entries(mcpJson.mcpServers ?? {})) {
11315
+ addTag(name, "project .mcp.json");
11316
+ if (!serverDefs.has(name)) serverDefs.set(name, def);
11317
+ }
11318
+ }
11319
+ return {
11320
+ configuredIn,
11321
+ disabled,
11322
+ serverDefs
11323
+ };
11324
+ }
11325
+ function gatherLiveProcesses(homeDir) {
11326
+ const procs = [];
11327
+ for (const pid of findClaudePids()) {
11328
+ const line = readProcessEnvLine(pid);
11329
+ if (!line) continue;
11330
+ const mcpConfigArg = extractMcpConfigArg(line);
11331
+ if (!mcpConfigArg) {
11332
+ procs.push({
11333
+ pid,
11334
+ mcpConfigPath: null,
11335
+ servers: null
11336
+ });
11337
+ continue;
11338
+ }
11339
+ const resolved = resolveHomePath(mcpConfigArg, homeDir);
11340
+ const parsed = readJsonFile(resolved);
11341
+ const servers = parsed ? Object.keys(parsed.mcpServers ?? {}) : [];
11342
+ procs.push({
11343
+ pid,
11344
+ mcpConfigPath: resolved,
11345
+ servers
11346
+ });
11347
+ }
11348
+ return procs;
11349
+ }
11350
+ /** Recursively find every file under `dir` ending in `.jsonl`. */
11351
+ function findJsonlFiles(dir) {
11352
+ let entries;
11353
+ try {
11354
+ entries = readdirSync(dir, { withFileTypes: true });
11355
+ } catch {
11356
+ return [];
11357
+ }
11358
+ const found = [];
11359
+ for (const entry of entries) {
11360
+ const full = join(dir, entry.name);
11361
+ if (entry.isDirectory()) found.push(...findJsonlFiles(full));
11362
+ else if (entry.isFile() && entry.name.endsWith(".jsonl")) found.push(full);
11363
+ }
11364
+ return found;
11365
+ }
11366
+ function foldToolUseBlock(block, usage) {
11367
+ if (block.type !== "tool_use") return;
11368
+ const name = block.name;
11369
+ if (!name || !name.startsWith("mcp__")) return;
11370
+ const parts = name.split("__");
11371
+ const server = parts[1];
11372
+ const tool = parts.slice(2).join("__");
11373
+ if (!server) return;
11374
+ if (!usage.has(server)) usage.set(server, {
11375
+ tools: /* @__PURE__ */ new Set(),
11376
+ calls: 0
11377
+ });
11378
+ const entry = usage.get(server);
11379
+ entry.tools.add(tool);
11380
+ entry.calls += 1;
11381
+ }
11382
+ async function gatherUsage(transcriptsDir, now) {
11383
+ const usage = /* @__PURE__ */ new Map();
11384
+ for (const file of findJsonlFiles(transcriptsDir)) {
11385
+ let stat;
11386
+ try {
11387
+ stat = statSync(file);
11388
+ } catch {
11389
+ continue;
11390
+ }
11391
+ if (!shouldScanTranscript(stat, now)) continue;
11392
+ const rl = createInterface({
11393
+ input: createReadStream(file, "utf8"),
11394
+ crlfDelay: Infinity
11395
+ });
11396
+ for await (const raw of rl) {
11397
+ if (!raw.includes("\"mcp__")) continue;
11398
+ let parsed;
11399
+ try {
11400
+ parsed = JSON.parse(raw);
11401
+ } catch {
11402
+ continue;
11403
+ }
11404
+ if (parsed.type === "tool_use") foldToolUseBlock(parsed, usage);
11405
+ if (Array.isArray(parsed.message?.content)) for (const block of parsed.message.content) foldToolUseBlock(block, usage);
11406
+ }
11407
+ }
11408
+ const result = /* @__PURE__ */ new Map();
11409
+ for (const [server, entry] of usage) result.set(server, {
11410
+ distinctTools: entry.tools.size,
11411
+ calls: entry.calls
11412
+ });
11413
+ return result;
11414
+ }
11415
+ function isStdioDef(def) {
11416
+ return typeof def === "object" && def !== null && typeof def.command === "string";
11417
+ }
11418
+ /** Connect to every stdio MCP server row and record how many tools it exposes. Side-effecting: only called when `opts.connect === true`. */
11419
+ async function connectAndCountTools(rows, serverDefs) {
11420
+ const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
11421
+ const { StdioClientTransport } = await import("@modelcontextprotocol/sdk/client/stdio.js");
11422
+ for (const row of rows) {
11423
+ const def = serverDefs.get(row.server);
11424
+ if (!isStdioDef(def)) continue;
11425
+ const transport = new StdioClientTransport({
11426
+ command: def.command,
11427
+ args: def.args ?? [],
11428
+ env: def.env
11429
+ });
11430
+ const client = new Client({
11431
+ name: "pai-audit-mcp",
11432
+ version: "1.0.0"
11433
+ }, { capabilities: {} });
11434
+ let settled = false;
11435
+ const timeout = new Promise((res) => {
11436
+ setTimeout(() => {
11437
+ if (!settled) res("UNKNOWN");
11438
+ }, 1e4);
11439
+ });
11440
+ try {
11441
+ const attempt = (async () => {
11442
+ await client.connect(transport);
11443
+ return (await client.listTools()).tools.length;
11444
+ })();
11445
+ const outcome = await Promise.race([attempt, timeout]);
11446
+ settled = true;
11447
+ row.toolsExposed = outcome;
11448
+ } catch {
11449
+ settled = true;
11450
+ row.toolsExposed = "UNKNOWN";
11451
+ } finally {
11452
+ try {
11453
+ await client.close();
11454
+ } catch {}
11455
+ }
11456
+ }
11457
+ }
11458
+ async function auditMcp(opts) {
11459
+ const cwd = opts.cwd;
11460
+ const homeDir = opts.homeDir;
11461
+ const transcriptsDir = opts.transcriptsDir ?? join(homeDir, ".claude", "projects");
11462
+ const { configuredIn, disabled, serverDefs } = gatherConfiguredSources(cwd, homeDir);
11463
+ const liveProcesses = gatherLiveProcesses(homeDir);
11464
+ const usage = await gatherUsage(transcriptsDir, Date.now());
11465
+ const pinned = opts.projectsDbPath ? projectLaunchConfig(resolve(cwd), opts.projectsDbPath)?.mcp ?? null : projectLaunchConfig(resolve(cwd))?.mcp ?? null;
11466
+ const allNames = new Set([...configuredIn.keys(), ...usage.keys()]);
11467
+ const allConfiguredNames = [...configuredIn.keys()];
11468
+ const rows = [];
11469
+ for (const name of allNames) {
11470
+ const liveMatches = liveProcesses.filter((p) => liveProcessLoadsServer(p, name, allConfiguredNames));
11471
+ const serverUsage = usage.get(name);
11472
+ rows.push({
11473
+ server: name,
11474
+ configuredIn: [...configuredIn.get(name) ?? /* @__PURE__ */ new Set()],
11475
+ disabled: disabled.has(name),
11476
+ pinned: pinned === null ? null : pinned.includes(name) ? "yes" : "-",
11477
+ loadedLive: liveMatches.length ? liveMatches.map((p) => `pid ${p.pid}`).join(", ") : "-",
11478
+ toolsExposed: "UNKNOWN",
11479
+ toolsUsed30d: serverUsage?.distinctTools ?? 0,
11480
+ calls30d: serverUsage?.calls ?? 0
11481
+ });
11482
+ }
11483
+ rows.sort((a, b) => a.server.localeCompare(b.server));
11484
+ if (opts.connect === true) await connectAndCountTools(rows, serverDefs);
11485
+ return {
11486
+ servers: rows,
11487
+ liveProcesses
11488
+ };
11489
+ }
11490
+
10534
11491
  //#endregion
10535
11492
  //#region src/cli/commands/audit.ts
10536
11493
  function severityColor(sev, text) {
@@ -10599,9 +11556,10 @@ function printSession(report) {
10599
11556
  console.log(`context on last turn: ${report.lastTurnContext ?? "n/a"}`);
10600
11557
  console.log(`cache_creation split: ephemeral_5m=${report.cacheCreationSplit.ephemeral5m}, ephemeral_1h=${report.cacheCreationSplit.ephemeral1h}`);
10601
11558
  console.log(dim$1(`models: ${JSON.stringify(report.models)}`));
11559
+ 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}`);
10602
11560
  }
10603
11561
  async function cmdSession(path, opts) {
10604
- const report = await auditSession(path);
11562
+ const report = await auditSession(path, opts.ctxThreshold ? parseInt(opts.ctxThreshold, 10) : void 0);
10605
11563
  if (!report) {
10606
11564
  console.error(err(" No session transcript found."));
10607
11565
  process.exitCode = 1;
@@ -10617,10 +11575,35 @@ function printSpawnGroup(name, stats) {
10617
11575
  stats.min === null ? "n/a" : String(stats.min),
10618
11576
  stats.median === null ? "n/a" : String(stats.median),
10619
11577
  stats.max === null ? "n/a" : String(stats.max),
11578
+ stats.medianOverhead === null ? "n/a" : String(stats.medianOverhead),
10620
11579
  stats.models.join(", ")
10621
11580
  ];
10622
11581
  }
10623
- function printSpawn(report) {
11582
+ const SPAWN_GROUP_LABELS = {
11583
+ agentSubagents: "Agent-tool subagents",
11584
+ workersInteractive: "pai workers (interactive/pane)",
11585
+ workersHeadless: "pai workers (headless -p)"
11586
+ };
11587
+ function printSpawn(report, detail) {
11588
+ if (detail) {
11589
+ console.log(header("Spawn overhead — per-log detail"));
11590
+ const rows = report.readings.map((r) => [
11591
+ SPAWN_GROUP_LABELS[r.group] ?? r.group,
11592
+ r.firstTurnContext === null ? "n/a" : String(r.firstTurnContext),
11593
+ r.promptTokens === null ? "n/a" : String(r.promptTokens),
11594
+ r.overhead === null ? "n/a" : String(r.overhead),
11595
+ r.model ?? "",
11596
+ shortenPath(r.path, 70)
11597
+ ]);
11598
+ console.log(renderTable([
11599
+ "group",
11600
+ "first-turn ctx",
11601
+ "prompt tok",
11602
+ "overhead",
11603
+ "model",
11604
+ "path"
11605
+ ], rows));
11606
+ }
10624
11607
  console.log(header("Spawn overhead (first-turn context tokens)"));
10625
11608
  const rows = [
10626
11609
  printSpawnGroup("Agent-tool subagents", report.agentSubagents),
@@ -10633,13 +11616,14 @@ function printSpawn(report) {
10633
11616
  "min",
10634
11617
  "median",
10635
11618
  "max",
11619
+ "median overhead",
10636
11620
  "models"
10637
11621
  ], rows));
10638
11622
  }
10639
11623
  async function cmdSpawn(opts) {
10640
11624
  const report = await auditSpawn(opts.n ? parseInt(opts.n, 10) : 10);
10641
11625
  if (opts.json) return printJson(report);
10642
- printSpawn(report);
11626
+ printSpawn(report, opts.detail);
10643
11627
  }
10644
11628
  function printDaemon(report) {
10645
11629
  console.log(header(`Daemon log: ${report.logPath}`));
@@ -10679,6 +11663,8 @@ function printEnv(report) {
10679
11663
  p.authTokenPresent ? "present" : "absent",
10680
11664
  JSON.stringify(p.defaultModels),
10681
11665
  p.enableToolSearch ?? "",
11666
+ String(p.mcpServers),
11667
+ p.deferral === "OFF" ? err(p.deferral) : p.deferral === "n/a" ? dim$1(p.deferral) : p.deferral,
10682
11668
  p.workerId ?? "",
10683
11669
  p.age ?? ""
10684
11670
  ]);
@@ -10688,10 +11674,16 @@ function printEnv(report) {
10688
11674
  "auth token",
10689
11675
  "default models",
10690
11676
  "tool search",
11677
+ "tools",
11678
+ "deferral",
10691
11679
  "worker id",
10692
11680
  "age"
10693
11681
  ], rows));
10694
- for (const p of report.processes) if (p.baseUrl) console.log(err(` RED: claude process ${p.pid} is routed through ${p.baseUrl}`));
11682
+ for (const p of report.processes) {
11683
+ if (p.baseUrl) console.log(err(` RED: claude process ${p.pid} is routed through ${p.baseUrl}`));
11684
+ const mcpCount = p.mcpServers === "default" ? report.mcpServerCount : p.mcpServers;
11685
+ if (p.deferral === "OFF" && mcpCount > 0) console.log(err(` RED: claude process ${p.pid} has --tools without ToolSearch (${mcpCount} MCP servers registered)`));
11686
+ }
10695
11687
  console.log(dim$1(`MCP servers registered: ${report.mcpServerCount}`));
10696
11688
  console.log(dim$1(`settings.json model: ${report.settingsModel ?? "unset"}, effortLevel: ${report.settingsEffortLevel ?? "unset"}`));
10697
11689
  }
@@ -10729,45 +11721,309 @@ async function cmdSchedule(opts) {
10729
11721
  if (opts.json) return printJson(report);
10730
11722
  printSchedule(report);
10731
11723
  }
10732
- async function cmdCombined(opts) {
11724
+ function printSkills(report, top) {
11725
+ console.log(header(`Skill/command/plugin catalogue (encoding: ${report.encoding})`));
11726
+ console.log(dim$1(`${report.entries.length} entries, ${report.enabledTotal} tokens loaded (${report.total} on disk)`));
11727
+ const subtotalRows = [
11728
+ "skills",
11729
+ "commands",
11730
+ "plugins"
11731
+ ].map((source) => [
11732
+ source,
11733
+ String(report.countsBySource[source]),
11734
+ String(report.totalsBySource[source]),
11735
+ String(report.enabledTotalsBySource[source])
11736
+ ]);
11737
+ console.log(renderTable([
11738
+ "source",
11739
+ "entries",
11740
+ "tokens",
11741
+ "enabled"
11742
+ ], subtotalRows));
11743
+ console.log(header(`Top ${top} by tokens`));
11744
+ const topRows = topByTokens(report, top).map((e) => [
11745
+ String(e.tokens),
11746
+ e.source,
11747
+ e.enabled ? "yes" : "no",
11748
+ e.name,
11749
+ e.path
11750
+ ]);
11751
+ console.log(renderTable([
11752
+ "tokens",
11753
+ "source",
11754
+ "on",
11755
+ "name",
11756
+ "path"
11757
+ ], topRows));
11758
+ console.log(header("Case-insensitive duplicate names"));
11759
+ if (report.duplicates.length === 0) console.log(dim$1("none"));
11760
+ else for (const dup of report.duplicates) {
11761
+ console.log(`${bold$1(dup.name)}:`);
11762
+ for (const path of dup.paths) console.log(` ${path}`);
11763
+ }
11764
+ }
11765
+ function cmdSkills(opts) {
11766
+ const top = opts.top ? parseInt(opts.top, 10) : 15;
11767
+ const report = auditSkills();
11768
+ if (opts.json) return printJson({
11769
+ ...report,
11770
+ top: topByTokens(report, top)
11771
+ });
11772
+ printSkills(report, top);
11773
+ }
11774
+ function printLadder(report) {
11775
+ console.log(header(`Context ladder (model: ${report.model}, mcp config: ${report.mcpConfigPath})`));
11776
+ const rows = report.readings.map((r) => [
11777
+ r.id,
11778
+ r.description,
11779
+ r.timedOut ? "TIMEOUT" : r.firstTurnContext === null ? `n/a${r.error ? ` (${r.error})` : ""}` : String(r.firstTurnContext),
11780
+ r.delta === null ? "n/a" : String(r.delta)
11781
+ ]);
11782
+ console.log(renderTable([
11783
+ "rung",
11784
+ "description",
11785
+ "first-turn tokens",
11786
+ "delta"
11787
+ ], rows));
11788
+ }
11789
+ async function cmdLadder(opts) {
11790
+ if (!opts.live) {
11791
+ console.log(warn$1("Refusing to run: `pai audit tokens ladder` spawns several real `claude -p` calls (billed API traffic). Pass --live to run it."));
11792
+ return;
11793
+ }
11794
+ const { workers } = readWorkersSection();
11795
+ const logDir = workersLogDir(workers);
11796
+ const report = await auditLadder({
11797
+ model: opts.model,
11798
+ mcpConfigPath: opts.mcpConfig,
11799
+ logDir
11800
+ });
11801
+ if (opts.json) return printJson(report);
11802
+ printLadder(report);
11803
+ }
11804
+ function printSubagents(report) {
11805
+ console.log(header(`Subagent definitions (encoding: ${report.encoding})`));
11806
+ if (report.entries.length === 0) {
11807
+ console.log(dim$1("no agent files"));
11808
+ return;
11809
+ }
11810
+ const rows = report.entries.map((e) => [
11811
+ String(e.tokens),
11812
+ e.model === "inherits" ? dim$1("inherits") : e.model,
11813
+ e.path
11814
+ ]);
11815
+ console.log(renderTable([
11816
+ "tokens",
11817
+ "model",
11818
+ "path"
11819
+ ], rows));
11820
+ const inheriting = report.entries.filter((e) => e.model === "inherits").length;
11821
+ console.log(dim$1(`${inheriting} of ${report.entries.length} inherit the caller's model`));
11822
+ }
11823
+ function cmdSubagents(opts) {
11824
+ const report = auditSubagents(homedir(), process.cwd());
11825
+ if (opts.json) return printJson(report);
11826
+ printSubagents(report);
11827
+ }
11828
+ function printMcp(report) {
11829
+ console.log(header("MCP servers: configured vs. loaded vs. used"));
11830
+ const rows = report.servers.map((s) => [
11831
+ s.server + (s.disabled ? dim$1(" (disabled)") : ""),
11832
+ s.configuredIn.join(", ") || dim$1("-"),
11833
+ s.pinned ?? dim$1("all"),
11834
+ s.loadedLive,
11835
+ String(s.toolsExposed),
11836
+ String(s.toolsUsed30d),
11837
+ String(s.calls30d)
11838
+ ]);
11839
+ console.log(renderTable([
11840
+ "server",
11841
+ "configured-in",
11842
+ "pinned",
11843
+ "loaded-live",
11844
+ "tools-exposed",
11845
+ "tools-used-30d",
11846
+ "calls-30d"
11847
+ ], rows));
11848
+ console.log(dim$1(`live claude processes: ${report.liveProcesses.length}`));
11849
+ }
11850
+ async function cmdMcp(opts) {
11851
+ const report = await auditMcp({
11852
+ cwd: process.cwd(),
11853
+ homeDir: homedir(),
11854
+ connect: opts.connect
11855
+ });
11856
+ if (opts.json) return printJson(report);
11857
+ printMcp(report);
11858
+ }
11859
+ const DEFAULT_CTX_THRESHOLD = 2e5;
11860
+ async function gatherCombined() {
10733
11861
  const cwd = process.cwd();
10734
11862
  const files = auditFiles(defaultFileSet(cwd));
10735
11863
  const hooks = auditHooks(cwd);
10736
- const session = await auditSession(newestSessionLog() ?? void 0).catch(() => null);
11864
+ const session = await auditSession(newestSessionLog() ?? void 0, DEFAULT_CTX_THRESHOLD).catch(() => null);
10737
11865
  const daemon = auditDaemon();
10738
11866
  const env = auditEnv();
10739
11867
  const spawn = await auditSpawn();
10740
11868
  const schedule = auditSchedule(cacheTtlSeconds(session?.cacheCreationSplit));
10741
- const findings = buildFindings({
10742
- files,
10743
- hooks,
10744
- session,
10745
- daemon,
10746
- env
11869
+ const skills = auditSkills();
11870
+ const subagents = auditSubagents(homedir(), cwd);
11871
+ const mcp = await auditMcp({
11872
+ cwd,
11873
+ homeDir: homedir()
10747
11874
  });
10748
- if (opts.json) return printJson({
11875
+ return {
10749
11876
  encoding: TOKEN_ENCODING,
10750
- findings,
11877
+ findings: buildFindings({
11878
+ files,
11879
+ hooks,
11880
+ session,
11881
+ daemon,
11882
+ env,
11883
+ skills,
11884
+ subagents,
11885
+ mcp,
11886
+ ctxThreshold: DEFAULT_CTX_THRESHOLD
11887
+ }),
10751
11888
  files,
10752
11889
  hooks,
10753
11890
  session,
10754
11891
  spawn,
10755
11892
  daemon,
10756
11893
  env,
10757
- schedule
10758
- });
10759
- console.log(header("pai audit tokens — combined report"));
10760
- console.log(dim$1(`token encoding: ${TOKEN_ENCODING}`));
10761
- const rows = findings.map((f) => [
10762
- severityColor(f.severity, f.finding),
10763
- severityColor(f.severity, f.severity),
10764
- f.evidence
10765
- ]);
10766
- console.log(renderTable([
10767
- "FINDING",
10768
- "SEVERITY",
10769
- "EVIDENCE"
10770
- ], rows));
11894
+ schedule,
11895
+ skills,
11896
+ subagents,
11897
+ mcp
11898
+ };
11899
+ }
11900
+ /** Strip ANSI escape sequences (chalk colour codes) from captured console output. */
11901
+ function stripAnsi$1(text) {
11902
+ return text.replace(/\x1b\[[0-9;]*m/g, "");
11903
+ }
11904
+ /** Run `fn`, capturing everything it sends through `console.log` as plain text. */
11905
+ function captureOutput(fn) {
11906
+ const original = console.log;
11907
+ const lines = [];
11908
+ console.log = (...args) => {
11909
+ lines.push(stripAnsi$1(args.map((a) => String(a)).join(" ")));
11910
+ };
11911
+ try {
11912
+ fn();
11913
+ } finally {
11914
+ console.log = original;
11915
+ }
11916
+ return lines.join("\n");
11917
+ }
11918
+ function combinedSectionOutputs(data) {
11919
+ const sections = [];
11920
+ sections.push({
11921
+ name: "files",
11922
+ text: captureOutput(() => printFiles(data.files))
11923
+ });
11924
+ sections.push({
11925
+ name: "hooks",
11926
+ text: captureOutput(() => printHooks(data.hooks))
11927
+ });
11928
+ if (data.session) sections.push({
11929
+ name: "session",
11930
+ text: captureOutput(() => printSession(data.session))
11931
+ });
11932
+ sections.push({
11933
+ name: "spawn",
11934
+ text: captureOutput(() => printSpawn(data.spawn))
11935
+ });
11936
+ sections.push({
11937
+ name: "daemon",
11938
+ text: captureOutput(() => printDaemon(data.daemon))
11939
+ });
11940
+ sections.push({
11941
+ name: "env",
11942
+ text: captureOutput(() => printEnv(data.env))
11943
+ });
11944
+ sections.push({
11945
+ name: "schedule",
11946
+ text: captureOutput(() => printSchedule(data.schedule))
11947
+ });
11948
+ sections.push({
11949
+ name: "skills",
11950
+ text: captureOutput(() => printSkills(data.skills, 15))
11951
+ });
11952
+ sections.push({
11953
+ name: "subagents",
11954
+ text: captureOutput(() => printSubagents(data.subagents))
11955
+ });
11956
+ sections.push({
11957
+ name: "mcp",
11958
+ text: captureOutput(() => printMcp(data.mcp))
11959
+ });
11960
+ return sections;
11961
+ }
11962
+ /**
11963
+ * Replace the home directory with `~` in both its plain form and the encoded
11964
+ * form Claude Code uses for transcript directories (`/Users/name/x` →
11965
+ * `-Users-name-x`), so a recorded run carries no account name.
11966
+ */
11967
+ function redactHome(text, home = homedir()) {
11968
+ const encoded = home.replace(/[\/\\]/g, "-");
11969
+ return text.split(home).join("~").split(encoded).join("~");
11970
+ }
11971
+ /** `--record <dir>`: write a dated, numbered markdown+JSON snapshot of the combined report, plus one summary line in `runs.md`. */
11972
+ function recordCombinedReport(dir, data) {
11973
+ mkdirSync(dir, { recursive: true });
11974
+ const existingNumbers = existsSync(dir) ? readdirSync(dir).map((f) => /-run(\d+)\.md$/.exec(f)?.[1]).filter((n) => n !== void 0).map(Number) : [];
11975
+ const runNumber = (existingNumbers.length ? Math.max(...existingNumbers) : 0) + 1;
11976
+ const date = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
11977
+ const baseName = `${date}-run${runNumber}`;
11978
+ const findingsTable = [
11979
+ "| FINDING | SEVERITY | EVIDENCE |",
11980
+ "|---------|----------|----------|",
11981
+ ...data.findings.map((f) => `| ${f.finding} | ${f.severity} | ${f.evidence} |`)
11982
+ ].join("\n");
11983
+ const sectionBlocks = combinedSectionOutputs(data).map((s) => `### ${s.name}\n\n\`\`\`\n${s.text}\n\`\`\`\n`).join("\n");
11984
+ const md = [
11985
+ "# pai audit tokens — recorded run",
11986
+ "",
11987
+ `date: ${date}`,
11988
+ `encoding: ${data.encoding}`,
11989
+ "",
11990
+ findingsTable,
11991
+ "",
11992
+ sectionBlocks
11993
+ ].join("\n");
11994
+ const json = JSON.stringify(data, null, 2);
11995
+ const mdRedacted = redactHome(md);
11996
+ const jsonRedacted = redactHome(json);
11997
+ writeFileSync(join(dir, `${baseName}.md`), mdRedacted, "utf8");
11998
+ writeFileSync(join(dir, `${baseName}.json`), jsonRedacted, "utf8");
11999
+ const runsPath = join(dir, "runs.md");
12000
+ if (!existsSync(runsPath)) writeFileSync(runsPath, "# Audit token-waste runs\n\n", "utf8");
12001
+ const counts = {
12002
+ RED: 0,
12003
+ AMBER: 0,
12004
+ GREEN: 0
12005
+ };
12006
+ for (const f of data.findings) counts[f.severity]++;
12007
+ appendFileSync(runsPath, `- ${date} run${runNumber}: ${counts.RED} RED, ${counts.AMBER} AMBER, ${counts.GREEN} GREEN — ${baseName}.md\n`, "utf8");
12008
+ }
12009
+ async function cmdCombined(opts) {
12010
+ const data = await gatherCombined();
12011
+ if (opts.json) printJson(data);
12012
+ else {
12013
+ console.log(header("pai audit tokens — combined report"));
12014
+ console.log(dim$1(`token encoding: ${data.encoding}`));
12015
+ const rows = data.findings.map((f) => [
12016
+ severityColor(f.severity, f.finding),
12017
+ severityColor(f.severity, f.severity),
12018
+ f.evidence
12019
+ ]);
12020
+ console.log(renderTable([
12021
+ "FINDING",
12022
+ "SEVERITY",
12023
+ "EVIDENCE"
12024
+ ], rows));
12025
+ }
12026
+ if (opts.record) recordCombinedReport(opts.record, data);
10771
12027
  }
10772
12028
  /**
10773
12029
  * `--json` is declared once, on `tokensCmd` itself, and read back from there
@@ -10778,7 +12034,7 @@ async function cmdCombined(opts) {
10778
12034
  * `--json` on `pai audit tokens files` silently produced the table output.
10779
12035
  */
10780
12036
  function registerAuditCommands(auditCmd) {
10781
- const tokensCmd = auditCmd.command("tokens").description("Token-waste audit: memory files, hooks, session usage, spawn overhead, daemon, env, schedule").option("--json", "Print JSON instead of a table").action(async () => {
12037
+ 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 () => {
10782
12038
  await cmdCombined(jsonOpt());
10783
12039
  });
10784
12040
  const jsonOpt = () => tokensCmd.opts();
@@ -10786,10 +12042,13 @@ function registerAuditCommands(auditCmd) {
10786
12042
  await cmdFiles(paths, jsonOpt());
10787
12043
  });
10788
12044
  tokensCmd.command("hooks").description("Token cost of every SessionStart / UserPromptSubmit hook").action(() => cmdHooks(jsonOpt()));
10789
- 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) => {
10790
- await cmdSession(path, jsonOpt());
12045
+ 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) => {
12046
+ await cmdSession(path, {
12047
+ ...jsonOpt(),
12048
+ ...opts
12049
+ });
10791
12050
  });
10792
- 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)").action(async (opts) => {
12051
+ 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) => {
10793
12052
  await cmdSpawn({
10794
12053
  ...jsonOpt(),
10795
12054
  ...opts
@@ -10800,6 +12059,25 @@ function registerAuditCommands(auditCmd) {
10800
12059
  tokensCmd.command("schedule").description("Launchd agents / crontab entries that outpace the measured prompt-cache TTL").action(async () => {
10801
12060
  await cmdSchedule(jsonOpt());
10802
12061
  });
12062
+ tokensCmd.command("skills").description("Token cost of the SKILL.md / command / plugin catalogue, top entries, duplicate names").option("--top <n>", "How many top-by-tokens entries to show (default 15)").action((opts) => {
12063
+ cmdSkills({
12064
+ ...jsonOpt(),
12065
+ ...opts
12066
+ });
12067
+ });
12068
+ tokensCmd.command("subagents").description("Token cost of Claude Code subagent definitions (~/.claude/agents, <cwd>/.claude/agents) and their model pinning").action(() => cmdSubagents(jsonOpt()));
12069
+ 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) => {
12070
+ await cmdMcp({
12071
+ ...jsonOpt(),
12072
+ ...opts
12073
+ });
12074
+ });
12075
+ 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) => {
12076
+ await cmdLadder({
12077
+ ...jsonOpt(),
12078
+ ...opts
12079
+ });
12080
+ });
10803
12081
  }
10804
12082
 
10805
12083
  //#endregion
@@ -16005,9 +17283,11 @@ function fail(e) {
16005
17283
  process.exitCode = 1;
16006
17284
  }
16007
17285
  function registerWorkerCommands(workerCmd) {
16008
- workerCmd.command("run").description("Run one claude-code worker through the configured provider.\nUnknown options are passed to claude verbatim (e.g. -p, --allowedTools);\n--output-format/--verbose are handled here.\n--label \"<goal>\" is optional — it is the row shown in ps / follow /\nthe status line; when absent it is derived from the prompt's first line\n(--chain/--agent derive their own instead).\n--spec <file> (or --spec -) reads the prompt from a file/stdin instead of\nan inline -p '<prompt>', which breaks on shell quoting; mutually\nexclusive with -p.\nGrant MCP tools by naming mcp__server__tool in --allowedTools (the server loads automatically);\n--chain draft,implement[,review] runs a spec-first pipeline;\n--agent <name> runs an agent definition from ~/.claude/agents.").allowUnknownOption(true).option("--provider <name>", "Provider to run on (default: active, else routing order)").option("--class <name>", "Use the provider of this class (draft, implement, review, research, spotcheck, simple, complex, image)").option("--role <name>", "Alias of --class (roles were renamed to classes)").option("--chain <stages>", "Comma-separated stage classes, e.g. draft,implement or draft,implement,review").option("--agent <name>", "Run the agent definition ~/.claude/agents/<name>.md on a worker").option("--model <model>", "Override the model for this run. Headless (-p) workers default to the --class model; an interactive launch (no -p) with no --model uses the harness default model from settings.json.").option("--label <text>", "Short task label shown in ps / follow / status line (default: first line of the prompt)").option("--spec <path>", "Read the prompt from this file (or - for stdin) instead of -p; mutually exclusive with -p").option("--cwd <dir>", "Directory the worker runs in (default: this process's cwd)").option("--mcp <names>", "MCP servers/sets this worker may use (comma-separated; see `pai worker mcp`)").option("--no-pane", "Do not open a follow pane for this worker").option("--worktree", "Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo)").option("--no-worktree", "Run in place, no worktree").option("--print-cmd", "Print the assembled claude argv as JSON and exit, without spawning (audit tool)").argument("[args...]", "claude arguments, e.g. -p '<task>' --allowedTools 'Read,Edit,Bash'").action(async (args, opts) => {
17286
+ workerCmd.command("run").description("Run one claude-code worker through the configured provider.\nUnknown options are passed to claude verbatim (e.g. -p, --allowedTools);\n--output-format/--verbose are handled here.\n--label \"<goal>\" is optional — it is the row shown in ps / follow /\nthe status line; when absent it is derived from the prompt's first line\n(--chain/--agent derive their own instead).\n--spec <file> (or --spec -) reads the prompt from a file/stdin instead of\nan inline -p '<prompt>', which breaks on shell quoting; mutually\nexclusive with -p.\nGrant MCP tools by naming mcp__server__tool in --allowedTools (the server loads automatically);\n--chain draft,implement[,review] runs a spec-first pipeline;\n--agent <name> runs an agent definition from ~/.claude/agents.").allowUnknownOption(true).option("--provider <name>", "Provider to run on (default: active, else routing order)").option("--class <name>", "Use the provider of this class (draft, implement, review, research, spotcheck, simple, complex, image)").option("--role <name>", "Alias of --class (roles were renamed to classes)").option("--chain <stages>", "Comma-separated stage classes, e.g. draft,implement or draft,implement,review").option("--agent <name>", "Run the agent definition ~/.claude/agents/<name>.md on a worker").option("--model <model>", "Override the model for this run. Headless (-p) workers default to the --class model; an interactive launch (no -p) with no --model uses the harness default model from settings.json.").option("--label <text>", "Short task label shown in ps / follow / status line (default: first line of the prompt)").option("--spec <path>", "Read the prompt from this file (or - for stdin) instead of -p; mutually exclusive with -p").option("--cwd <dir>", "Directory the worker runs in (default: this process's cwd)").option("--mcp <names>", "MCP servers/sets this worker may use (comma-separated; see `pai worker mcp`)").option("--no-pane", "Do not open a follow pane for this worker").option("--worktree", "Run in a git worktree on branch worker/<id> (default for implement/complex/plan in a git repo)").option("--no-worktree", "Run in place, no worktree").option("--print-cmd", "Print the assembled claude argv as JSON and exit, without spawning (audit tool)").option("--report <format>", "Final-report contract/parser: json or ag2 (default: ag2, or PAI_WORKER_REPORT)").option("--no-report-retry", "Skip the one bounded re-ask when the final AG2 message fails validation").argument("[args...]", "claude arguments, e.g. -p '<task>' --allowedTools 'Read,Edit,Bash'").action(async (args, opts) => {
16009
17287
  try {
16010
17288
  const className = opts.class ?? opts.role;
17289
+ if (opts.report !== void 0 && opts.report !== "json" && opts.report !== "ag2") throw new Error(`--report: expected "json" or "ag2", got "${opts.report}"`);
17290
+ const reportFormatFlag = opts.report;
16011
17291
  if (opts.cwd !== void 0) {
16012
17292
  if (!existsSync(opts.cwd)) throw new Error(`--cwd: directory does not exist: ${opts.cwd}`);
16013
17293
  if (!statSync(opts.cwd).isDirectory()) throw new Error(`--cwd: not a directory: ${opts.cwd}`);
@@ -16072,6 +17352,8 @@ function registerWorkerCommands(workerCmd) {
16072
17352
  noPane: opts.pane === false,
16073
17353
  worktreeFlag: opts.worktree,
16074
17354
  printCmd: opts.printCmd,
17355
+ reportFormatFlag,
17356
+ noReportRetry: opts.reportRetry === false,
16075
17357
  claudeArgs
16076
17358
  });
16077
17359
  process.exitCode = rc;
@@ -18630,4 +19912,4 @@ claude() {
18630
19912
 
18631
19913
  //#endregion
18632
19914
  export { drainStdio as n, buildProgram as t };
18633
- //# sourceMappingURL=program-LLNPpxlj.mjs.map
19915
+ //# sourceMappingURL=program-cr5U1-DK.mjs.map