reasonix 0.4.21 → 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",
@@ -4260,11 +4481,109 @@ function sep() {
4260
4481
  return process.platform === "win32" ? "\\" : "/";
4261
4482
  }
4262
4483
 
4263
- // src/index.ts
4264
- var VERSION = "0.4.20";
4484
+ // src/version.ts
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";
4488
+ import { fileURLToPath } from "url";
4489
+ var REGISTRY_URL = "https://registry.npmjs.org/reasonix/latest";
4490
+ var LATEST_CACHE_TTL_MS = 24 * 60 * 60 * 1e3;
4491
+ var LATEST_FETCH_TIMEOUT_MS = 2e3;
4492
+ function readPackageVersion() {
4493
+ try {
4494
+ let dir = dirname5(fileURLToPath(import.meta.url));
4495
+ for (let i = 0; i < 6; i++) {
4496
+ const p = join5(dir, "package.json");
4497
+ if (existsSync5(p)) {
4498
+ const pkg = JSON.parse(readFileSync7(p, "utf8"));
4499
+ if (pkg?.name === "reasonix" && typeof pkg.version === "string") {
4500
+ return pkg.version;
4501
+ }
4502
+ }
4503
+ const parent = dirname5(dir);
4504
+ if (parent === dir) break;
4505
+ dir = parent;
4506
+ }
4507
+ } catch {
4508
+ }
4509
+ return "0.0.0-dev";
4510
+ }
4511
+ var VERSION = readPackageVersion();
4512
+ function cachePath(homeDirOverride) {
4513
+ return join5(homeDirOverride ?? homedir4(), ".reasonix", "version-cache.json");
4514
+ }
4515
+ function readCache(homeDirOverride) {
4516
+ try {
4517
+ const raw = readFileSync7(cachePath(homeDirOverride), "utf8");
4518
+ const parsed = JSON.parse(raw);
4519
+ if (parsed && typeof parsed.version === "string" && typeof parsed.checkedAt === "number") {
4520
+ return parsed;
4521
+ }
4522
+ } catch {
4523
+ }
4524
+ return null;
4525
+ }
4526
+ function writeCache(entry, homeDirOverride) {
4527
+ try {
4528
+ const p = cachePath(homeDirOverride);
4529
+ mkdirSync4(dirname5(p), { recursive: true });
4530
+ writeFileSync4(p, JSON.stringify(entry), "utf8");
4531
+ } catch {
4532
+ }
4533
+ }
4534
+ async function getLatestVersion(opts = {}) {
4535
+ const ttl = opts.ttlMs ?? LATEST_CACHE_TTL_MS;
4536
+ if (!opts.force) {
4537
+ const cached = readCache(opts.homeDir);
4538
+ if (cached && Date.now() - cached.checkedAt < ttl) return cached.version;
4539
+ }
4540
+ const fetchImpl = opts.fetchImpl ?? globalThis.fetch;
4541
+ if (!fetchImpl) return null;
4542
+ const url = opts.registryUrl ?? REGISTRY_URL;
4543
+ const timeout = opts.timeoutMs ?? LATEST_FETCH_TIMEOUT_MS;
4544
+ const controller = new AbortController();
4545
+ const timer = setTimeout(() => controller.abort(), timeout);
4546
+ try {
4547
+ const res = await fetchImpl(url, {
4548
+ signal: controller.signal,
4549
+ headers: { accept: "application/json" }
4550
+ });
4551
+ if (!res.ok) return null;
4552
+ const body = await res.json();
4553
+ if (typeof body.version !== "string") return null;
4554
+ writeCache({ version: body.version, checkedAt: Date.now() }, opts.homeDir);
4555
+ return body.version;
4556
+ } catch {
4557
+ return null;
4558
+ } finally {
4559
+ clearTimeout(timer);
4560
+ }
4561
+ }
4562
+ function compareVersions(a, b) {
4563
+ const [aCore = "0", aPre = ""] = a.split("-", 2);
4564
+ const [bCore = "0", bPre = ""] = b.split("-", 2);
4565
+ const aParts = aCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
4566
+ const bParts = bCore.split(".").map((p) => Number.parseInt(p, 10) || 0);
4567
+ for (let i = 0; i < 3; i++) {
4568
+ const diff = (aParts[i] ?? 0) - (bParts[i] ?? 0);
4569
+ if (diff !== 0) return diff;
4570
+ }
4571
+ if (!aPre && !bPre) return 0;
4572
+ if (!aPre) return 1;
4573
+ if (!bPre) return -1;
4574
+ return aPre < bPre ? -1 : aPre > bPre ? 1 : 0;
4575
+ }
4576
+ function isNpxInstall() {
4577
+ const bin = process.argv[1] ?? "";
4578
+ if (/[/\\]_npx[/\\]/.test(bin)) return true;
4579
+ if (/[/\\]\.pnpm[/\\]/.test(bin) && /dlx/i.test(bin)) return true;
4580
+ const ua = process.env.npm_config_user_agent ?? "";
4581
+ if (ua.includes("npx/")) return true;
4582
+ return false;
4583
+ }
4265
4584
 
