reasonix 0.4.22 → 0.4.23

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") {
@@ -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
  }
@@ -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;
@@ -4362,7 +4583,7 @@ function isNpxInstall() {
4362
4583
  }
4363
4584
 
4364
4585
  // src/cli/commands/chat.tsx
4365
- import { existsSync as existsSync5, statSync as statSync3 } from "fs";
4586
+ import { existsSync as existsSync6, statSync as statSync3 } from "fs";
4366
4587
  import { render } from "ink";
4367
4588
  import React15, { useState as useState7 } from "react";
4368
4589
 
@@ -5395,6 +5616,15 @@ var SLASH_COMMANDS = [
5395
5616
  argsHint: "[list|show <name>|<name> [args]]",
5396
5617
  summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)"
5397
5618
  },
5619
+ {
5620
+ cmd: "hooks",
5621
+ argsHint: "[reload]",
5622
+ summary: "list active hooks (settings.json under .reasonix/) \xB7 reload re-reads from disk"
5623
+ },
5624
+ {
5625
+ cmd: "update",
5626
+ summary: "show current vs latest version + the shell command to upgrade"
5627
+ },
5398
5628
  { cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
5399
5629
  { cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
5400
5630
  { cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
@@ -5568,6 +5798,13 @@ function handleSlash(cmd, args, loop, ctx = {}) {
5568
5798
  case "skills": {
5569
5799
  return handleSkillSlash(args, ctx);
5570
5800
  }
5801
+ case "hook":
5802
+ case "hooks": {
5803
+ return handleHooksSlash(args, loop, ctx);
5804
+ }
5805
+ case "update": {
5806
+ return handleUpdateSlash(ctx);
5807
+ }
5571
5808
  case "think":
5572
5809
  case "reasoning": {
5573
5810
  const raw = loop.scratch.reasoning;
@@ -5804,6 +6041,93 @@ ${entry.text}`
5804
6041
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
5805
6042
  }
5806
6043
  }
6044
+ function handleUpdateSlash(ctx) {
6045
+ const latest = ctx.latestVersion ?? null;
6046
+ const lines = [`current: reasonix ${VERSION}`];
6047
+ if (latest === null) {
6048
+ ctx.refreshLatestVersion?.();
6049
+ lines.push(
6050
+ "latest: (not yet resolved \u2014 background check in flight or offline)",
6051
+ "",
6052
+ "triggered a fresh registry fetch \u2014 retry `/update` in a few seconds,",
6053
+ "or run `reasonix update` in another terminal to force it synchronously."
6054
+ );
6055
+ return { info: lines.join("\n") };
6056
+ }
6057
+ lines.push(`latest: reasonix ${latest}`);
6058
+ const diff = compareVersions(VERSION, latest);
6059
+ if (diff >= 0) {
6060
+ lines.push("", "you're on the latest. nothing to do.");
6061
+ return { info: lines.join("\n") };
6062
+ }
6063
+ if (isNpxInstall()) {
6064
+ lines.push(
6065
+ "",
6066
+ "you're running via npx \u2014 the next `npx reasonix ...` launch will auto-fetch.",
6067
+ "to force a refresh sooner: `npm cache clean --force`."
6068
+ );
6069
+ } else {
6070
+ lines.push(
6071
+ "",
6072
+ "to upgrade, exit this session and run:",
6073
+ " reasonix update (interactive, dry-run supported via --dry-run)",
6074
+ " npm install -g reasonix@latest (direct)",
6075
+ "",
6076
+ "in-session install is deliberately disabled \u2014 the npm spawn would",
6077
+ "corrupt this TUI's rendering and Windows can lock the running binary."
6078
+ );
6079
+ }
6080
+ return { info: lines.join("\n") };
6081
+ }
6082
+ function handleHooksSlash(args, loop, ctx) {
6083
+ const sub = (args[0] ?? "").toLowerCase();
6084
+ if (sub === "reload") {
6085
+ if (!ctx.reloadHooks) {
6086
+ return {
6087
+ info: "/hooks reload is not available in this context (no reload callback wired)."
6088
+ };
6089
+ }
6090
+ const count = ctx.reloadHooks();
6091
+ return { info: `\u25B8 reloaded hooks \xB7 ${count} active` };
6092
+ }
6093
+ if (sub !== "" && sub !== "list" && sub !== "ls") {
6094
+ return {
6095
+ info: "usage: /hooks list active hooks\n /hooks reload re-read settings.json files"
6096
+ };
6097
+ }
6098
+ const hooks = loop.hooks;
6099
+ const projPath = ctx.codeRoot ? projectSettingsPath(ctx.codeRoot) : void 0;
6100
+ const globPath = globalSettingsPath();
6101
+ if (hooks.length === 0) {
6102
+ const lines2 = [
6103
+ "no hooks configured.",
6104
+ "",
6105
+ "drop a settings.json with a `hooks` key into either of:",
6106
+ ctx.codeRoot ? ` \xB7 ${projPath} (project)` : " \xB7 <project>/.reasonix/settings.json (project)",
6107
+ ` \xB7 ${globPath} (global)`,
6108
+ "",
6109
+ "events: PreToolUse, PostToolUse, UserPromptSubmit, Stop",
6110
+ "exit 0 = pass \xB7 exit 2 = block (Pre*) \xB7 other = warn"
6111
+ ];
6112
+ return { info: lines2.join("\n") };
6113
+ }
6114
+ const grouped = /* @__PURE__ */ new Map();
6115
+ for (const event of HOOK_EVENTS) grouped.set(event, []);
6116
+ for (const h of hooks) grouped.get(h.event)?.push(h);
6117
+ const lines = [`\u25B8 ${hooks.length} hook(s) loaded`];
6118
+ for (const event of HOOK_EVENTS) {
6119
+ const list = grouped.get(event) ?? [];
6120
+ if (list.length === 0) continue;
6121
+ lines.push("", `${event}:`);
6122
+ for (const h of list) {
6123
+ const match = h.match && h.match !== "*" ? ` match=${h.match}` : "";
6124
+ const desc = h.description ? ` \u2014 ${h.description}` : "";
6125
+ lines.push(` [${h.scope}]${match} ${h.command}${desc}`);
6126
+ }
6127
+ }
6128
+ lines.push("", `sources: project=${projPath ?? "(none \u2014 chat mode)"} \xB7 global=${globPath}`);
6129
+ return { info: lines.join("\n") };
6130
+ }
5807
6131
  function handleSkillSlash(args, ctx) {
5808
6132
  const store = new SkillStore({ projectRoot: ctx.codeRoot });
5809
6133
  const sub = (args[0] ?? "").toLowerCase();
@@ -6129,7 +6453,12 @@ function App({
6129
6453
  const [toolProgress, setToolProgress] = useState5(null);
6130
6454
  const [statusLine, setStatusLine] = useState5(null);
6131
6455
  const [balance, setBalance] = useState5(null);
6132
- const [updateAvailable, setUpdateAvailable] = useState5(null);
6456
+ const [latestVersion, setLatestVersion] = useState5(null);
6457
+ const updateAvailable = latestVersion && compareVersions(VERSION, latestVersion) < 0 ? latestVersion : null;
6458
+ const [hookList, setHookList] = useState5(
6459
+ () => loadHooks({ projectRoot: codeMode?.rootDir })
6460
+ );
6461
+ const hookCwd = codeMode?.rootDir ?? process.cwd();
6133
6462
  const lastEditSnapshots = useRef2(null);
6134
6463
  const pendingEdits = useRef2([]);
6135
6464
  const [pendingShell, setPendingShell] = useState5(null);
@@ -6185,10 +6514,23 @@ function App({
6185
6514
  system,
6186
6515
  toolSpecs: tools?.specs()
6187
6516
  });
6188
- const l = new CacheFirstLoop({ client, prefix, tools, model, harvest: harvest2, branch, session });
6517
+ const l = new CacheFirstLoop({
6518
+ client,
6519
+ prefix,
6520
+ tools,
6521
+ model,
6522
+ harvest: harvest2,
6523
+ branch,
6524
+ session,
6525
+ hooks: hookList,
6526
+ hookCwd
6527
+ });
6189
6528
  loopRef.current = l;
6190
6529
  return l;
6191
6530
  }, [model, system, harvest2, branch, session, tools]);
6531
+ useEffect2(() => {
6532
+ loop.hooks = hookList;
6533
+ }, [loop, hookList]);
6192
6534
  useEffect2(() => {
6193
6535
  let cancelled = false;
6194
6536
  void (async () => {
@@ -6206,7 +6548,7 @@ function App({
6206
6548
  void (async () => {
6207
6549
  const latest = await getLatestVersion();
6208
6550
  if (cancelled || !latest) return;
6209
- if (compareVersions(VERSION, latest) < 0) setUpdateAvailable(latest);
6551
+ setLatestVersion(latest);
6210
6552
  })();
6211
6553
  return () => {
6212
6554
  cancelled = true;
@@ -6383,7 +6725,19 @@ function App({
6383
6725
  memoryRoot: codeMode?.rootDir ?? process.cwd(),
6384
6726
  planMode,
6385
6727
  setPlanMode: codeMode ? togglePlanMode : void 0,
6386
- clearPendingPlan: codeMode ? clearPendingPlan : void 0
6728
+ clearPendingPlan: codeMode ? clearPendingPlan : void 0,
6729
+ reloadHooks: () => {
6730
+ const fresh = loadHooks({ projectRoot: codeMode?.rootDir });
6731
+ setHookList(fresh);
6732
+ return fresh.length;
6733
+ },
6734
+ latestVersion,
6735
+ refreshLatestVersion: () => {
6736
+ void (async () => {
6737
+ const fresh = await getLatestVersion({ force: true });
6738
+ if (fresh) setLatestVersion(fresh);
6739
+ })();
6740
+ }
6387
6741
  });
6388
6742
  if (result.exit) {
6389
6743
  transcriptRef.current?.end();
@@ -6421,6 +6775,23 @@ function App({
6421
6775
  return;
6422
6776
  }
6423
6777
  }
6778
+ if (hookList.some((h) => h.event === "UserPromptSubmit")) {
6779
+ const promptReport = await runHooks({
6780
+ hooks: hookList,
6781
+ payload: { event: "UserPromptSubmit", cwd: hookCwd, prompt: text }
6782
+ });
6783
+ if (promptReport.outcomes.length > 0) {
6784
+ setHistorical((prev) => [
6785
+ ...prev,
6786
+ ...promptReport.outcomes.filter((o) => o.decision !== "pass").map((o) => ({
6787
+ id: `hp-${Date.now()}-${Math.random()}`,
6788
+ role: "warning",
6789
+ text: formatHookOutcomeMessage(o)
6790
+ }))
6791
+ ]);
6792
+ }
6793
+ if (promptReport.blocked) return;
6794
+ }
6424
6795
  promptHistory.current.push(text);
6425
6796
  setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
6426
6797
  const assistantId = `a-${Date.now()}`;
@@ -6581,6 +6952,28 @@ function App({
6581
6952
  }
6582
6953
  }
6583
6954
  flush();
6955
+ if (hookList.some((h) => h.event === "Stop")) {
6956
+ const stopReport = await runHooks({
6957
+ hooks: hookList,
6958
+ payload: {
6959
+ event: "Stop",
6960
+ cwd: hookCwd,
6961
+ lastAssistantText: streamRef.text,
6962
+ turn: loop.stats.summary().turns
6963
+ }
6964
+ });
6965
+ for (const o of stopReport.outcomes) {
6966
+ if (o.decision === "pass") continue;
6967
+ setHistorical((prev) => [
6968
+ ...prev,
6969
+ {
6970
+ id: `hs-${Date.now()}-${Math.random()}`,
6971
+ role: "warning",
6972
+ text: formatHookOutcomeMessage(o)
6973
+ }
6974
+ ]);
6975
+ }
6976
+ }
6584
6977
  } finally {
6585
6978
  if (timer) clearInterval(timer);
6586
6979
  setStreaming(null);
@@ -6606,7 +6999,10 @@ function App({
6606
6999
  codeMode,
6607
7000
  codeUndo,
6608
7001
  exit,
7002
+ hookCwd,
7003
+ hookList,
6609
7004
  loop,
7005
+ latestVersion,
6610
7006
  mcpSpecs,
6611
7007
  mcpServers,
6612
7008
  planMode,
@@ -7128,7 +7524,7 @@ async function chatCommand(opts) {
7128
7524
  const prior = loadSessionMessages(opts.session);
7129
7525
  if (prior.length > 0) {
7130
7526
  const p = sessionPath(opts.session);
7131
- const mtime = existsSync5(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
7527
+ const mtime = existsSync6(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
7132
7528
  sessionPreview = { messageCount: prior.length, lastActive: mtime };
7133
7529
  }
7134
7530
  } else if (opts.session && opts.forceNew) {
@@ -8150,13 +8546,13 @@ async function setupCommand(_opts = {}) {
8150
8546
  }
8151
8547
 
8152
8548
  // src/cli/commands/stats.ts
8153
- import { existsSync as existsSync6, readFileSync as readFileSync7 } from "fs";
8549
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
8154
8550
  function statsCommand(opts) {
8155
- if (!existsSync6(opts.transcript)) {
8551
+ if (!existsSync7(opts.transcript)) {
8156
8552
  console.error(`no such transcript: ${opts.transcript}`);
8157
8553
  process.exit(1);
8158
8554
  }
8159
- const lines = readFileSync7(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8555
+ const lines = readFileSync8(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8160
8556
  let assistantTurns = 0;
8161
8557
  let toolCalls = 0;
8162
8558
  let lastTurn = 0;
@@ -8176,7 +8572,7 @@ function statsCommand(opts) {
8176
8572
  }
8177
8573
 
8178
8574
  // src/cli/commands/update.ts
8179
- import { spawn as spawn3 } from "child_process";
8575
+ import { spawn as spawn4 } from "child_process";
8180
8576
  function planUpdate(input) {
8181
8577
  const diff = compareVersions(input.current, input.latest);
8182
8578
  if (diff > 0) {
@@ -8207,7 +8603,7 @@ function planUpdate(input) {
8207
8603
  }
8208
8604
  function defaultSpawn(argv) {
8209
8605
  return new Promise((resolve6, reject) => {
8210
- const child = spawn3(argv[0], argv.slice(1), {
8606
+ const child = spawn4(argv[0], argv.slice(1), {
8211
8607
  stdio: "inherit",
8212
8608
  shell: process.platform === "win32"
8213
8609
  });