reasonix 0.4.22 → 0.4.24

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -506,6 +506,174 @@ function resolveTemperatures(budget, custom) {
506
506
  return out;
507
507
  }
508
508
 
509
+ // src/hooks.ts
510
+ import { spawn } from "child_process";
511
+ import { existsSync, readFileSync as readFileSync2 } from "fs";
512
+ import { homedir as homedir2 } from "os";
513
+ import { join as join2 } from "path";
514
+ var HOOK_EVENTS = [
515
+ "PreToolUse",
516
+ "PostToolUse",
517
+ "UserPromptSubmit",
518
+ "Stop"
519
+ ];
520
+ var BLOCKING_EVENTS = /* @__PURE__ */ new Set(["PreToolUse", "UserPromptSubmit"]);
521
+ var DEFAULT_TIMEOUTS_MS = {
522
+ PreToolUse: 5e3,
523
+ UserPromptSubmit: 5e3,
524
+ PostToolUse: 3e4,
525
+ Stop: 3e4
526
+ };
527
+ var HOOK_SETTINGS_FILENAME = "settings.json";
528
+ var HOOK_SETTINGS_DIRNAME = ".reasonix";
529
+ function globalSettingsPath(homeDirOverride) {
530
+ return join2(homeDirOverride ?? homedir2(), HOOK_SETTINGS_DIRNAME, HOOK_SETTINGS_FILENAME);
531
+ }
532
+ function projectSettingsPath(projectRoot) {
533
+ return join2(projectRoot, HOOK_SETTINGS_DIRNAME, HOOK_SETTINGS_FILENAME);
534
+ }
535
+ function readSettingsFile(path) {
536
+ if (!existsSync(path)) return null;
537
+ try {
538
+ const raw = readFileSync2(path, "utf8");
539
+ const parsed = JSON.parse(raw);
540
+ if (parsed && typeof parsed === "object") return parsed;
541
+ } catch {
542
+ }
543
+ return null;
544
+ }
545
+ function loadHooks(opts = {}) {
546
+ const out = [];
547
+ if (opts.projectRoot) {
548
+ const projPath = projectSettingsPath(opts.projectRoot);
549
+ const settings2 = readSettingsFile(projPath);
550
+ if (settings2) appendResolved(out, settings2, "project", projPath);
551
+ }
552
+ const globalPath = globalSettingsPath(opts.homeDir);
553
+ const settings = readSettingsFile(globalPath);
554
+ if (settings) appendResolved(out, settings, "global", globalPath);
555
+ return out;
556
+ }
557
+ function appendResolved(out, settings, scope, source) {
558
+ if (!settings.hooks) return;
559
+ for (const event of HOOK_EVENTS) {
560
+ const list = settings.hooks[event];
561
+ if (!Array.isArray(list)) continue;
562
+ for (const cfg of list) {
563
+ if (!cfg || typeof cfg.command !== "string" || cfg.command.trim() === "") continue;
564
+ out.push({ ...cfg, event, scope, source });
565
+ }
566
+ }
567
+ }
568
+ function matchesTool(hook, toolName) {
569
+ if (hook.event !== "PreToolUse" && hook.event !== "PostToolUse") return true;
570
+ const m = hook.match;
571
+ if (!m || m === "*") return true;
572
+ try {
573
+ const re = new RegExp(`^(?:${m})$`);
574
+ return re.test(toolName);
575
+ } catch {
576
+ return false;
577
+ }
578
+ }
579
+ function defaultSpawner(input) {
580
+ return new Promise((resolve6) => {
581
+ const child = spawn(input.command, {
582
+ cwd: input.cwd,
583
+ shell: true,
584
+ stdio: ["pipe", "pipe", "pipe"]
585
+ });
586
+ let stdout2 = "";
587
+ let stderr = "";
588
+ let timedOut = false;
589
+ const timer = setTimeout(() => {
590
+ timedOut = true;
591
+ child.kill("SIGTERM");
592
+ setTimeout(() => {
593
+ try {
594
+ child.kill("SIGKILL");
595
+ } catch {
596
+ }
597
+ }, 500);
598
+ }, input.timeoutMs);
599
+ child.stdout.on("data", (chunk) => {
600
+ stdout2 += chunk.toString("utf8");
601
+ });
602
+ child.stderr.on("data", (chunk) => {
603
+ stderr += chunk.toString("utf8");
604
+ });
605
+ child.once("error", (err) => {
606
+ clearTimeout(timer);
607
+ resolve6({
608
+ exitCode: null,
609
+ stdout: stdout2,
610
+ stderr,
611
+ timedOut: false,
612
+ spawnError: err
613
+ });
614
+ });
615
+ child.once("close", (code) => {
616
+ clearTimeout(timer);
617
+ resolve6({
618
+ exitCode: code,
619
+ stdout: stdout2.trim(),
620
+ stderr: stderr.trim(),
621
+ timedOut
622
+ });
623
+ });
624
+ try {
625
+ child.stdin.write(input.stdin);
626
+ child.stdin.end();
627
+ } catch {
628
+ }
629
+ });
630
+ }
631
+ function formatHookOutcomeMessage(outcome) {
632
+ if (outcome.decision === "pass") return "";
633
+ const detail = (outcome.stderr || outcome.stdout || "").trim();
634
+ const tag = `${outcome.hook.scope}/${outcome.hook.event}`;
635
+ const cmd = outcome.hook.command.length > 60 ? `${outcome.hook.command.slice(0, 60)}\u2026` : outcome.hook.command;
636
+ const head = `hook ${tag} \`${cmd}\` ${outcome.decision}`;
637
+ return detail ? `${head}: ${detail}` : head;
638
+ }
639
+ function decideOutcome(event, raw) {
640
+ if (raw.spawnError) return "error";
641
+ if (raw.timedOut) return BLOCKING_EVENTS.has(event) ? "block" : "warn";
642
+ if (raw.exitCode === 0) return "pass";
643
+ if (raw.exitCode === 2 && BLOCKING_EVENTS.has(event)) return "block";
644
+ return "warn";
645
+ }
646
+ async function runHooks(opts) {
647
+ const spawner = opts.spawner ?? defaultSpawner;
648
+ const event = opts.payload.event;
649
+ const toolName = opts.payload.toolName ?? "";
650
+ const matching = opts.hooks.filter((h) => h.event === event && matchesTool(h, toolName));
651
+ const outcomes = [];
652
+ let blocked = false;
653
+ const stdin2 = `${JSON.stringify(opts.payload)}
654
+ `;
655
+ for (const hook of matching) {
656
+ const start = Date.now();
657
+ const timeoutMs = hook.timeout ?? DEFAULT_TIMEOUTS_MS[event];
658
+ const cwd = hook.cwd ?? opts.payload.cwd;
659
+ const raw = await spawner({ command: hook.command, cwd, stdin: stdin2, timeoutMs });
660
+ const decision = decideOutcome(event, raw);
661
+ outcomes.push({
662
+ hook,
663
+ decision,
664
+ exitCode: raw.exitCode,
665
+ stdout: raw.stdout,
666
+ stderr: raw.stderr || (raw.spawnError ? raw.spawnError.message : "") || (raw.timedOut ? `hook timed out after ${timeoutMs}ms` : ""),
667
+ durationMs: Date.now() - start
668
+ });
669
+ if (decision === "block") {
670
+ blocked = true;
671
+ break;
672
+ }
673
+ }
674
+ return { event, outcomes, blocked };
675
+ }
676
+
509
677
  // src/repair/flatten.ts