4266
4585
  // src/cli/commands/chat.tsx
4267
- import { existsSync as existsSync4, statSync as statSync3 } from "fs";
4586
+ import { existsSync as existsSync6, statSync as statSync3 } from "fs";
4268
4587
  import { render } from "ink";
4269
4588
  import React15, { useState as useState7 } from "react";
4270
4589
 
@@ -5255,7 +5574,8 @@ function StatsPanel({
5255
5574
  harvestOn,
5256
5575
  branchBudget,
5257
5576
  planMode,
5258
- balance
5577
+ balance,
5578
+ updateAvailable
5259
5579
  }) {
5260
5580
  const hitPct = (summary.cacheHitRatio * 100).toFixed(1);
5261
5581
  const hitColor = summary.cacheHitRatio >= 0.7 ? "green" : summary.cacheHitRatio >= 0.4 ? "yellow" : "red";
@@ -5263,7 +5583,7 @@ function StatsPanel({
5263
5583
  const ctxMax = DEEPSEEK_CONTEXT_TOKENS[model] ?? DEFAULT_CONTEXT_TOKENS;
5264
5584
  const ctxRatio = summary.lastPromptTokens / ctxMax;
5265
5585
  const ctxColor = ctxRatio >= 0.8 ? "red" : ctxRatio >= 0.5 ? "yellow" : void 0;
5266
- return /* @__PURE__ */ React11.createElement(Box10, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React11.createElement(Box10, { justifyContent: "space-between" }, /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React11.createElement(Text10, { color: "yellow" }, model), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React11.createElement(Text10, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React11.createElement(Text10, { color: "blue" }, " \xB7 branch", branchBudget) : null, planMode ? /* @__PURE__ */ React11.createElement(Text10, { color: "red", bold: true }, " ", "\xB7 PLAN") : null), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help")), /* @__PURE__ */ React11.createElement(Box10, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "cache hit "), /* @__PURE__ */ React11.createElement(Text10, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "cost "), /* @__PURE__ */ React11.createElement(Text10, { color: "green", bold: true }, "$", summary.totalCostUsd.toFixed(6)), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " (in ", "$", summary.totalInputCostUsd.toFixed(6), " \xB7 out ", "$", summary.totalOutputCostUsd.toFixed(6), ")")), summary.lastPromptTokens > 0 ? /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "ctx "), /* @__PURE__ */ React11.createElement(Text10, { color: ctxColor, bold: ctxColor !== void 0 }, formatTokens(summary.lastPromptTokens), "/", formatTokens(ctxMax)), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " (", (ctxRatio * 100).toFixed(0), "%)"), ctxRatio >= 0.8 ? /* @__PURE__ */ React11.createElement(Text10, { color: "red", bold: true }, " ", "\xB7 /compact") : null) : null, balance ? /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "balance "), /* @__PURE__ */ React11.createElement(Text10, { color: balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green", bold: true }, balance.currency === "USD" ? "$" : "", balance.total.toFixed(2), balance.currency !== "USD" ? ` ${balance.currency}` : "")) : null));
5586
+ return /* @__PURE__ */ React11.createElement(Box10, { borderStyle: "round", borderColor: "cyan", flexDirection: "column", paddingX: 1 }, /* @__PURE__ */ React11.createElement(Box10, { justifyContent: "space-between" }, /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { color: "cyan", bold: true }, "Reasonix"), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, ` v${VERSION}`), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " \xB7 model "), /* @__PURE__ */ React11.createElement(Text10, { color: "yellow" }, model), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " \xB7 prefix "), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, prefixHash), harvestOn ? /* @__PURE__ */ React11.createElement(Text10, { color: "magenta" }, " \xB7 harvest") : null, branchOn ? /* @__PURE__ */ React11.createElement(Text10, { color: "blue" }, " \xB7 branch", branchBudget) : null, planMode ? /* @__PURE__ */ React11.createElement(Text10, { color: "red", bold: true }, " ", "\xB7 PLAN") : null), /* @__PURE__ */ React11.createElement(Text10, null, updateAvailable ? /* @__PURE__ */ React11.createElement(Text10, { color: "yellow", bold: true }, `update: ${updateAvailable} \xB7 `) : null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "turns ", summary.turns, " \xB7 type /help"))), /* @__PURE__ */ React11.createElement(Box10, { marginTop: 1, gap: 3 }, /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "cache hit "), /* @__PURE__ */ React11.createElement(Text10, { color: hitColor, bold: true }, hitPct, "%")), /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "cost "), /* @__PURE__ */ React11.createElement(Text10, { color: "green", bold: true }, "$", summary.totalCostUsd.toFixed(6)), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " (in ", "$", summary.totalInputCostUsd.toFixed(6), " \xB7 out ", "$", summary.totalOutputCostUsd.toFixed(6), ")")), summary.lastPromptTokens > 0 ? /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "ctx "), /* @__PURE__ */ React11.createElement(Text10, { color: ctxColor, bold: ctxColor !== void 0 }, formatTokens(summary.lastPromptTokens), "/", formatTokens(ctxMax)), /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, " (", (ctxRatio * 100).toFixed(0), "%)"), ctxRatio >= 0.8 ? /* @__PURE__ */ React11.createElement(Text10, { color: "red", bold: true }, " ", "\xB7 /compact") : null) : null, balance ? /* @__PURE__ */ React11.createElement(Text10, null, /* @__PURE__ */ React11.createElement(Text10, { dimColor: true }, "balance "), /* @__PURE__ */ React11.createElement(Text10, { color: balance.total < 1 ? "red" : balance.total < 5 ? "yellow" : "green", bold: true }, balance.currency === "USD" ? "$" : "", balance.total.toFixed(2), balance.currency !== "USD" ? ` ${balance.currency}` : "")) : null));
5267
5587
  }
5268
5588
  function formatTokens(n) {
5269
5589
  if (n < 1e3) return String(n);
@@ -5296,6 +5616,15 @@ var SLASH_COMMANDS = [
5296
5616
  argsHint: "[list|show <name>|<name> [args]]",
5297
5617
  summary: "list / run user skills (<project>/.reasonix/skills + ~/.reasonix/skills)"
5298
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
+ },
5299
5628
  { cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
5300
5629
  { cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
5301
5630
  { cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
@@ -5469,6 +5798,13 @@ function handleSlash(cmd, args, loop, ctx = {}) {
5469
5798
  case "skills": {
5470
5799
  return handleSkillSlash(args, ctx);
5471
5800
  }
5801
+ case "hook":
5802
+ case "hooks": {
5803
+ return handleHooksSlash(args, loop, ctx);
5804
+ }
5805
+ case "update": {
5806
+ return handleUpdateSlash(ctx);
5807
+ }
5472
5808
  case "think":
5473
5809
  case "reasoning": {
5474
5810
  const raw = loop.scratch.reasoning;
@@ -5705,6 +6041,93 @@ ${entry.text}`
5705
6041
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
5706
6042
  }
5707
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
+ }
5708
6131
  function handleSkillSlash(args, ctx) {
5709
6132
  const store = new SkillStore({ projectRoot: ctx.codeRoot });
5710
6133
  const sub = (args[0] ?? "").toLowerCase();
@@ -6030,6 +6453,12 @@ function App({
6030
6453
  const [toolProgress, setToolProgress] = useState5(null);
6031
6454
  const [statusLine, setStatusLine] = useState5(null);
6032
6455
  const [balance, setBalance] = 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();
6033
6462
  const lastEditSnapshots = useRef2(null);
6034
6463
  const pendingEdits = useRef2([]);
6035
6464
  const [pendingShell, setPendingShell] = useState5(null);
@@ -6085,10 +6514,23 @@ function App({
6085
6514
  system,
6086
6515
  toolSpecs: tools?.specs()
6087
6516
  });
6088
- 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
+ });
6089
6528
  loopRef.current = l;
6090
6529
  return l;
6091
6530
  }, [model, system, harvest2, branch, session, tools]);
6531
+ useEffect2(() => {
6532
+ loop.hooks = hookList;
6533
+ }, [loop, hookList]);
6092
6534
  useEffect2(() => {
6093
6535
  let cancelled = false;
6094
6536
  void (async () => {
@@ -6101,6 +6543,17 @@ function App({
6101
6543
  cancelled = true;
6102
6544
  };
6103
6545
  }, [loop]);
6546
+ useEffect2(() => {
6547
+ let cancelled = false;
6548
+ void (async () => {
6549
+ const latest = await getLatestVersion();
6550
+ if (cancelled || !latest) return;
6551
+ setLatestVersion(latest);
6552
+ })();
6553
+ return () => {
6554
+ cancelled = true;
6555
+ };
6556
+ }, []);
6104
6557
  useEffect2(() => {
6105
6558
  if (!progressSink) return;
6106
6559
  progressSink.current = (info) => {
@@ -6272,7 +6725,19 @@ function App({
6272
6725
  memoryRoot: codeMode?.rootDir ?? process.cwd(),
6273
6726
  planMode,
6274
6727
  setPlanMode: codeMode ? togglePlanMode : void 0,
6275
- 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
+ }
6276
6741
  });
6277
6742
  if (result.exit) {
6278
6743
  transcriptRef.current?.end();
@@ -6310,6 +6775,23 @@ function App({
6310
6775
  return;
6311
6776
  }
6312
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
+ }
6313
6795
  promptHistory.current.push(text);
6314
6796
  setHistorical((prev) => [...prev, { id: `u-${Date.now()}`, role: "user", text }]);
6315
6797
  const assistantId = `a-${Date.now()}`;
@@ -6470,6 +6952,28 @@ function App({
6470
6952
  }
6471
6953
  }
6472
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
+ }
6473
6977
  } finally {
6474
6978
  if (timer) clearInterval(timer);
6475
6979
  setStreaming(null);
@@ -6495,7 +6999,10 @@ function App({
6495
6999
  codeMode,
6496
7000
  codeUndo,
6497
7001
  exit,
7002
+ hookCwd,
7003
+ hookList,
6498
7004
  loop,
7005
+ latestVersion,
6499
7006
  mcpSpecs,
6500
7007
  mcpServers,
6501
7008
  planMode,
@@ -6659,7 +7166,8 @@ Stay in plan mode \u2014 address the feedback (explore more if needed), then sub
6659
7166
  harvestOn: loop.harvestEnabled,
6660
7167
  branchBudget: loop.branchOptions.budget,
6661
7168
  planMode,
6662
- balance
7169
+ balance,
7170
+ updateAvailable
6663
7171
  }
6664
7172
  ), /* @__PURE__ */ React12.createElement(Static, { items: historical }, (item) => /* @__PURE__ */ React12.createElement(EventRow, { key: item.id, event: item })), !PLAIN_UI && !pendingShell && !pendingPlan && !stagedInput && streaming ? /* @__PURE__ */ React12.createElement(Box11, { marginY: 1 }, /* @__PURE__ */ React12.createElement(EventRow, { event: streaming })) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !stagedInput && ongoingTool ? /* @__PURE__ */ React12.createElement(OngoingToolRow, { tool: ongoingTool, progress: toolProgress }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !stagedInput && !ongoingTool && statusLine ? /* @__PURE__ */ React12.createElement(StatusRow, { text: statusLine }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !stagedInput && busy && !streaming && !ongoingTool && !statusLine ? /* @__PURE__ */ React12.createElement(StatusRow, { text: "processing\u2026" }) : null, stagedInput ? /* @__PURE__ */ React12.createElement(
6665
7173
  PlanRefineInput,
@@ -7016,7 +7524,7 @@ async function chatCommand(opts) {
7016
7524
  const prior = loadSessionMessages(opts.session);
7017
7525
  if (prior.length > 0) {
7018
7526
  const p = sessionPath(opts.session);
7019
- const mtime = existsSync4(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
7527
+ const mtime = existsSync6(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
7020
7528
  sessionPreview = { messageCount: prior.length, lastActive: mtime };
7021
7529
  }
7022
7530
  } else if (opts.session && opts.forceNew) {
@@ -7085,7 +7593,7 @@ async function codeCommand(opts = {}) {
7085
7593
  }
7086
7594
 
7087
7595
  // src/cli/commands/diff.ts
7088
- import { writeFileSync as writeFileSync4 } from "fs";
7596
+ import { writeFileSync as writeFileSync5 } from "fs";
7089
7597
  import { basename as basename2 } from "path";
7090
7598
  import { render as render2 } from "ink";
7091
7599
  import React18 from "react";
@@ -7231,7 +7739,7 @@ async function diffCommand(opts) {
7231
7739
  if (wantMarkdown) {
7232
7740
  console.log(renderSummaryTable(report));
7233
7741
  const md = renderMarkdown(report);
7234
- writeFileSync4(opts.mdPath, md, "utf8");
7742
+ writeFileSync5(opts.mdPath, md, "utf8");
7235
7743
  console.log(`
7236
7744
  markdown report written to ${opts.mdPath}`);
7237
7745
  return;
@@ -8038,13 +8546,13 @@ async function setupCommand(_opts = {}) {
8038
8546
  }
8039
8547
 
8040
8548
  // src/cli/commands/stats.ts
8041
- import { existsSync as existsSync5, readFileSync as readFileSync6 } from "fs";
8549
+ import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
8042
8550
  function statsCommand(opts) {
8043
- if (!existsSync5(opts.transcript)) {
8551
+ if (!existsSync7(opts.transcript)) {
8044
8552
  console.error(`no such transcript: ${opts.transcript}`);
8045
8553
  process.exit(1);
8046
8554
  }
8047
- const lines = readFileSync6(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8555
+ const lines = readFileSync8(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8048
8556
  let assistantTurns = 0;
8049
8557
  let toolCalls = 0;
8050
8558
  let lastTurn = 0;
@@ -8063,6 +8571,84 @@ function statsCommand(opts) {
8063
8571
  console.log(`last turn index: ${lastTurn}`);
8064
8572
  }
8065
8573
 
8574
+ // src/cli/commands/update.ts
8575
+ import { spawn as spawn4 } from "child_process";
8576
+ function planUpdate(input) {
8577
+ const diff = compareVersions(input.current, input.latest);
8578
+ if (diff > 0) {
8579
+ return {
8580
+ action: "newer-local",
8581
+ message: `current (${input.current}) is newer than the published ${input.latest} \u2014 nothing to do.`
8582
+ };
8583
+ }
8584
+ if (diff === 0) {
8585
+ return { action: "up-to-date", message: `reasonix ${input.current} is up to date.` };
8586
+ }
8587
+ if (input.npx) {
8588
+ return {
8589
+ action: "npx-hint",
8590
+ message: [
8591
+ `reasonix ${input.latest} is available.`,
8592
+ "you're running via npx \u2014 the next `npx reasonix ...` launch will auto-fetch",
8593
+ "the latest (npx caches packages for a short window). to force a refresh",
8594
+ "sooner, clear the cache: `npm cache clean --force`."
8595
+ ].join("\n")
8596
+ };
8597
+ }
8598
+ return {
8599
+ action: "run-npm-install",
8600
+ message: `upgrading reasonix ${input.current} \u2192 ${input.latest}`,
8601
+ command: ["npm", "install", "-g", "reasonix@latest"]
8602
+ };
8603
+ }
8604
+ function defaultSpawn(argv) {
8605
+ return new Promise((resolve6, reject) => {
8606
+ const child = spawn4(argv[0], argv.slice(1), {
8607
+ stdio: "inherit",
8608
+ shell: process.platform === "win32"
8609
+ });
8610
+ child.once("error", reject);
8611
+ child.once("exit", (code) => resolve6(code ?? 1));
8612
+ });
8613
+ }
8614
+ async function updateCommand(opts = {}) {
8615
+ const write = opts.write ?? ((m) => process.stdout.write(m));
8616
+ const exit = opts.exit ?? ((c) => process.exit(c));
8617
+ const fetchLatest = opts.fetchLatest ?? (() => getLatestVersion({ force: true }));
8618
+ const isNpx = opts.isNpx ?? isNpxInstall;
8619
+ const doSpawn = opts.spawnInstall ?? defaultSpawn;
8620
+ write(`current: reasonix ${VERSION}
8621
+ `);
8622
+ const latest = await fetchLatest();
8623
+ if (!latest) {
8624
+ write("could not reach registry.npmjs.org \u2014 check your network.\n");
8625
+ exit(1);
8626
+ return;
8627
+ }
8628
+ write(`latest: reasonix ${latest}
8629
+ `);
8630
+ const plan = planUpdate({ current: VERSION, latest, npx: isNpx() });
8631
+ write(`
8632
+ ${plan.message}
8633
+ `);
8634
+ if (plan.action !== "run-npm-install" || !plan.command) return;
8635
+ if (opts.dryRun) {
8636
+ write(`(dry run) would run: ${plan.command.join(" ")}
8637
+ `);
8638
+ return;
8639
+ }
8640
+ write(`
8641
+ running: ${plan.command.join(" ")}
8642
+ `);
8643
+ const code = await doSpawn(plan.command);
8644
+ if (code !== 0) {
8645
+ write(`
8646
+ npm exited with code ${code}. upgrade did not complete.
8647
+ `);
8648
+ exit(code);
8649
+ }
8650
+ }
8651
+
8066
8652
  // src/cli/commands/version.ts
8067
8653
  function versionCommand() {
8068
8654
  console.log(`reasonix ${VERSION}`);
@@ -8255,6 +8841,11 @@ mcp.command("inspect <spec>").description(
8255
8841
  }
8256
8842
  });
8257
8843
  program.command("version").description("Print Reasonix version.").action(versionCommand);
8844
+ program.command("update").description(
8845
+ "Check the npm registry for a newer Reasonix and install it. Detects npx vs global install; for npx users, prints a cache-refresh hint instead of running `npm i -g`."
8846
+ ).option("--dry-run", "Print the plan without executing the install").action(async (opts) => {
8847
+ await updateCommand({ dryRun: !!opts.dryRun });
8848
+ });
8258
8849
  program.parseAsync(process.argv).catch((err) => {
8259
8850
  console.error(err);
8260
8851
  process.exit(1);