510
678
  function analyzeSchema(schema) {
511
679
  if (!schema) return { shouldFlatten: false, leafCount: 0, maxDepth: 0 };
@@ -1128,21 +1296,21 @@ function signature2(call) {
1128
1296
  import {
1129
1297
  appendFileSync,
1130
1298
  chmodSync as chmodSync2,
1131
- existsSync,
1299
+ existsSync as existsSync2,
1132
1300
  mkdirSync as mkdirSync2,
1133
- readFileSync as readFileSync2,
1301
+ readFileSync as readFileSync3,
1134
1302
  readdirSync,
1135
1303
  statSync,
1136
1304
  unlinkSync,
1137
1305
  writeFileSync as writeFileSync2
1138
1306
  } from "fs";
1139
- import { homedir as homedir2 } from "os";
1140
- import { dirname as dirname2, join as join2 } from "path";
1307
+ import { homedir as homedir3 } from "os";
1308
+ import { dirname as dirname2, join as join3 } from "path";
1141
1309
  function sessionsDir() {
1142
- return join2(homedir2(), ".reasonix", "sessions");
1310
+ return join3(homedir3(), ".reasonix", "sessions");
1143
1311
  }
1144
1312
  function sessionPath(name) {
1145
- return join2(sessionsDir(), `${sanitizeName(name)}.jsonl`);
1313
+ return join3(sessionsDir(), `${sanitizeName(name)}.jsonl`);
1146
1314
  }
1147
1315
  function sanitizeName(name) {
1148
1316
  const cleaned = name.replace(/[^\w\-\u4e00-\u9fa5]/g, "_").slice(0, 64);
@@ -1150,9 +1318,9 @@ function sanitizeName(name) {
1150
1318
  }
1151
1319
  function loadSessionMessages(name) {
1152
1320
  const path = sessionPath(name);
1153
- if (!existsSync(path)) return [];
1321
+ if (!existsSync2(path)) return [];
1154
1322
  try {
1155
- const raw = readFileSync2(path, "utf8");
1323
+ const raw = readFileSync3(path, "utf8");
1156
1324
  const out = [];
1157
1325
  for (const line of raw.split(/\r?\n/)) {
1158
1326
  const trimmed = line.trim();
@@ -1180,11 +1348,11 @@ function appendSessionMessage(name, message) {
1180
1348
  }
1181
1349
  function listSessions() {
1182
1350
  const dir = sessionsDir();
1183
- if (!existsSync(dir)) return [];
1351
+ if (!existsSync2(dir)) return [];
1184
1352
  try {
1185
1353
  const files = readdirSync(dir).filter((f) => f.endsWith(".jsonl"));
1186
1354
  return files.map((file) => {
1187
- const path = join2(dir, file);
1355
+ const path = join3(dir, file);
1188
1356
  const stat = statSync(path);
1189
1357
  const name = file.replace(/\.jsonl$/, "");
1190
1358
  const messageCount = countLines(path);
@@ -1216,7 +1384,7 @@ function rewriteSession(name, messages) {
1216
1384
  }
1217
1385
  function countLines(path) {
1218
1386
  try {
1219
- const raw = readFileSync2(path, "utf8");
1387
+ const raw = readFileSync3(path, "utf8");
1220
1388
  return raw.split(/\r?\n/).filter((l) => l.trim()).length;
1221
1389
  } catch {
1222
1390
  return 0;
@@ -1330,6 +1498,14 @@ var CacheFirstLoop = class {
1330
1498
  branchEnabled;
1331
1499
  branchOptions;
1332
1500
  sessionName;
1501
+ /**
1502
+ * Hook list, mutable so `/hooks reload` can swap it without
1503
+ * reconstructing the loop. Default empty — the filter cost on a
1504
+ * tool call is one array length check.
1505
+ */
1506
+ hooks;
1507
+ /** `cwd` reported to hook stdin. Resolved once at construction. */
1508
+ hookCwd;
1333
1509
  /** Number of messages that were pre-loaded from the session file. */
1334
1510
  resumedMessageCount;
1335
1511
  _turn = 0;
@@ -1348,6 +1524,8 @@ var CacheFirstLoop = class {
1348
1524
  this.tools = opts.tools ?? new ToolRegistry();
1349
1525
  this.model = opts.model ?? "deepseek-chat";
1350
1526
  this.maxToolIters = opts.maxToolIters ?? 64;
1527
+ this.hooks = opts.hooks ?? [];
1528
+ this.hookCwd = opts.hookCwd ?? process.cwd();
1351
1529
  if (typeof opts.branch === "number") {
1352
1530
  this.branchOptions = { budget: opts.branch };
1353
1531
  } else if (opts.branch && typeof opts.branch === "object") {
@@ -1811,7 +1989,37 @@ var CacheFirstLoop = class {
1811
1989
  toolName: name,
1812
1990
  toolArgs: args
1813
1991
  };
1814
- const result = await this.tools.dispatch(name, args, { signal });
1992
+ const parsedArgs = safeParseToolArgs(args);
1993
+ const preReport = await runHooks({
1994
+ hooks: this.hooks,
1995
+ payload: {
1996
+ event: "PreToolUse",
1997
+ cwd: this.hookCwd,
1998
+ toolName: name,
1999
+ toolArgs: parsedArgs
2000
+ }
2001
+ });
2002
+ for (const w of hookWarnings(preReport.outcomes, this._turn)) yield w;
2003
+ let result;
2004
+ if (preReport.blocked) {
2005
+ const blocking = preReport.outcomes[preReport.outcomes.length - 1];
2006
+ const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
2007
+ result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}
2008
+ ${reason}`;
2009
+ } else {
2010
+ result = await this.tools.dispatch(name, args, { signal });
2011
+ const postReport = await runHooks({
2012
+ hooks: this.hooks,
2013
+ payload: {
2014
+ event: "PostToolUse",
2015
+ cwd: this.hookCwd,
2016
+ toolName: name,
2017
+ toolArgs: parsedArgs,
2018
+ toolResult: result
2019
+ }
2020
+ });
2021
+ for (const w of hookWarnings(postReport.outcomes, this._turn)) yield w;
2022
+ }
1815
2023
  this.appendAndPersist({
1816
2024
  role: "tool",
1817
2025
  tool_call_id: call.id ?? "",
@@ -1898,6 +2106,19 @@ function stripHallucinatedToolMarkup(s) {
1898
2106
  out = out.replace(/<|DSML|[\s\S]*$/g, "");
1899
2107
  return out.trim();
1900
2108
  }
2109
+ function safeParseToolArgs(raw) {
2110
+ try {
2111
+ return JSON.parse(raw);
2112
+ } catch {
2113
+ return raw;
2114
+ }
2115
+ }
2116
+ function* hookWarnings(outcomes, turn) {
2117
+ for (const o of outcomes) {
2118
+ if (o.decision === "pass") continue;
2119
+ yield { turn, role: "warning", content: formatHookOutcomeMessage(o) };
2120
+ }
2121
+ }
1901
2122
  function reasonPrefixFor(reason, iterCap) {
1902
2123
  if (reason === "aborted") return "[aborted by user (Esc) \u2014 summarizing what I found so far]";
1903
2124
  if (reason === "context-guard") {
@@ -2238,10 +2459,10 @@ function registerFilesystemTools(registry, opts) {
2238
2459
  const after = before.slice(0, firstIdx) + args.replace + before.slice(firstIdx + args.search.length);
2239
2460
  await fs.writeFile(abs, after, "utf8");
2240
2461
  const rel = pathMod.relative(rootDir, abs);
2241
- const header = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
2462
+ const header2 = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
2242
2463
  const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
2243
2464
  const diff = renderEditDiff(args.search, args.replace, startLine);
2244
- return `${header}
2465
+ return `${header2}
2245
2466
  ${diff}`;
2246
2467
  }
2247
2468
  });
@@ -2493,8 +2714,8 @@ function registerPlanTool(registry, opts = {}) {
2493
2714
  }
2494
2715
 
2495
2716
  // src/tools/shell.ts
2496
- import { spawn } from "child_process";
2497
- import { existsSync as existsSync2, statSync as statSync2 } from "fs";
2717
+ import { spawn as spawn2 } from "child_process";
2718
+ import { existsSync as existsSync3, statSync as statSync2 } from "fs";
2498
2719
  import * as pathMod2 from "path";
2499
2720
  var DEFAULT_TIMEOUT_SEC = 60;
2500
2721
  var DEFAULT_MAX_OUTPUT_CHARS = 32e3;
@@ -2617,7 +2838,7 @@ async function runCommand(cmd, opts) {
2617
2838
  return await new Promise((resolve6, reject) => {
2618
2839
  let child;
2619
2840
  try {
2620
- child = spawn(bin, args, effectiveSpawnOpts);
2841
+ child = spawn2(bin, args, effectiveSpawnOpts);
2621
2842
  } catch (err) {
2622
2843
  reject(err);
2623
2844
  return;
@@ -2672,7 +2893,7 @@ function resolveExecutable(cmd, opts = {}) {
2672
2893
  }
2673
2894
  function defaultIsFile(full) {
2674
2895
  try {
2675
- return existsSync2(full) && statSync2(full).isFile();
2896
+ return existsSync3(full) && statSync2(full).isFile();
2676
2897
  } catch {
2677
2898
  return false;
2678
2899
  }
@@ -2784,11 +3005,11 @@ function registerShellTools(registry, opts) {
2784
3005
  return registry;
2785
3006
  }
2786
3007
  function formatCommandResult(cmd, r) {
2787
- const header = r.timedOut ? `$ ${cmd}
3008
+ const header2 = r.timedOut ? `$ ${cmd}
2788
3009
  [killed after timeout]` : `$ ${cmd}
2789
3010
  [exit ${r.exitCode ?? "?"}]`;
2790
- return r.output ? `${header}
2791
- ${r.output}` : header;
3011
+ return r.output ? `${header2}
3012
+ ${r.output}` : header2;
2792
3013
  }
2793
3014
 
2794
3015
  // src/tools/web.ts
@@ -2953,9 +3174,9 @@ function registerWebTools(registry, opts = {}) {
2953
3174
  throw new Error("web_fetch: url must start with http:// or https://");
2954
3175
  }
2955
3176
  const page = await webFetch(args.url, { maxChars: maxFetchChars, signal: ctx?.signal });
2956
- const header = page.title ? `${page.title}
3177
+ const header2 = page.title ? `${page.title}
2957
3178
  ${page.url}` : page.url;
2958
- return `${header}
3179
+ return `${header2}
2959
3180
 
2960
3181
  ${page.text}`;
2961
3182
  }
@@ -2975,12 +3196,12 @@ ${i + 1}. ${r.title}`);
2975
3196
  }
2976
3197
 
2977
3198
  // src/env.ts
2978
- import { readFileSync as readFileSync3 } from "fs";
3199
+ import { readFileSync as readFileSync4 } from "fs";
2979
3200
  import { resolve as resolve3 } from "path";
2980
3201
  function loadDotenv(path = ".env") {
2981
3202
  let raw;
2982
3203
  try {
2983
- raw = readFileSync3(resolve3(process.cwd(), path), "utf8");
3204
+ raw = readFileSync4(resolve3(process.cwd(), path), "utf8");
2984
3205
  } catch {
2985
3206
  return;
2986
3207
  }
@@ -2999,7 +3220,7 @@ function loadDotenv(path = ".env") {
2999
3220
  }
3000
3221
 
3001
3222
  // src/transcript.ts
3002
- import { createWriteStream, readFileSync as readFileSync4 } from "fs";
3223
+ import { createWriteStream, readFileSync as readFileSync5 } from "fs";
3003
3224
  function recordFromLoopEvent(ev, extra) {
3004
3225
  const rec = {
3005
3226
  ts: (/* @__PURE__ */ new Date()).toISOString(),
@@ -3050,7 +3271,7 @@ function openTranscriptFile(path, meta) {
3050
3271
  return stream;
3051
3272
  }
3052
3273
  function readTranscript(path) {
3053
- const raw = readFileSync4(path, "utf8");
3274
+ const raw = readFileSync5(path, "utf8");
3054
3275
  return parseTranscript(raw);
3055
3276
  }
3056
3277
  function isPlanStateEmptyShape(s) {
@@ -3784,7 +4005,7 @@ var McpClient = class {
3784
4005
  };
3785
4006
 
3786
4007
  // src/mcp/stdio.ts
3787
- import { spawn as spawn2 } from "child_process";
4008
+ import { spawn as spawn3 } from "child_process";
3788
4009
  var StdioTransport = class {
3789
4010
  child;
3790
4011
  queue = [];
@@ -3799,14 +4020,14 @@ var StdioTransport = class {
3799
4020
  opts.command,
3800
4021
  ...(opts.args ?? []).map((a) => quoteArg(a, process.platform === "win32"))
3801
4022
  ].join(" ");
3802
- this.child = spawn2(line, [], {
4023
+ this.child = spawn3(line, [], {
3803
4024
  env,
3804
4025
  cwd: opts.cwd,
3805
4026
  stdio: ["pipe", "pipe", "inherit"],
3806
4027
  shell: true
3807
4028
  });
3808
4029
  } else {
3809
- this.child = spawn2(opts.command, opts.args ?? [], {
4030
+ this.child = spawn3(opts.command, opts.args ?? [], {
3810
4031
  env,
3811
4032
  cwd: opts.cwd,
3812
4033
  stdio: ["pipe", "pipe", "inherit"]
@@ -4136,7 +4357,7 @@ async function trySection(load) {
4136
4357
  }
4137
4358
 
4138
4359
  // src/code/edit-blocks.ts
4139
- import { existsSync as existsSync3, mkdirSync as mkdirSync3, readFileSync as readFileSync5, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
4360
+ import { existsSync as existsSync4, mkdirSync as mkdirSync3, readFileSync as readFileSync6, unlinkSync as unlinkSync2, writeFileSync as writeFileSync3 } from "fs";
4140
4361
  import { dirname as dirname4, resolve as resolve4 } from "path";
4141
4362
  var BLOCK_RE = /^(\S[^\n]*)\n<{7} SEARCH\n([\s\S]*?)\n?={7}\n([\s\S]*?)\n?>{7} REPLACE/gm;
4142
4363
  function parseEditBlocks(text) {
@@ -4165,7 +4386,7 @@ function applyEditBlock(block, rootDir) {
4165
4386
  };
4166
4387
  }
4167
4388
  const searchEmpty = block.search.length === 0;
4168
- const exists = existsSync3(absTarget);
4389
+ const exists = existsSync4(absTarget);
4169
4390
  try {
4170
4391
  if (!exists) {
4171
4392
  if (!searchEmpty) {
@@ -4179,7 +4400,7 @@ function applyEditBlock(block, rootDir) {
4179
4400
  writeFileSync3(absTarget, block.replace, "utf8");
4180
4401
  return { path: block.path, status: "created" };
4181
4402
  }
4182
- const content = readFileSync5(absTarget, "utf8");
4403
+ const content = readFileSync6(absTarget, "utf8");
4183
4404
  if (searchEmpty) {
4184
4405
  return {
4185
4406
  path: block.path,
@@ -4213,12 +4434,12 @@ function snapshotBeforeEdits(blocks, rootDir) {
4213
4434
  if (seen.has(b.path)) continue;
4214
4435
  seen.add(b.path);
4215
4436
  const abs = resolve4(absRoot, b.path);
4216
- if (!existsSync3(abs)) {
4437
+ if (!existsSync4(abs)) {
4217
4438
  snapshots.push({ path: b.path, prevContent: null });
4218
4439
  continue;
4219
4440
  }
4220
4441
  try {
4221
- snapshots.push({ path: b.path, prevContent: readFileSync5(abs, "utf8") });
4442
+ snapshots.push({ path: b.path, prevContent: readFileSync6(abs, "utf8") });
4222
4443
  } catch {
4223
4444
  snapshots.push({ path: b.path, prevContent: null });
4224
4445
  }
@@ -4238,7 +4459,7 @@ function restoreSnapshots(snapshots, rootDir) {
4238
4459
  }
4239
4460
  try {
4240
4461
  if (snap.prevContent === null) {
4241
- if (existsSync3(abs)) unlinkSync2(abs);
4462
+ if (existsSync4(abs)) unlinkSync2(abs);
4242
4463
  return {
4243
4464
  path: snap.path,
4244
4465
  status: "applied",
@@ -4261,9 +4482,9 @@ function sep() {
4261
4482
  }
4262
4483
 
4263
4484
  // src/version.ts
4264
- import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
4265
- import { homedir as homedir3 } from "os";
4266
- import { dirname as dirname5, join as join4 } from "path";
4485
+ import { existsSync as existsSync5, mkdirSync as mkdirSync4, readFileSync as readFileSync7, writeFileSync as writeFileSync4 } from "fs";
4486
+ import { homedir as homedir4 } from "os";
4487
+ import { dirname as dirname5, join as join5 } from "path";
4267
4488
  import { fileURLToPath } from "url";
4268
4489
  var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
4269
4490
  var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -4272,9 +4493,9 @@ function readPackageVersion() {
4272
4493
  try {
4273
4494
  let dir = dirname5(fileURLToPath(import.meta.url));
4274
4495
  for (let i = 0; i < 6; i++) {
4275
- const p = join4(dir, "package.json");
4276
- if (existsSync4(p)) {
4277
- const pkg = JSON.parse(readFileSync6(p, "utf8"));
4496
+ const p = join5(dir, "package.json");
4497
+ if (existsSync5(p)) {
4498
+ const pkg = JSON.parse(readFileSync7(p, "utf8"));
4278
4499
  if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
4279
4500
  return pkg.version;
4280
4501
  }
@@ -4289,11 +4510,11 @@ function readPackageVersion() {
4289
4510
  }
4290
4511
  var VERSION = readPackageVersion();
4291
4512
  function cachePath(homeDirOverride) {
4292
- return join4(homeDirOverride ?? homedir3(), ".reasonix", "version-cache.json");
4513
+ return join5(homeDirOverride ?? homedir4(), ".reasonix", "version-cache.json");
4293
4514
  }
4294
4515
  function readCache(homeDirOverride) {
4295
4516
  try {
4296
- const raw = readFileSync6(cachePath(homeDirOverride), "utf8");
4517
+ const raw = readFileSync7(cachePath(homeDirOverride), "utf8");
4297
4518
  const parsed = JSON.parse(raw);
4298
4519
  if (parsed && typeof parsed.version === "string" && typeof parsed.checkedAt === "number") {
4299
4520
  return parsed;
@@ -4361,8 +4582,134 @@ function isNpxInstall() {
4361
4582
  return false;
4362
4583
  }
4363
4584
 
4585
+ // src/usage.ts
4586
+ import { appendFileSync as appendFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, statSync as statSync3 } from "fs";
4587
+ import { homedir as homedir5 } from "os";
4588
+ import { dirname as dirname6, join as join6 } from "path";
4589
+ function defaultUsageLogPath(homeDirOverride) {
4590
+ return join6(homeDirOverride ?? homedir5(), ".reasonix", "usage.jsonl");
4591
+ }
4592
+ function appendUsage(input) {
4593
+ const record = {
4594
+ ts: input.now ?? Date.now(),
4595
+ session: input.session,
4596
+ model: input.model,
4597
+ promptTokens: input.usage.promptTokens,
4598
+ completionTokens: input.usage.completionTokens,
4599
+ cacheHitTokens: input.usage.promptCacheHitTokens,
4600
+ cacheMissTokens: input.usage.promptCacheMissTokens,
4601
+ costUsd: costUsd(input.model, input.usage),
4602
+ claudeEquivUsd: claudeEquivalentCost(input.usage)
4603
+ };
4604
+ const path = input.path ?? defaultUsageLogPath();
4605
+ try {
4606
+ mkdirSync5(dirname6(path), { recursive: true });
4607
+ appendFileSync2(path, `${JSON.stringify(record)}
4608
+ `, "utf8");
4609
+ } catch {
4610
+ }
4611
+ return record;
4612
+ }
4613
+ function readUsageLog(path = defaultUsageLogPath()) {
4614
+ if (!existsSync6(path)) return [];
4615
+ let raw;
4616
+ try {
4617
+ raw = readFileSync8(path, "utf8");
4618
+ } catch {
4619
+ return [];
4620
+ }
4621
+ const out = [];
4622
+ for (const line of raw.split(/\r?\n/)) {
4623
+ if (!line.trim()) continue;
4624
+ try {
4625
+ const rec = JSON.parse(line);
4626
+ if (isValidRecord(rec)) out.push(rec);
4627
+ } catch {
4628
+ }
4629
+ }
4630
+ return out;
4631
+ }
4632
+ function isValidRecord(rec) {
4633
+ if (!rec || typeof rec !== "object") return false;
4634
+ const r = rec;
4635
+ return typeof r.ts === "number" && typeof r.model === "string" && typeof r.promptTokens === "number" && typeof r.completionTokens === "number" && typeof r.cacheHitTokens === "number" && typeof r.cacheMissTokens === "number" && typeof r.costUsd === "number" && typeof r.claudeEquivUsd === "number";
4636
+ }
4637
+ function bucketCacheHitRatio(b) {
4638
+ const denom = b.cacheHitTokens + b.cacheMissTokens;
4639
+ return denom > 0 ? b.cacheHitTokens / denom : 0;
4640
+ }
4641
+ function bucketSavingsFraction(b) {
4642
+ return b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
4643
+ }
4644
+ function emptyBucket(label, since) {
4645
+ return {
4646
+ label,
4647
+ since,
4648
+ turns: 0,
4649
+ promptTokens: 0,
4650
+ completionTokens: 0,
4651
+ cacheHitTokens: 0,
4652
+ cacheMissTokens: 0,
4653
+ costUsd: 0,
4654
+ claudeEquivUsd: 0
4655
+ };
4656
+ }
4657
+ function addToBucket(b, r) {
4658
+ b.turns += 1;
4659
+ b.promptTokens += r.promptTokens;
4660
+ b.completionTokens += r.completionTokens;
4661
+ b.cacheHitTokens += r.cacheHitTokens;
4662
+ b.cacheMissTokens += r.cacheMissTokens;
4663
+ b.costUsd += r.costUsd;
4664
+ b.claudeEquivUsd += r.claudeEquivUsd;
4665
+ }
4666
+ function aggregateUsage(records, opts = {}) {
4667
+ const now = opts.now ?? Date.now();
4668
+ const day = 24 * 60 * 60 * 1e3;
4669
+ const today = emptyBucket("today", now - day);
4670
+ const week = emptyBucket("week", now - 7 * day);
4671
+ const month = emptyBucket("month", now - 30 * day);
4672
+ const all = emptyBucket("all-time", 0);
4673
+ const modelCounts = /* @__PURE__ */ new Map();
4674
+ const sessionCounts = /* @__PURE__ */ new Map();
4675
+ let firstSeen = null;
4676
+ let lastSeen = null;
4677
+ for (const r of records) {
4678
+ addToBucket(all, r);
4679
+ if (r.ts >= today.since) addToBucket(today, r);
4680
+ if (r.ts >= week.since) addToBucket(week, r);
4681
+ if (r.ts >= month.since) addToBucket(month, r);
4682
+ modelCounts.set(r.model, (modelCounts.get(r.model) ?? 0) + 1);
4683
+ const sessKey = r.session ?? "(ephemeral)";
4684
+ sessionCounts.set(sessKey, (sessionCounts.get(sessKey) ?? 0) + 1);
4685
+ if (firstSeen === null || r.ts < firstSeen) firstSeen = r.ts;
4686
+ if (lastSeen === null || r.ts > lastSeen) lastSeen = r.ts;
4687
+ }
4688
+ const byModel = Array.from(modelCounts.entries()).map(([model, turns]) => ({ model, turns })).sort((a, b) => b.turns - a.turns);
4689
+ const bySession = Array.from(sessionCounts.entries()).map(([session, turns]) => ({ session, turns })).sort((a, b) => b.turns - a.turns);
4690
+ return {
4691
+ buckets: [today, week, month, all],
4692
+ byModel,
4693
+ bySession,
4694
+ firstSeen,
4695
+ lastSeen
4696
+ };
4697
+ }
4698
+ function formatLogSize(path = defaultUsageLogPath()) {
4699
+ if (!existsSync6(path)) return "";
4700
+ try {
4701
+ const s = statSync3(path);
4702
+ const bytes = s.size;
4703
+ if (bytes < 1024) return `${bytes} B`;
4704
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
4705
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
4706
+ } catch {
4707
+ return "";
4708
+ }
4709
+ }
4710
+
4364
4711
  // src/cli/commands/chat.tsx
4365
- import { existsSync as existsSync5, statSync as statSync3 } from "fs";
4712
+ import { existsSync as existsSync8, statSync as statSync4 } from "fs";
4366
4713
  import { render } from "ink";
4367
4714
  import React15, { useState as useState7 } from "react";
4368
4715
 
@@ -4401,7 +4748,7 @@ function registerSkillTools(registry, opts = {}) {
4401
4748
  });
4402
4749
  }
4403
4750
  const rawArgs = typeof args.arguments === "string" ? args.arguments.trim() : "";
4404
- const header = [
4751
+ const header2 = [
4405
4752
  `# Skill: ${skill.name}`,
4406
4753
  skill.description ? `> ${skill.description}` : "",
4407
4754
  `(scope: ${skill.scope} \xB7 ${skill.path})`
@@ -4409,7 +4756,7 @@ function registerSkillTools(registry, opts = {}) {
4409
4756
  const argsBlock = rawArgs ? `
4410
4757
 
4411
4758
  Arguments: ${rawArgs}` : "";
4412
- return `${header}
4759
+ return `${header2}
4413
4760
 
4414
4761
  ${skill.body}${argsBlock}`;
4415
4762
  }
@@ -4638,7 +4985,7 @@ function parseBlocks(raw) {
4638
4985
  if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(next)) {
4639
4986
  flushPara();
4640
4987
  flushList();
4641
- const header = splitTableRow(line);
4988
+ const header2 = splitTableRow(line);
4642
4989
  const rows = [];
4643
4990
  let j = i + 2;
4644
4991
  while (j < lines.length) {
@@ -4647,7 +4994,7 @@ function parseBlocks(raw) {
4647
4994
  rows.push(splitTableRow(r));
4648
4995
  j++;
4649
4996
  }
4650
- out.push({ kind: "table", header, rows });
4997
+ out.push({ kind: "table", header: header2, rows });
4651
4998
  i = j - 1;
4652
4999
  continue;
4653
5000
  }
@@ -4714,7 +5061,7 @@ function TableBlockRow({ block }) {
4714
5061
  for (const r of block.rows) cellLengths.push(displayWidth(r[c] ?? ""));
4715
5062
  widths.push(Math.min(40, Math.max(3, ...cellLengths)));
4716
5063
  }
4717
- const pad2 = (s, w) => {
5064
+ const pad3 = (s, w) => {
4718
5065
  const dw = displayWidth(s);
4719
5066
  if (dw >= w) return s;
4720
5067
  return s + " ".repeat(w - dw);
@@ -4722,12 +5069,12 @@ function TableBlockRow({ block }) {
4722
5069
  const separator = widths.map((w) => "\u2500".repeat(w)).join("\u2500\u253C\u2500");
4723
5070
  return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Box2, null, block.header.map((cell, ci) => (
4724
5071
  // biome-ignore lint/suspicious/noArrayIndexKey: table columns never reorder — derived from a static header array
4725
- /* @__PURE__ */ React2.createElement(Text2, { key: `h-${ci}`, bold: true, color: "cyan" }, pad2(cell, widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
5072
+ /* @__PURE__ */ React2.createElement(Text2, { key: `h-${ci}`, bold: true, color: "cyan" }, pad3(cell, widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
4726
5073
  ))), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, separator), block.rows.map((row2, ri) => (
4727
5074
  // biome-ignore lint/suspicious/noArrayIndexKey: table rows render in source order and don't reorder
4728
5075
  /* @__PURE__ */ React2.createElement(Box2, { key: `r-${ri}` }, Array.from({ length: colCount }).map((_, ci) => (
4729
5076
  // biome-ignore lint/suspicious/noArrayIndexKey: same — column axis is fixed by the table shape
4730
- /* @__PURE__ */ React2.createElement(Text2, { key: `c-${ri}-${ci}` }, pad2(row2[ci] ?? "", widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
5077
+ /* @__PURE__ */ React2.createElement(Text2, { key: `c-${ri}-${ci}` }, pad3(row2[ci] ?? "", widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
4731
5078
  )))
4732
5079
  )));
4733
5080
  }
@@ -5372,6 +5719,114 @@ function formatTokens(n) {
5372
5719
 
5373
5720
  // src/cli/ui/slash.ts
5374
5721
  import { spawnSync } from "child_process";
5722
+
5723
+ // src/cli/commands/stats.ts
5724
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
5725
+ function statsCommand(opts) {
5726
+ if (opts.transcript) {
5727
+ transcriptSummary(opts.transcript);
5728
+ return;
5729
+ }
5730
+ dashboard(opts);
5731
+ }
5732
+ function transcriptSummary(path) {
5733
+ if (!existsSync7(path)) {
5734
+ console.error(`no such transcript: ${path}`);
5735
+ process.exit(1);
5736
+ }
5737
+ const lines = readFileSync9(path, "utf8").split(/\r?\n/).filter(Boolean);
5738
+ let assistantTurns = 0;
5739
+ let toolCalls = 0;
5740
+ let lastTurn = 0;
5741
+ for (const line of lines) {
5742
+ try {
5743
+ const rec = JSON.parse(line);
5744
+ if (rec.role === "assistant_final") assistantTurns++;
5745
+ if (rec.role === "tool") toolCalls++;
5746
+ if (typeof rec.turn === "number") lastTurn = Math.max(lastTurn, rec.turn);
5747
+ } catch {
5748
+ }
5749
+ }
5750
+ console.log(`transcript: ${path}`);
5751
+ console.log(`assistant turns: ${assistantTurns}`);
5752
+ console.log(`tool invocations: ${toolCalls}`);
5753
+ console.log(`last turn index: ${lastTurn}`);
5754
+ }
5755
+ function dashboard(opts) {
5756
+ const path = opts.logPath ?? defaultUsageLogPath();
5757
+ const records = readUsageLog(path);
5758
+ if (records.length === 0) {
5759
+ console.log("no usage data yet.");
5760
+ console.log("");
5761
+ console.log(` ${path}`);
5762
+ console.log("");
5763
+ console.log("run `reasonix chat`, `reasonix code`, or `reasonix run <task>` \u2014 every turn");
5764
+ console.log("appends one line to the log and `reasonix stats` will roll it up.");
5765
+ return;
5766
+ }
5767
+ const agg = aggregateUsage(records, { now: opts.now });
5768
+ console.log(renderDashboard(agg, path));
5769
+ }
5770
+ function renderDashboard(agg, logPath) {
5771
+ const lines = [];
5772
+ const size = formatLogSize(logPath);
5773
+ lines.push(`Reasonix usage \u2014 ${logPath}${size ? ` (${size})` : ""}`);
5774
+ lines.push("");
5775
+ lines.push(header());
5776
+ lines.push(divider());
5777
+ for (const b of agg.buckets) {
5778
+ lines.push(bucketRow(b));
5779
+ }
5780
+ lines.push("");
5781
+ if (agg.byModel.length > 0) {
5782
+ const totalTurns = agg.buckets[agg.buckets.length - 1]?.turns ?? 0;
5783
+ const top = agg.byModel[0];
5784
+ if (top && totalTurns > 0) {
5785
+ const pct2 = (top.turns / totalTurns * 100).toFixed(0);
5786
+ lines.push(`most used model: ${top.model} (${pct2}% of turns)`);
5787
+ }
5788
+ }
5789
+ if (agg.bySession.length > 0) {
5790
+ const top = agg.bySession[0];
5791
+ if (top) lines.push(`top session: ${top.session} (${top.turns} turns)`);
5792
+ }
5793
+ if (agg.firstSeen) {
5794
+ lines.push(`tracked since: ${new Date(agg.firstSeen).toISOString().slice(0, 10)}`);
5795
+ }
5796
+ return lines.join("\n");
5797
+ }
5798
+ function header() {
5799
+ return [
5800
+ pad("", 10),
5801
+ pad("turns", 8, "right"),
5802
+ pad("cache hit", 10, "right"),
5803
+ pad("cost (USD)", 14, "right"),
5804
+ pad("vs Claude", 14, "right"),
5805
+ pad("saved", 10, "right")
5806
+ ].join(" ");
5807
+ }
5808
+ function divider() {
5809
+ return "-".repeat(70);
5810
+ }
5811
+ function bucketRow(b) {
5812
+ const hit = bucketCacheHitRatio(b);
5813
+ const savings = bucketSavingsFraction(b);
5814
+ return [
5815
+ pad(b.label, 10),
5816
+ pad(b.turns.toString(), 8, "right"),
5817
+ pad(b.turns > 0 ? `${(hit * 100).toFixed(1)}%` : "\u2014", 10, "right"),
5818
+ pad(b.turns > 0 ? `$${b.costUsd.toFixed(6)}` : "\u2014", 14, "right"),
5819
+ pad(b.turns > 0 ? `$${b.claudeEquivUsd.toFixed(4)}` : "\u2014", 14, "right"),
5820
+ pad(b.turns > 0 && savings > 0 ? `${(savings * 100).toFixed(1)}%` : "\u2014", 10, "right")
5821
+ ].join(" ");
5822
+ }
5823
+ function pad(s, width, align = "left") {
5824
+ if (s.length >= width) return s;
5825
+ const fill = " ".repeat(width - s.length);
5826
+ return align === "right" ? `${fill}${s}` : `${s}${fill}`;
5827
+ }
5828
+
5829
+ // src/cli/ui/slash.ts
5375
5830
  var SLASH_COMMANDS = [
5376
5831
  { cmd: "help", summary: "show the full command reference" },
5377
5832
  { cmd: "status", summary: "current model, flags, context, session" },
@@ -5395,6 +5850,19 @@ var SLASH_COMMANDS = [
5395
5850
  argsHint: "[list|show <name>|<name> [args]]",
5396
5851
  summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)"
5397
5852
  },
5853
+ {
5854
+ cmd: "hooks",
5855
+ argsHint: "[reload]",
5856
+ summary: "list active hooks (settings.json under .reasonix/) \xB7 reload re-reads from disk"
5857
+ },
5858
+ {
5859
+ cmd: "update",
5860
+ summary: "show current vs latest version + the shell command to upgrade"
5861
+ },
5862
+ {
5863
+ cmd: "stats",
5864
+ summary: "cross-session cost dashboard (today / week / month / all-time \xB7 cache hit \xB7 vs Claude)"
5865
+ },
5398
5866
  { cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
5399
5867
  { cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
5400
5868
  { cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
@@ -5568,6 +6036,16 @@ function handleSlash(cmd, args, loop, ctx = {}) {
5568
6036
  case "skills": {
5569
6037
  return handleSkillSlash(args, ctx);
5570
6038
  }
6039
+ case "hook":
6040
+ case "hooks": {
6041
+ return handleHooksSlash(args, loop, ctx);
6042
+ }
6043
+ case "update": {
6044
+ return handleUpdateSlash(ctx);
6045
+ }
6046
+ case "stats": {
6047
+ return handleStatsSlash();
6048
+ }
5571
6049
  case "think":
5572
6050
  case "reasoning": {
5573
6051
  const raw = loop.scratch.reasoning;
@@ -5804,6 +6282,111 @@ ${entry.text}`
5804
6282
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
5805
6283
  }
5806
6284
  }
6285
+ function handleStatsSlash() {
6286
+ const path = defaultUsageLogPath();
6287
+ const records = readUsageLog(path);
6288
+ if (records.length === 0) {
6289
+ return {
6290
+ info: [
6291
+ "no usage data yet.",
6292
+ "",
6293
+ ` ${path}`,
6294
+ "",
6295
+ "every turn you run here appends one record \u2014 this session's turns",
6296
+ "will show up in the dashboard once you send a message."
6297
+ ].join("\n")
6298
+ };
6299
+ }
6300
+ const agg = aggregateUsage(records);
6301
+ return { info: renderDashboard(agg, path) };
6302
+ }
6303
+ function handleUpdateSlash(ctx) {
6304
+ const latest = ctx.latestVersion ?? null;
6305
+ const lines = [`current: reasonix ${VERSION}`];
6306
+ if (latest === null) {
6307
+ ctx.refreshLatestVersion?.();
6308
+ lines.push(
6309
+ "latest: (not yet resolved \u2014 background check in flight or offline)",
6310
+ "",
6311
+ "triggered a fresh registry fetch \u2014 retry `/update` in a few seconds,",
6312
+ "or run `reasonix update` in another terminal to force it synchronously."
6313
+ );
6314
+ return { info: lines.join("\n") };
6315
+ }
6316
+ lines.push(`latest: reasonix ${latest}`);
6317
+ const diff = compareVersions(VERSION, latest);
6318
+ if (diff >= 0) {
6319
+ lines.push("", "you're on the latest. nothing to do.");
6320
+ return { info: lines.join("\n") };
6321
+ }
6322
+ if (isNpxInstall()) {
6323
+ lines.push(
6324
+ "",
6325
+ "you're running via npx \u2014 the next `npx reasonix ...` launch will auto-fetch.",
6326
+ "to force a refresh sooner: `npm cache clean --force`."
6327
+ );
6328
+ } else {
6329
+ lines.push(
6330
+ "",
6331
+ "to upgrade, exit this session and run:",
6332
+ " reasonix update (interactive, dry-run supported via --dry-run)",
6333
+ " npm install -g reasonix@latest (direct)",
6334
+ "",
6335
+ "in-session install is deliberately disabled \u2014 the npm spawn would",
6336
+ "corrupt this TUI's rendering and Windows can lock the running binary."
6337
+ );
6338
+ }
6339
+ return { info: lines.join("\n") };
6340
+ }
6341
+ function handleHooksSlash(args, loop, ctx) {
6342
+ const sub = (args[0] ?? "").toLowerCase();
6343
+ if (sub === "reload") {
6344
+ if (!ctx.reloadHooks) {
6345
+ return {
6346
+ info: "/hooks reload is not available in this context (no reload callback wired)."
6347
+ };
6348
+ }
6349
+ const count = ctx.reloadHooks();
6350
+ return { info: `\u25B8 reloaded hooks \xB7 ${count} active` };
6351
+ }
6352
+ if (sub !== "" && sub !== "list" && sub !== "ls") {
6353
+ return {
6354
+ info: "usage: /hooks list active hooks\n /hooks reload re-read settings.json files"
6355
+ };
6356
+ }
6357
+ const hooks = loop.hooks;
6358
+ const projPath = ctx.codeRoot ? projectSettingsPath(ctx.codeRoot) : void 0;
6359
+ const globPath = globalSettingsPath();
6360
+ if (hooks.length === 0) {
6361
+ const lines2 = [
6362
+ "no hooks configured.",
6363
+ "",
6364
+ "drop a settings.json with a `hooks` key into either of:",
6365
+ ctx.codeRoot ? ` \xB7 ${projPath} (project)` : " \xB7 <project>/.reasonix/settings.json (project)",
6366
+ ` \xB7 ${globPath} (global)`,
6367
+ "",
6368
+ "events: PreToolUse, PostToolUse, UserPromptSubmit, Stop",
6369
+ "exit 0 = pass \xB7 exit 2 = block (Pre*) \xB7 other = warn"
6370
+ ];
6371
+ return { info: lines2.join("\n") };
6372
+ }
6373
+ const grouped = /* @__PURE__ */ new Map();
6374
+ for (const event of HOOK_EVENTS) grouped.set(event, []);
6375
+ for (const h of hooks) grouped.get(h.event)?.push(h);
6376
+ const lines = [`\u25B8 ${hooks.length} hook(s) loaded`];
6377
+ for (const event of HOOK_EVENTS) {
6378
+ const list = grouped.get(event) ?? [];
6379
+ if (list.length === 0) continue;
6380
+ lines.push("", `${event}:`);
6381
+ for (const h of list) {
6382
+ const match = h.match && h.match !== "*" ? ` match=${h.match}` : "";
6383
+ const desc = h.description ? ` \u2014 ${h.description}` : "";
6384
+ lines.push(` [${h.scope}]${match} ${h.command}${desc}`);
6385
+ }
6386
+ }
6387
+ lines.push("", `sources: project=${projPath ?? "(none \u2014 chat mode)"} \xB7 global=${globPath}`);
6388
+ return { info: lines.join("\n") };
6389
+ }
5807
6390
  function handleSkillSlash(args, ctx) {
5808
6391
  const store = new SkillStore({ projectRoot: ctx.codeRoot });
5809
6392
  const sub = (args[0] ?? "").toLowerCase();
@@ -5861,12 +6444,12 @@ function handleSkillSlash(args, ctx) {
5861
6444
  };
5862
6445
  }
5863
6446
  const extra = args.slice(1).join(" ").trim();
5864
- const header = `# Skill: ${skill.name}${skill.description ? `
6447
+ const header2 = `# Skill: ${skill.name}${skill.description ? `
5865
6448
  > ${skill.description}` : ""}`;
5866
6449
  const argsLine = extra ? `
5867
6450
 
5868
6451
  Arguments: ${extra}` : "";
5869
- const payload = `${header}
6452
+ const payload = `${header2}
5870
6453
 
5871
6454
  ${skill.body}${argsLine}`;
5872
6455
  return {
@@ -6044,9 +6627,9 @@ function appendSection(lines, label, section) {
6044
6627
  }
6045
6628
  function formatToolList(history) {
6046
6629
  const total = history.length;
6047
- const header = `Tool calls in this session (${total}, most recent first):`;
6630
+ const header2 = `Tool calls in this session (${total}, most recent first):`;
6048
6631
  const shown = Math.min(total, 10);
6049
- const lines = [header];
6632
+ const lines = [header2];
6050
6633
  for (let i = 0; i < shown; i++) {
6051
6634
  const entry = history[total - 1 - i];
6052
6635
  if (!entry) continue;
@@ -6129,7 +6712,12 @@ function App({
6129
6712
  const [toolProgress, setToolProgress] = useState5(null);
6130
6713
  const [statusLine, setStatusLine] = useState5(null);
6131
6714
  const [balance, setBalance] = useState5(null);
6132
- const [updateAvailable, setUpdateAvailable] = useState5(null);
6715
+ const [latestVersion, setLatestVersion] = useState5(null);
6716
+ const updateAvailable = latestVersion && compareVersions(VERSION, latestVersion) < 0 ? latestVersion : null;
6717
+ const [hookList, setHookList] = useState5(
6718
+ () => loadHooks({ projectRoot: codeMode?.rootDir })
6719
+ );
6720
+ const hookCwd = codeMode?.rootDir ?? process.cwd();
6133
6721
  const lastEditSnapshots = useRef2(null);
6134
6722
  const pendingEdits = useRef2([]);
6135
6723
  const [pendingShell, setPendingShell] = useState5(null);
@@ -6185,10 +6773,23 @@ function App({
6185
6773
  system,
6186
6774
  toolSpecs: tools?.specs()
6187
6775
  });
6188
- const l = new CacheFirstLoop({ client, prefix, tools, model, harvest: harvest2, branch, session });
6776
+ const l = new CacheFirstLoop({
6777
+ client,
6778
+ prefix,
6779
+ tools,
6780
+ model,
6781
+ harvest: harvest2,
6782
+ branch,
6783
+ session,
6784
+ hooks: hookList,
6785
+ hookCwd
6786
+ });
6189
6787
  loopRef.current = l;
6190
6788
  return l;
6191
6789
  }, [model, system, harvest2, branch, session, tools]);
6790
+ useEffect2(() => {
6791
+ loop.hooks = hookList;
6792
+ }, [loop, hookList]);
6192
6793
  useEffect2(() => {
6193
6794
  let cancelled = false;
6194
6795
  void (async () => {
@@ -6206,7 +6807,7 @@ function App({
6206
6807
  void (async () => {
6207
6808
  const latest = await getLatestVersion();
6208
6809
  if (cancelled || !latest) return;
6209
- if (compareVersions(VERSION, latest) < 0) setUpdateAvailable(latest);
6810
+ setLatestVersion(latest);
6210
6811
  })();
6211
6812
  return () => {
6212
6813
  cancelled = true;
@@ -6383,7 +6984,19 @@ function App({
6383
6984
  memoryRoot: codeMode?.rootDir ?? process.cwd(),
6384
6985
  planMode,
6385
6986
  setPlanMode: codeMode ? togglePlanMode : void 0,
6386
- clearPendingPlan: codeMode ? clearPendingPlan : void 0
6987
+ clearPendingPlan: codeMode ? clearPendingPlan : void 0,
6988
+ reloadHooks: () => {
6989
+ const fresh = loadHooks({ projectRoot: codeMode?.rootDir });
6990
+ setHookList(fresh);
6991
+ return fresh.length;
6992
+ },
6993
+ latestVersion,
6994
+ refreshLatestVersion: () => {
6995
+ void (async () => {
6996
+ const fresh = await getLatestVersion({ force: true });
6997
+ if (fresh) setLatestVersion(fresh);
6998
+ })();
6999
+ }
6387
7000
  });
6388
7001
  if (result.exit) {
6389
7002
  transcriptRef.current?.end();
@@ -6421,6 +7034,23 @@ function App({
6421
7034
  return;
6422
7035
  }
6423
7036
  }
7037
+ if (hookList.some((h) => h.event === "UserPromptSubmit")) {
7038
+ const promptReport = await runHooks({
7039
+ hooks: hookList,
7040
+ payload: { event: "UserPromptSubmit", cwd: hookCwd, prompt: text }
7041
+ });
7042
+ if (promptReport.outcomes.length > 0) {
7043
+ setHistorical((prev) => [
7044
+ ...prev,
7045
+ ...promptReport.outcomes.filter((o) => o.decision !== "pass").map((o) => ({
7046
+ id: `hp-${Date.now()}-${Math.random()}`,
7047
+ role: "warning",
7048
+ text: formatHookOutcomeMessage(o)
7049
+ }))
7050
+ ]);
7051
+ }
7052
+ if (promptReport.blocked) return;
7053
+ }
6424
7054
  promptHistory.current.push(text);
6425
7055
  setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
6426
7056
  const assistantId = `a-${Date.now()}`;
@@ -6493,6 +7123,13 @@ function App({
6493
7123
  const repairNote = ev.repair ? describeRepair(ev.repair) : "";
6494
7124
  setStreaming(null);
6495
7125
  setSummary(loop.stats.summary());
7126
+ if (ev.stats?.usage) {
7127
+ appendUsage({
7128
+ session: session ?? null,
7129
+ model: ev.stats.model,
7130
+ usage: ev.stats.usage
7131
+ });
7132
+ }
6496
7133
  const finalText = ev.content || streamRef.text;
6497
7134
  const iterReasoning = streamRef.reasoning || void 0;
6498
7135
  const iterId = `${assistantId}-i${assistantIterCounter.current++}`;
@@ -6581,6 +7218,28 @@ function App({
6581
7218
  }
6582
7219
  }
6583
7220
  flush();
7221
+ if (hookList.some((h) => h.event === "Stop")) {
7222
+ const stopReport = await runHooks({
7223
+ hooks: hookList,
7224
+ payload: {
7225
+ event: "Stop",
7226
+ cwd: hookCwd,
7227
+ lastAssistantText: streamRef.text,
7228
+ turn: loop.stats.summary().turns
7229
+ }
7230
+ });
7231
+ for (const o of stopReport.outcomes) {
7232
+ if (o.decision === "pass") continue;
7233
+ setHistorical((prev) => [
7234
+ ...prev,
7235
+ {
7236
+ id: `hs-${Date.now()}-${Math.random()}`,
7237
+ role: "warning",
7238
+ text: formatHookOutcomeMessage(o)
7239
+ }
7240
+ ]);
7241
+ }
7242
+ }
6584
7243
  } finally {
6585
7244
  if (timer) clearInterval(timer);
6586
7245
  setStreaming(null);
@@ -6606,10 +7265,14 @@ function App({
6606
7265
  codeMode,
6607
7266
  codeUndo,
6608
7267
  exit,
7268
+ hookCwd,
7269
+ hookList,
6609
7270
  loop,
7271
+ latestVersion,
6610
7272
  mcpSpecs,
6611
7273
  mcpServers,
6612
7274
  planMode,
7275
+ session,
6613
7276
  slashSelected,
6614
7277
  togglePlanMode,
6615
7278
  writeTranscript
@@ -6872,8 +7535,8 @@ function formatEditResults(results) {
6872
7535
  });
6873
7536
  const ok = results.filter((r) => r.status === "applied" || r.status === "created").length;
6874
7537
  const total = results.length;
6875
- const header = `\u25B8 edit blocks: ${ok}/${total} applied \u2014 /undo to roll back, or \`git diff\` to review`;
6876
- return [header, ...lines].join("\n");
7538
+ const header2 = `\u25B8 edit blocks: ${ok}/${total} applied \u2014 /undo to roll back, or \`git diff\` to review`;
7539
+ return [header2, ...lines].join("\n");
6877
7540
  }
6878
7541
  function formatPendingPreview(blocks) {
6879
7542
  const lines = blocks.map((b) => {
@@ -6882,8 +7545,8 @@ function formatPendingPreview(blocks) {
6882
7545
  const tag = b.search === "" ? "NEW " : " ";
6883
7546
  return ` ${tag}${b.path} (-${removed} +${added} lines)`;
6884
7547
  });
6885
- const header = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply (or y) to commit \xB7 /discard (or n) to drop`;
6886
- return [header, ...lines].join("\n");
7548
+ const header2 = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply (or y) to commit \xB7 /discard (or n) to drop`;
7549
+ return [header2, ...lines].join("\n");
6887
7550
  }
6888
7551
  function countLines2(s) {
6889
7552
  if (s.length === 0) return 0;
@@ -7128,7 +7791,7 @@ async function chatCommand(opts) {
7128
7791
  const prior = loadSessionMessages(opts.session);
7129
7792
  if (prior.length > 0) {
7130
7793
  const p = sessionPath(opts.session);
7131
- const mtime = existsSync5(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
7794
+ const mtime = existsSync8(p) ? statSync4(p).mtime : /* @__PURE__ */ new Date();
7132
7795
  sessionPreview = { messageCount: prior.length, lastActive: mtime };
7133
7796
  }
7134
7797
  } else if (opts.session && opts.forceNew) {
@@ -7471,7 +8134,7 @@ function mcpListCommand(opts) {
7471
8134
  console.log("Popular MCP servers you can bridge into Reasonix:");
7472
8135
  console.log("");
7473
8136
  for (const entry of MCP_CATALOG) {
7474
- console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
8137
+ console.log(` ${pad2(entry.name, 12)} ${entry.summary}`);
7475
8138
  console.log(` ${mcpCommandFor(entry)}`);
7476
8139
  if (entry.note) console.log(` \xB7 ${entry.note}`);
7477
8140
  console.log("");
@@ -7484,7 +8147,7 @@ function mcpListCommand(opts) {
7484
8147
  " https://mcp.so \u2014 community-maintained catalog"
7485
8148
  );
7486
8149
  }
7487
- function pad(s, width) {
8150
+ function pad2(s, width) {
7488
8151
  return s.length >= width ? s : s + " ".repeat(width - s.length);
7489
8152
  }
7490
8153
 
@@ -7753,6 +8416,9 @@ async function runCommand2(opts) {
7753
8416
  [error] ${ev.error}
7754
8417
  `);
7755
8418
  if (ev.role === "done") process.stdout.write("\n");
8419
+ if (ev.role === "assistant_final" && ev.stats?.usage) {
8420
+ appendUsage({ session: null, model: ev.stats.model, usage: ev.stats.usage });
8421
+ }
7756
8422
  if (transcriptStream && ev.role !== "assistant_delta") {
7757
8423
  writeRecord(transcriptStream, recordFromLoopEvent(ev, { model: opts.model, prefixHash }));
7758
8424
  }
@@ -8149,34 +8815,8 @@ async function setupCommand(_opts = {}) {
8149
8815
  await waitUntilExit();
8150
8816
  }
8151
8817
 
8152
- // src/cli/commands/stats.ts
8153
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
8154
- function statsCommand(opts) {
8155
- if (!existsSync6(opts.transcript)) {
8156
- console.error(`no such transcript: ${opts.transcript}`);
8157
- process.exit(1);
8158
- }
8159
- const lines = readFileSync7(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8160
- let assistantTurns = 0;
8161
- let toolCalls = 0;
8162
- let lastTurn = 0;
8163
- for (const line of lines) {
8164
- try {
8165
- const rec = JSON.parse(line);
8166
- if (rec.role === "assistant_final") assistantTurns++;
8167
- if (rec.role === "tool") toolCalls++;
8168
- if (typeof rec.turn === "number") lastTurn = Math.max(lastTurn, rec.turn);
8169
- } catch {
8170
- }
8171
- }
8172
- console.log(`transcript: ${opts.transcript}`);
8173
- console.log(`assistant turns: ${assistantTurns}`);
8174
- console.log(`tool invocations: ${toolCalls}`);
8175
- console.log(`last turn index: ${lastTurn}`);
8176
- }
8177
-
8178
8818
  // src/cli/commands/update.ts
8179
- import { spawn as spawn3 } from "child_process";
8819
+ import { spawn as spawn4 } from "child_process";
8180
8820
  function planUpdate(input) {
8181
8821
  const diff = compareVersions(input.current, input.latest);
8182
8822
  if (diff > 0) {
@@ -8207,7 +8847,7 @@ function planUpdate(input) {
8207
8847
  }
8208
8848
  function defaultSpawn(argv) {
8209
8849
  return new Promise((resolve6, reject) => {
8210
- const child = spawn3(argv[0], argv.slice(1), {
8850
+ const child = spawn4(argv[0], argv.slice(1), {
8211
8851
  stdio: "inherit",
8212
8852
  shell: process.platform === "win32"
8213
8853
  });
@@ -8400,7 +9040,9 @@ program.command("run <task>").description("Run a single task non-interactively,
8400
9040
  mcpPrefix: opts.mcpPrefix
8401
9041
  });
8402
9042
  });
8403
- program.command("stats <transcript>").description("Summarize a JSONL transcript produced by `reasonix chat --transcript`.").action((transcript) => {
9043
+ program.command("stats [transcript]").description(
9044
+ "Show usage dashboard (today / week / month / all-time \xB7 turns \xB7 cache hit \xB7 cost \xB7 savings vs Claude). Pass a transcript path to fall back to the per-file summary (assistant turns + tool calls)."
9045
+ ).action((transcript) => {
8404
9046
  statsCommand({ transcript });
8405
9047
  });
8406
9048
  program.command("sessions [name]").description("List saved chat sessions, or inspect one by name.").option("-v, --verbose", "Include system prompts + tool-call metadata when inspecting").action((name, opts) => {