reasonix 0.4.23 → 0.4.26

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
@@ -7,7 +7,7 @@ import {
7
7
  memoryEnabled,
8
8
  readProjectMemory,
9
9
  sanitizeMemoryName
10
- } from "./chunk-K6MR4SWS.js";
10
+ } from "./chunk-2BYEKJHX.js";
11
11
 
12
12
  // src/cli/index.ts
13
13
  import { Command } from "commander";
@@ -1893,6 +1893,10 @@ var CacheFirstLoop = class {
1893
1893
  usage = resp.usage;
1894
1894
  }
1895
1895
  } catch (err) {
1896
+ if (signal.aborted) {
1897
+ yield { turn: this._turn, role: "done", content: "" };
1898
+ return;
1899
+ }
1896
1900
  yield {
1897
1901
  turn: this._turn,
1898
1902
  role: "error",
@@ -2212,6 +2216,74 @@ import { promises as fs } from "fs";
2212
2216
  import * as pathMod from "path";
2213
2217
  var DEFAULT_MAX_READ_BYTES = 2 * 1024 * 1024;
2214
2218
  var DEFAULT_MAX_LIST_BYTES = 256 * 1024;
2219
+ var SKIP_DIR_NAMES = /* @__PURE__ */ new Set([
2220
+ "node_modules",
2221
+ ".git",
2222
+ ".hg",
2223
+ ".svn",
2224
+ "dist",
2225
+ "build",
2226
+ "out",
2227
+ ".next",
2228
+ ".nuxt",
2229
+ "target",
2230
+ // Rust / Java
2231
+ ".venv",
2232
+ "venv",
2233
+ "__pycache__",
2234
+ ".pytest_cache",
2235
+ ".mypy_cache",
2236
+ ".cache",
2237
+ "coverage"
2238
+ ]);
2239
+ var BINARY_EXTENSIONS = /* @__PURE__ */ new Set([
2240
+ ".png",
2241
+ ".jpg",
2242
+ ".jpeg",
2243
+ ".gif",
2244
+ ".bmp",
2245
+ ".ico",
2246
+ ".webp",
2247
+ ".tiff",
2248
+ ".pdf",
2249
+ ".zip",
2250
+ ".tar",
2251
+ ".gz",
2252
+ ".bz2",
2253
+ ".xz",
2254
+ ".7z",
2255
+ ".rar",
2256
+ ".exe",
2257
+ ".dll",
2258
+ ".so",
2259
+ ".dylib",
2260
+ ".bin",
2261
+ ".class",
2262
+ ".jar",
2263
+ ".war",
2264
+ ".o",
2265
+ ".obj",
2266
+ ".lib",
2267
+ ".a",
2268
+ ".woff",
2269
+ ".woff2",
2270
+ ".ttf",
2271
+ ".otf",
2272
+ ".eot",
2273
+ ".mp3",
2274
+ ".mp4",
2275
+ ".mov",
2276
+ ".avi",
2277
+ ".webm",
2278
+ ".wasm",
2279
+ ".pyc",
2280
+ ".pyo"
2281
+ ]);
2282
+ function isLikelyBinaryByName(name) {
2283
+ const dot = name.lastIndexOf(".");
2284
+ if (dot < 0) return false;
2285
+ return BINARY_EXTENSIONS.has(name.slice(dot).toLowerCase());
2286
+ }
2215
2287
  function registerFilesystemTools(registry, opts) {
2216
2288
  const rootDir = pathMod.resolve(opts.rootDir);
2217
2289
  const allowWriting = opts.allowWriting !== false;
@@ -2221,7 +2293,12 @@ function registerFilesystemTools(registry, opts) {
2221
2293
  if (typeof raw !== "string" || raw.length === 0) {
2222
2294
  throw new Error("path must be a non-empty string");
2223
2295
  }
2224
- const resolved = pathMod.resolve(rootDir, raw);
2296
+ let normalized = raw;
2297
+ while (normalized.startsWith("/") || normalized.startsWith("\\")) {
2298
+ normalized = normalized.slice(1);
2299
+ }
2300
+ if (normalized.length === 0) normalized = ".";
2301
+ const resolved = pathMod.resolve(rootDir, normalized);
2225
2302
  const normRoot = pathMod.resolve(rootDir);
2226
2303
  const rel = pathMod.relative(normRoot, resolved);
2227
2304
  if (rel.startsWith("..") || pathMod.isAbsolute(rel)) {
@@ -2387,6 +2464,114 @@ function registerFilesystemTools(registry, opts) {
2387
2464
  return matches.length === 0 ? "(no matches)" : matches.join("\n");
2388
2465
  }
2389
2466
  });
2467
+ registry.register({
2468
+ name: "search_content",
2469
+ description: "Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
2470
+ readOnly: true,
2471
+ parameters: {
2472
+ type: "object",
2473
+ properties: {
2474
+ pattern: {
2475
+ type: "string",
2476
+ description: "Substring (or regex) to search file contents for."
2477
+ },
2478
+ path: {
2479
+ type: "string",
2480
+ description: "Directory to start the search at (default: sandbox root)."
2481
+ },
2482
+ glob: {
2483
+ type: "string",
2484
+ description: "Optional file-name suffix or substring filter. Examples: '.ts' (only TypeScript), 'test' (any file with 'test' in the name). Reduces noise when you know the file shape."
2485
+ },
2486
+ case_sensitive: {
2487
+ type: "boolean",
2488
+ description: "When true, match case exactly. Default false (case-insensitive)."
2489
+ },
2490
+ include_deps: {
2491
+ type: "boolean",
2492
+ description: "When true, also search inside node_modules / .git / dist / build / etc. Off by default \u2014 most exploration questions are about the user's own code."
2493
+ }
2494
+ },
2495
+ required: ["pattern"]
2496
+ },
2497
+ fn: async (args) => {
2498
+ const startAbs = safePath(args.path ?? ".");
2499
+ const caseSensitive = args.case_sensitive === true;
2500
+ const includeDeps = args.include_deps === true;
2501
+ const nameFilter = typeof args.glob === "string" ? args.glob.toLowerCase() : null;
2502
+ let re = null;
2503
+ try {
2504
+ re = new RegExp(args.pattern, caseSensitive ? "" : "i");
2505
+ } catch {
2506
+ re = null;
2507
+ }
2508
+ const needle = caseSensitive ? args.pattern : args.pattern.toLowerCase();
2509
+ const matches = [];
2510
+ let totalBytes = 0;
2511
+ let scanned = 0;
2512
+ let truncated = false;
2513
+ const walk2 = async (dir) => {
2514
+ if (truncated) return;
2515
+ let entries;
2516
+ try {
2517
+ entries = await fs.readdir(dir, { withFileTypes: true });
2518
+ } catch {
2519
+ return;
2520
+ }
2521
+ for (const e of entries) {
2522
+ if (truncated) return;
2523
+ if (e.isDirectory()) {
2524
+ if (!includeDeps && SKIP_DIR_NAMES.has(e.name)) continue;
2525
+ await walk2(pathMod.join(dir, e.name));
2526
+ continue;
2527
+ }
2528
+ if (!e.isFile()) continue;
2529
+ if (nameFilter && !e.name.toLowerCase().includes(nameFilter)) continue;
2530
+ if (isLikelyBinaryByName(e.name)) continue;
2531
+ const full = pathMod.join(dir, e.name);
2532
+ let stat;
2533
+ try {
2534
+ stat = await fs.stat(full);
2535
+ } catch {
2536
+ continue;
2537
+ }
2538
+ if (stat.size > 2 * 1024 * 1024) continue;
2539
+ let raw;
2540
+ try {
2541
+ raw = await fs.readFile(full);
2542
+ } catch {
2543
+ continue;
2544
+ }
2545
+ const firstNul = raw.indexOf(0);
2546
+ if (firstNul !== -1 && firstNul < 8 * 1024) continue;
2547
+ const text = raw.toString("utf8");
2548
+ const rel = pathMod.relative(rootDir, full);
2549
+ const lines = text.split(/\r?\n/);
2550
+ for (let li = 0; li < lines.length; li++) {
2551
+ const line = lines[li];
2552
+ const lineForCheck = caseSensitive ? line : line.toLowerCase();
2553
+ const hit = re ? re.test(line) : lineForCheck.includes(needle);
2554
+ if (!hit) continue;
2555
+ const display = line.length > 200 ? `${line.slice(0, 200)}\u2026` : line;
2556
+ const out = `${rel}:${li + 1}: ${display}`;
2557
+ if (totalBytes + out.length + 1 > maxListBytes) {
2558
+ matches.push(`[\u2026 truncated at ${maxListBytes} bytes \u2014 refine pattern or path \u2026]`);
2559
+ truncated = true;
2560
+ return;
2561
+ }
2562
+ matches.push(out);
2563
+ totalBytes += out.length + 1;
2564
+ }
2565
+ scanned++;
2566
+ }
2567
+ };
2568
+ await walk2(startAbs);
2569
+ if (matches.length === 0) {
2570
+ return scanned === 0 ? "(no files scanned \u2014 path empty or all files filtered out)" : `(no matches across ${scanned} file${scanned === 1 ? "" : "s"})`;
2571
+ }
2572
+ return matches.join("\n");
2573
+ }
2574
+ });
2390
2575
  registry.register({
2391
2576
  name: "get_file_info",
2392
2577
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
@@ -2459,10 +2644,10 @@ function registerFilesystemTools(registry, opts) {
2459
2644
  const after = before.slice(0, firstIdx) + args.replace + before.slice(firstIdx + args.search.length);
2460
2645
  await fs.writeFile(abs, after, "utf8");
2461
2646
  const rel = pathMod.relative(rootDir, abs);
2462
- const header = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
2647
+ const header2 = `edited ${rel} (${args.search.length}\u2192${args.replace.length} chars)`;
2463
2648
  const startLine = before.slice(0, firstIdx).split(/\r?\n/).length;
2464
2649
  const diff = renderEditDiff(args.search, args.replace, startLine);
2465
- return `${header}
2650
+ return `${header2}
2466
2651
  ${diff}`;
2467
2652
  }
2468
2653
  });
@@ -2713,6 +2898,127 @@ function registerPlanTool(registry, opts = {}) {
2713
2898
  return registry;
2714
2899
  }
2715
2900
 
2901
+ // src/tools/subagent.ts
2902
+ var DEFAULT_MAX_RESULT_CHARS2 = 8e3;
2903
+ var DEFAULT_MAX_ITERS = 16;
2904
+ var DEFAULT_SUBAGENT_MODEL = "deepseek-chat";
2905
+ var SUBAGENT_TOOL_NAME = "spawn_subagent";
2906
+ var NEVER_INHERITED_TOOLS = /* @__PURE__ */ new Set([SUBAGENT_TOOL_NAME, "submit_plan"]);
2907
+ async function spawnSubagent(opts) {
2908
+ const model = opts.model ?? DEFAULT_SUBAGENT_MODEL;
2909
+ const maxToolIters = opts.maxToolIters ?? DEFAULT_MAX_ITERS;
2910
+ const maxResultChars = opts.maxResultChars ?? DEFAULT_MAX_RESULT_CHARS2;
2911
+ const sink = opts.sink;
2912
+ const startedAt = Date.now();
2913
+ const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}\u2026` : opts.task;
2914
+ sink?.current?.({
2915
+ kind: "start",
2916
+ task: taskPreview,
2917
+ iter: 0,
2918
+ elapsedMs: 0
2919
+ });
2920
+ const childTools = forkRegistryExcluding(opts.parentRegistry, NEVER_INHERITED_TOOLS);
2921
+ const childPrefix = new ImmutablePrefix({
2922
+ system: opts.system,
2923
+ toolSpecs: childTools.specs()
2924
+ });
2925
+ const childLoop = new CacheFirstLoop({
2926
+ client: opts.client,
2927
+ prefix: childPrefix,
2928
+ tools: childTools,
2929
+ model,
2930
+ maxToolIters,
2931
+ hooks: [],
2932
+ stream: false
2933
+ });
2934
+ const onParentAbort = () => childLoop.abort();
2935
+ opts.parentSignal?.addEventListener("abort", onParentAbort, { once: true });
2936
+ let final = "";
2937
+ let errorMessage;
2938
+ let toolIter = 0;
2939
+ try {
2940
+ for await (const ev of childLoop.step(opts.task)) {
2941
+ if (ev.role === "tool") {
2942
+ toolIter++;
2943
+ sink?.current?.({
2944
+ kind: "progress",
2945
+ task: taskPreview,
2946
+ iter: toolIter,
2947
+ elapsedMs: Date.now() - startedAt
2948
+ });
2949
+ }
2950
+ if (ev.role === "assistant_final") {
2951
+ final = ev.content ?? "";
2952
+ }
2953
+ if (ev.role === "error") {
2954
+ errorMessage = ev.error ?? "subagent error";
2955
+ }
2956
+ }
2957
+ } catch (err) {
2958
+ errorMessage = err.message;
2959
+ } finally {
2960
+ opts.parentSignal?.removeEventListener("abort", onParentAbort);
2961
+ }
2962
+ if (!errorMessage && !final) {
2963
+ errorMessage = opts.parentSignal?.aborted ? "subagent aborted before producing an answer" : "subagent ended without producing an answer";
2964
+ }
2965
+ const elapsedMs = Date.now() - startedAt;
2966
+ const turns = childLoop.stats.turns.length;
2967
+ const costUsd2 = childLoop.stats.totalCost;
2968
+ const truncated = final.length > maxResultChars ? `${final.slice(0, maxResultChars)}
2969
+
2970
+ [\u2026truncated ${final.length - maxResultChars} chars; ask the subagent for a tighter summary if you need more.]` : final;
2971
+ sink?.current?.({
2972
+ kind: "end",
2973
+ task: taskPreview,
2974
+ iter: toolIter,
2975
+ elapsedMs,
2976
+ summary: errorMessage ? void 0 : truncated.slice(0, 120),
2977
+ error: errorMessage,
2978
+ turns
2979
+ });
2980
+ return {
2981
+ success: !errorMessage,
2982
+ output: errorMessage ? "" : truncated,
2983
+ error: errorMessage,
2984
+ turns,
2985
+ toolIters: toolIter,
2986
+ elapsedMs,
2987
+ costUsd: costUsd2
2988
+ };
2989
+ }
2990
+ function formatSubagentResult(r) {
2991
+ if (!r.success) {
2992
+ return JSON.stringify({
2993
+ success: false,
2994
+ error: r.error ?? "unknown subagent error",
2995
+ turns: r.turns,
2996
+ tool_iters: r.toolIters,
2997
+ elapsed_ms: r.elapsedMs
2998
+ });
2999
+ }
3000
+ return JSON.stringify({
3001
+ success: true,
3002
+ output: r.output,
3003
+ turns: r.turns,
3004
+ tool_iters: r.toolIters,
3005
+ elapsed_ms: r.elapsedMs,
3006
+ cost_usd: r.costUsd
3007
+ });
3008
+ }
3009
+ function forkRegistryExcluding(parent, exclude) {
3010
+ const child = new ToolRegistry();
3011
+ for (const spec of parent.specs()) {
3012
+ const name = spec.function.name;
3013
+ if (exclude.has(name)) continue;
3014
+ const def = parent.get(name);
3015
+ if (!def) continue;
3016
+ child.register(def);
3017
+ }
3018
+ if (parent.planMode) child.setPlanMode(true);
3019
+ return child;
3020
+ }
3021
+
2716
3022
  // src/tools/shell.ts
2717
3023
  import { spawn as spawn2 } from "child_process";
2718
3024
  import { existsSync as existsSync3, statSync as statSync2 } from "fs";
@@ -2910,7 +3216,7 @@ function prepareSpawn(argv, opts = {}) {
2910
3216
  const cmdline = [resolved, ...tail].map(quoteForCmdExe).join(" ");
2911
3217
  return {
2912
3218
  bin: "cmd.exe",
2913
- args: ["/d", "/s", "/c", cmdline],
3219
+ args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
2914
3220
  // windowsVerbatimArguments prevents Node from re-quoting the /c
2915
3221
  // payload — we've already composed an exact cmd.exe command
2916
3222
  // line. Without this Node wraps our already-quoted string in
@@ -2922,12 +3228,36 @@ function prepareSpawn(argv, opts = {}) {
2922
3228
  const cmdline = [head, ...tail].map(quoteForCmdExe).join(" ");
2923
3229
  return {
2924
3230
  bin: "cmd.exe",
2925
- args: ["/d", "/s", "/c", cmdline],
3231
+ args: ["/d", "/s", "/c", withUtf8Codepage(cmdline)],
2926
3232
  spawnOverrides: { windowsVerbatimArguments: true }
2927
3233
  };
2928
3234
  }
3235
+ if (isPowerShellExe(resolved)) {
3236
+ const patched = injectPowerShellUtf8(tail);
3237
+ if (patched) {
3238
+ return { bin: resolved, args: patched, spawnOverrides: {} };
3239
+ }
3240
+ }
2929
3241
  return { bin: resolved, args: [...tail], spawnOverrides: {} };
2930
3242
  }
3243
+ function isPowerShellExe(resolved) {
3244
+ return /(?:^|[\\/])(?:powershell|pwsh)(?:\.exe)?$/i.test(resolved);
3245
+ }
3246
+ function injectPowerShellUtf8(args) {
3247
+ const prelude = "[Console]::OutputEncoding=[System.Text.Encoding]::UTF8;$OutputEncoding=[System.Text.Encoding]::UTF8;";
3248
+ for (let i = 0; i < args.length; i++) {
3249
+ const a = args[i] ?? "";
3250
+ if (/^-(?:Command|c)$/i.test(a) && i + 1 < args.length) {
3251
+ const out = [...args];
3252
+ out[i + 1] = `${prelude}${args[i + 1] ?? ""}`;
3253
+ return out;
3254
+ }
3255
+ }
3256
+ return null;
3257
+ }
3258
+ function withUtf8Codepage(cmdline) {
3259
+ return `chcp 65001 >nul & ${cmdline}`;
3260
+ }
2931
3261
  function isBareWindowsName(s) {
2932
3262
  if (!s) return false;
2933
3263
  if (s.includes("/") || s.includes("\\")) return false;
@@ -3005,11 +3335,11 @@ function registerShellTools(registry, opts) {
3005
3335
  return registry;
3006
3336
  }
3007
3337
  function formatCommandResult(cmd, r) {
3008
- const header = r.timedOut ? `$ ${cmd}
3338
+ const header2 = r.timedOut ? `$ ${cmd}
3009
3339
  [killed after timeout]` : `$ ${cmd}
3010
3340
  [exit ${r.exitCode ?? "?"}]`;
3011
- return r.output ? `${header}
3012
- ${r.output}` : header;
3341
+ return r.output ? `${header2}
3342
+ ${r.output}` : header2;
3013
3343
  }
3014
3344
 
3015
3345
  // src/tools/web.ts
@@ -3174,9 +3504,9 @@ function registerWebTools(registry, opts = {}) {
3174
3504
  throw new Error("web_fetch: url must start with http:// or https://");
3175
3505
  }
3176
3506
  const page = await webFetch(args.url, { maxChars: maxFetchChars, signal: ctx?.signal });
3177
- const header = page.title ? `${page.title}
3507
+ const header2 = page.title ? `${page.title}
3178
3508
  ${page.url}` : page.url;
3179
- return `${header}
3509
+ return `${header2}
3180
3510
 
3181
3511
  ${page.text}`;
3182
3512
  }
@@ -4582,28 +4912,163 @@ function isNpxInstall() {
4582
4912
  return false;
4583
4913
  }
4584
4914
 
4915
+ // src/usage.ts
4916
+ import { appendFileSync as appendFileSync2, existsSync as existsSync6, mkdirSync as mkdirSync5, readFileSync as readFileSync8, statSync as statSync3 } from "fs";
4917
+ import { homedir as homedir5 } from "os";
4918
+ import { dirname as dirname6, join as join6 } from "path";
4919
+ function defaultUsageLogPath(homeDirOverride) {
4920
+ return join6(homeDirOverride ?? homedir5(), ".reasonix", "usage.jsonl");
4921
+ }
4922
+ function appendUsage(input) {
4923
+ const record = {
4924
+ ts: input.now ?? Date.now(),
4925
+ session: input.session,
4926
+ model: input.model,
4927
+ promptTokens: input.usage.promptTokens,
4928
+ completionTokens: input.usage.completionTokens,
4929
+ cacheHitTokens: input.usage.promptCacheHitTokens,
4930
+ cacheMissTokens: input.usage.promptCacheMissTokens,
4931
+ costUsd: costUsd(input.model, input.usage),
4932
+ claudeEquivUsd: claudeEquivalentCost(input.usage)
4933
+ };
4934
+ const path = input.path ?? defaultUsageLogPath();
4935
+ try {
4936
+ mkdirSync5(dirname6(path), { recursive: true });
4937
+ appendFileSync2(path, `${JSON.stringify(record)}
4938
+ `, "utf8");
4939
+ } catch {
4940
+ }
4941
+ return record;
4942
+ }
4943
+ function readUsageLog(path = defaultUsageLogPath()) {
4944
+ if (!existsSync6(path)) return [];
4945
+ let raw;
4946
+ try {
4947
+ raw = readFileSync8(path, "utf8");
4948
+ } catch {
4949
+ return [];
4950
+ }
4951
+ const out = [];
4952
+ for (const line of raw.split(/\r?\n/)) {
4953
+ if (!line.trim()) continue;
4954
+ try {
4955
+ const rec = JSON.parse(line);
4956
+ if (isValidRecord(rec)) out.push(rec);
4957
+ } catch {
4958
+ }
4959
+ }
4960
+ return out;
4961
+ }
4962
+ function isValidRecord(rec) {
4963
+ if (!rec || typeof rec !== "object") return false;
4964
+ const r = rec;
4965
+ 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";
4966
+ }
4967
+ function bucketCacheHitRatio(b) {
4968
+ const denom = b.cacheHitTokens + b.cacheMissTokens;
4969
+ return denom > 0 ? b.cacheHitTokens / denom : 0;
4970
+ }
4971
+ function bucketSavingsFraction(b) {
4972
+ return b.claudeEquivUsd > 0 ? 1 - b.costUsd / b.claudeEquivUsd : 0;
4973
+ }
4974
+ function emptyBucket(label, since) {
4975
+ return {
4976
+ label,
4977
+ since,
4978
+ turns: 0,
4979
+ promptTokens: 0,
4980
+ completionTokens: 0,
4981
+ cacheHitTokens: 0,
4982
+ cacheMissTokens: 0,
4983
+ costUsd: 0,
4984
+ claudeEquivUsd: 0
4985
+ };
4986
+ }
4987
+ function addToBucket(b, r) {
4988
+ b.turns += 1;
4989
+ b.promptTokens += r.promptTokens;
4990
+ b.completionTokens += r.completionTokens;
4991
+ b.cacheHitTokens += r.cacheHitTokens;
4992
+ b.cacheMissTokens += r.cacheMissTokens;
4993
+ b.costUsd += r.costUsd;
4994
+ b.claudeEquivUsd += r.claudeEquivUsd;
4995
+ }
4996
+ function aggregateUsage(records, opts = {}) {
4997
+ const now = opts.now ?? Date.now();
4998
+ const day = 24 * 60 * 60 * 1e3;
4999
+ const today = emptyBucket("today", now - day);
5000
+ const week = emptyBucket("week", now - 7 * day);
5001
+ const month = emptyBucket("month", now - 30 * day);
5002
+ const all = emptyBucket("all-time", 0);
5003
+ const modelCounts = /* @__PURE__ */ new Map();
5004
+ const sessionCounts = /* @__PURE__ */ new Map();
5005
+ let firstSeen = null;
5006
+ let lastSeen = null;
5007
+ for (const r of records) {
5008
+ addToBucket(all, r);
5009
+ if (r.ts >= today.since) addToBucket(today, r);
5010
+ if (r.ts >= week.since) addToBucket(week, r);
5011
+ if (r.ts >= month.since) addToBucket(month, r);
5012
+ modelCounts.set(r.model, (modelCounts.get(r.model) ?? 0) + 1);
5013
+ const sessKey = r.session ?? "(ephemeral)";
5014
+ sessionCounts.set(sessKey, (sessionCounts.get(sessKey) ?? 0) + 1);
5015
+ if (firstSeen === null || r.ts < firstSeen) firstSeen = r.ts;
5016
+ if (lastSeen === null || r.ts > lastSeen) lastSeen = r.ts;
5017
+ }
5018
+ const byModel = Array.from(modelCounts.entries()).map(([model, turns]) => ({ model, turns })).sort((a, b) => b.turns - a.turns);
5019
+ const bySession = Array.from(sessionCounts.entries()).map(([session, turns]) => ({ session, turns })).sort((a, b) => b.turns - a.turns);
5020
+ return {
5021
+ buckets: [today, week, month, all],
5022
+ byModel,
5023
+ bySession,
5024
+ firstSeen,
5025
+ lastSeen
5026
+ };
5027
+ }
5028
+ function formatLogSize(path = defaultUsageLogPath()) {
5029
+ if (!existsSync6(path)) return "";
5030
+ try {
5031
+ const s = statSync3(path);
5032
+ const bytes = s.size;
5033
+ if (bytes < 1024) return `${bytes} B`;
5034
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
5035
+ return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
5036
+ } catch {
5037
+ return "";
5038
+ }
5039
+ }
5040
+
4585
5041
  // src/cli/commands/chat.tsx
4586
- import { existsSync as existsSync6, statSync as statSync3 } from "fs";
5042
+ import { existsSync as existsSync8, statSync as statSync4 } from "fs";
4587
5043
  import { render } from "ink";
4588
5044
  import React15, { useState as useState7 } from "react";
4589
5045
 
5046
+ // src/cli/ui/App.tsx
5047
+ import { Box as Box11, Static, Text as Text11, useApp, useInput as useInput4 } from "ink";
5048
+ import React12, { useCallback, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState5 } from "react";
5049
+
4590
5050
  // src/tools/skills.ts
4591
5051
  function registerSkillTools(registry, opts = {}) {
4592
- const store = new SkillStore({ homeDir: opts.homeDir, projectRoot: opts.projectRoot });
5052
+ const store = new SkillStore({
5053
+ homeDir: opts.homeDir,
5054
+ projectRoot: opts.projectRoot,
5055
+ disableBuiltins: opts.disableBuiltins
5056
+ });
5057
+ const subagentRunner = opts.subagentRunner;
4593
5058
  registry.register({
4594
5059
  name: "run_skill",
4595
- description: "Load the full body of a user-defined skill into this conversation. Call when the pinned Skills index (in the system prompt) lists a skill whose description matches what's being asked. Returns the skill's markdown instructions \u2014 read them and continue the loop, calling whatever filesystem / shell / web tools the skill's prose requires. Skills are user content; follow their instructions, but keep Reasonix's own safety rules (no destructive ops without confirmation, etc.).",
5060
+ description: "Invoke a playbook from the Skills index pinned in the system prompt. Each entry is a self-contained instruction block. Skills marked with \u{1F9EC} in the index spawn an isolated subagent \u2014 only the final distilled answer comes back, the model's tool calls + reasoning during the run never enter your context. Plain skills are inlined: the body becomes a tool result you read and follow. For \u{1F9EC} subagent skills, supply 'arguments' describing the concrete task \u2014 they'll be the only context the subagent has.",
4596
5061
  readOnly: true,
4597
5062
  parameters: {
4598
5063
  type: "object",
4599
5064
  properties: {
4600
5065
  name: {
4601
5066
  type: "string",
4602
- description: "Skill identifier as it appears in the pinned Skills index (e.g. 'review', 'security-review'). Case-sensitive."
5067
+ description: "Skill identifier as it appears in the pinned Skills index (e.g. 'explore', 'review', 'security-review'). Case-sensitive."
4603
5068
  },
4604
5069
  arguments: {
4605
5070
  type: "string",
4606
- description: "Optional free-form arguments the caller wants the skill to act on. Forwarded verbatim as an 'Arguments:' line appended to the skill body; the skill's own instructions decide how to consume them."
5071
+ description: "Free-form arguments the skill should act on. For inline skills: appended to the body as an 'Arguments:' line; the skill's own instructions decide how to consume them. For \u{1F9EC} subagent skills: REQUIRED \u2014 becomes the entire task description the subagent receives, since it has no other context."
4607
5072
  }
4608
5073
  },
4609
5074
  required: ["name"]
@@ -4622,7 +5087,20 @@ function registerSkillTools(registry, opts = {}) {
4622
5087
  });
4623
5088
  }
4624
5089
  const rawArgs = typeof args.arguments === "string" ? args.arguments.trim() : "";
4625
- const header = [
5090
+ if (skill.runAs === "subagent") {
5091
+ if (!subagentRunner) {
5092
+ return JSON.stringify({
5093
+ error: `run_skill: skill ${JSON.stringify(name)} is marked runAs=subagent but no subagent runner is configured for this session. Skill authors who need isolation should run inside reasonix code (or a library setup that passes subagentRunner to registerSkillTools).`
5094
+ });
5095
+ }
5096
+ if (!rawArgs) {
5097
+ return JSON.stringify({
5098
+ error: `run_skill: skill ${JSON.stringify(name)} is a subagent and requires 'arguments' \u2014 the subagent has no other context, so describe the concrete task in the arguments field.`
5099
+ });
5100
+ }
5101
+ return subagentRunner(skill, rawArgs);
5102
+ }
5103
+ const header2 = [
4626
5104
  `# Skill: ${skill.name}`,
4627
5105
  skill.description ? `> ${skill.description}` : "",
4628
5106
  `(scope: ${skill.scope} \xB7 ${skill.path})`
@@ -4630,7 +5108,7 @@ function registerSkillTools(registry, opts = {}) {
4630
5108
  const argsBlock = rawArgs ? `
4631
5109
 
4632
5110
  Arguments: ${rawArgs}` : "";
4633
- return `${header}
5111
+ return `${header2}
4634
5112
 
4635
5113
  ${skill.body}${argsBlock}`;
4636
5114
  }
@@ -4638,10 +5116,6 @@ ${skill.body}${argsBlock}`;
4638
5116
  return registry;
4639
5117
  }
4640
5118
 
4641
- // src/cli/ui/App.tsx
4642
- import { Box as Box11, Static, Text as Text11, useApp, useInput as useInput4 } from "ink";
4643
- import React12, { useCallback, useEffect as useEffect2, useMemo, useRef as useRef2, useState as useState5 } from "react";
4644
-
4645
5119
  // src/cli/ui/EventLog.tsx
4646
5120
  import { Box as Box3, Text as Text3 } from "ink";
4647
5121
  import React4 from "react";
@@ -4714,7 +5188,7 @@ function stripMath(s) {
4714
5188
  ).replace(/\\sqrt\s*\{([^{}]+)\}/g, (_m, g) => `\u221A(${g.trim()})`).replace(/\\boxed\s*\{([^{}]+)\}/g, (_m, g) => `\u3010${g.trim()}\u3011`).replace(/\\text\s*\{([^{}]+)\}/g, (_m, g) => g.trim()).replace(/\\overline\s*\{([^{}]+)\}/g, (_m, g) => `${g.trim()}\u0304`).replace(/\\hat\s*\{([^{}]+)\}/g, (_m, g) => `${g.trim()}\u0302`).replace(/\\vec\s*\{([^{}]+)\}/g, (_m, g) => `\u2192${g.trim()}`).replace(/\\cdot/g, "\xB7").replace(/\\times/g, "\xD7").replace(/\\div/g, "\xF7").replace(/\\pm/g, "\xB1").replace(/\\mp/g, "\u2213").replace(/\\leq/g, "\u2264").replace(/\\geq/g, "\u2265").replace(/\\neq/g, "\u2260").replace(/\\approx/g, "\u2248").replace(/\\in\b/g, "\u2208").replace(/\\notin\b/g, "\u2209").replace(/\\infty/g, "\u221E").replace(/\\sum\b/g, "\u03A3").replace(/\\prod\b/g, "\u03A0").replace(/\\int\b/g, "\u222B").replace(/\\alpha/g, "\u03B1").replace(/\\beta/g, "\u03B2").replace(/\\gamma/g, "\u03B3").replace(/\\delta/g, "\u03B4").replace(/\\theta/g, "\u03B8").replace(/\\lambda/g, "\u03BB").replace(/\\mu/g, "\u03BC").replace(/\\pi/g, "\u03C0").replace(/\\sigma/g, "\u03C3").replace(/\\phi/g, "\u03C6").replace(/\\omega/g, "\u03C9").replace(/\\implies\b/g, "\u21D2").replace(/\\iff\b/g, "\u21D4").replace(/\\to\b/g, "\u2192").replace(/\\rightarrow/g, "\u2192").replace(/\\Rightarrow/g, "\u21D2").replace(/\\leftarrow/g, "\u2190").replace(/\\Leftarrow/g, "\u21D0").replace(/\\ldots/g, "\u2026").replace(/\\cdots/g, "\u22EF").replace(/\\quad/g, " ").replace(/\\qquad/g, " ").replace(/\\,/g, " ").replace(/\\;/g, " ").replace(/\\!/g, "").replace(/\\\\/g, "\n").replace(/\^\{([\w+-]+)\}/g, (_m, g) => toSuperscript(g)).replace(/\^([0-9+\-n])/g, (_m, g) => toSuperscript(g)).replace(/_\{([\w+-]+)\}/g, (_m, g) => toSubscript(g)).replace(/_([0-9+\-])/g, (_m, g) => toSubscript(g)).replace(/\\[a-zA-Z]+\s*\{([^{}]+)\}\s*\{([^{}]+)\}/g, "($1)/($2)").replace(/\\[a-zA-Z]+\s*\{([^{}]+)\}/g, "$1").replace(/\\[a-zA-Z]+/g, "").replace(/[ \t]{2,}/g, " ");
4715
5189
  }
4716
5190
  var INLINE_RE = /(\*\*([^*\n]+?)\*\*|```([^\n]+?)```|`([^`\n]+?)`|(?<![*\w])\*([^*\n]+?)\*(?!\w))/g;
4717
- function InlineMd({ text }) {
5191
+ function InlineMd({ text, padTo }) {
4718
5192
  const parts = [];
4719
5193
  let last = 0;
4720
5194
  let idx = 0;
@@ -4746,8 +5220,20 @@ function InlineMd({ text }) {
4746
5220
  if (last < text.length) {
4747
5221
  parts.push(/* @__PURE__ */ React2.createElement(Text2, { key: `t${idx++}` }, text.slice(last)));
4748
5222
  }
5223
+ if (padTo !== void 0) {
5224
+ const seen = visibleWidth(text);
5225
+ if (seen < padTo) {
5226
+ parts.push(/* @__PURE__ */ React2.createElement(Text2, { key: `pad${idx++}` }, " ".repeat(padTo - seen)));
5227
+ }
5228
+ }
4749
5229
  return /* @__PURE__ */ React2.createElement(Text2, null, parts);
4750
5230
  }
5231
+ function stripInlineMarkup(s) {
5232
+ return s.replace(/\*\*([^*\n]+?)\*\*/g, "$1").replace(/```([^\n]+?)```/g, (_m, c) => c.replace(/^(\w+)\s+/, "")).replace(/`([^`\n]+?)`/g, "$1").replace(/(?<![*\w])\*([^*\n]+?)\*(?!\w)/g, "$1");
5233
+ }
5234
+ function visibleWidth(s) {
5235
+ return displayWidth(stripInlineMarkup(s));
5236
+ }
4751
5237
  function parseBlocks(raw) {
4752
5238
  const lines = raw.split(/\r?\n/);
4753
5239
  const out = [];
@@ -4854,21 +5340,51 @@ function parseBlocks(raw) {
4854
5340
  out.push({ kind: "heading", level: hm[1].length, text: hm[2].trim() });
4855
5341
  continue;
4856
5342
  }
4857
- if (line.includes("|")) {
5343
+ if (/^\s*┌─+┐\s*$/.test(line)) {
5344
+ let j = i + 1;
5345
+ const bodyLines = [];
5346
+ while (j < lines.length && !/^\s*└─+┘\s*$/.test(lines[j])) {
5347
+ const inner = lines[j];
5348
+ const m = inner.match(/^\s*│\s?(.*?)\s?│\s*$/);
5349
+ bodyLines.push(m ? m[1] ?? "" : inner);
5350
+ j++;
5351
+ }
5352
+ if (j < lines.length) {
5353
+ flushPara();
5354
+ flushList();
5355
+ out.push({ kind: "code", lang: "", text: bodyLines.join("\n") });
5356
+ i = j;
5357
+ continue;
5358
+ }
5359
+ }
5360
+ if (line.includes("|") || line.includes("\u2502")) {
4858
5361
  const next = (lines[i + 1] ?? "").trim();
4859
- if (/^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(next)) {
5362
+ const isGfmSep = /^\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)+\|?\s*$/.test(next);
5363
+ const isBoxSep = /^[│─┼┬┴┌┐└┘├┤\s]+$/.test(next) && /─{2,}/.test(next);
5364
+ if (isGfmSep || isBoxSep) {
4860
5365
  flushPara();
4861
5366
  flushList();
4862
- const header = splitTableRow(line);
5367
+ const header2 = splitTableRow(line);
5368
+ const colCount = header2.length;
4863
5369
  const rows = [];
4864
5370
  let j = i + 2;
4865
5371
  while (j < lines.length) {
4866
5372
  const r = lines[j].replace(/\s+$/g, "");
4867
- if (r.trim() === "" || !r.includes("|")) break;
5373
+ if (r.trim() === "") break;
5374
+ if (!r.includes("|") && !r.includes("\u2502")) {
5375
+ const prev = rows[rows.length - 1];
5376
+ if (prev && prev.length === colCount) {
5377
+ const lastIdx = prev.length - 1;
5378
+ prev[lastIdx] = `${prev[lastIdx] ?? ""} ${r.trim()}`;
5379
+ j++;
5380
+ continue;
5381
+ }
5382
+ break;
5383
+ }
4868
5384
  rows.push(splitTableRow(r));
4869
5385
  j++;
4870
5386
  }
4871
- out.push({ kind: "table", header, rows });
5387
+ out.push({ kind: "table", header: header2, rows });
4872
5388
  i = j - 1;
4873
5389
  continue;
4874
5390
  }
@@ -4923,7 +5439,7 @@ function BlockView({ block }) {
4923
5439
  }
4924
5440
  function splitTableRow(line) {
4925
5441
  const SENTINEL = "\0";
4926
- const masked = line.replace(/\\\|/g, SENTINEL);
5442
+ const masked = line.replace(/\\\|/g, SENTINEL).replace(/│/g, "|");
4927
5443
  const trimmed = masked.trim().replace(/^\||\|$/g, "");
4928
5444
  return trimmed.split("|").map((c) => c.trim().replace(new RegExp(SENTINEL, "g"), "|"));
4929
5445
  }
@@ -4931,24 +5447,19 @@ function TableBlockRow({ block }) {
4931
5447
  const colCount = Math.max(block.header.length, ...block.rows.map((r) => r.length));
4932
5448
  const widths = [];
4933
5449
  for (let c = 0; c < colCount; c++) {
4934
- const cellLengths = [displayWidth(block.header[c] ?? "")];
4935
- for (const r of block.rows) cellLengths.push(displayWidth(r[c] ?? ""));
5450
+ const cellLengths = [visibleWidth(block.header[c] ?? "")];
5451
+ for (const r of block.rows) cellLengths.push(visibleWidth(r[c] ?? ""));
4936
5452
  widths.push(Math.min(40, Math.max(3, ...cellLengths)));
4937
5453
  }
4938
- const pad2 = (s, w) => {
4939
- const dw = displayWidth(s);
4940
- if (dw >= w) return s;
4941
- return s + " ".repeat(w - dw);
4942
- };
4943
5454
  const separator = widths.map((w) => "\u2500".repeat(w)).join("\u2500\u253C\u2500");
4944
5455
  return /* @__PURE__ */ React2.createElement(Box2, { flexDirection: "column" }, /* @__PURE__ */ React2.createElement(Box2, null, block.header.map((cell, ci) => (
4945
5456
  // biome-ignore lint/suspicious/noArrayIndexKey: table columns never reorder — derived from a static header array
4946
- /* @__PURE__ */ React2.createElement(Text2, { key: `h-${ci}`, bold: true, color: "cyan" }, pad2(cell, widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
5457
+ /* @__PURE__ */ React2.createElement(Text2, { key: `h-${ci}`, bold: true, color: "cyan" }, /* @__PURE__ */ React2.createElement(InlineMd, { text: cell, padTo: widths[ci] ?? 3 }), ci < colCount - 1 ? " \u2502 " : "")
4947
5458
  ))), /* @__PURE__ */ React2.createElement(Text2, { dimColor: true }, separator), block.rows.map((row2, ri) => (
4948
5459
  // biome-ignore lint/suspicious/noArrayIndexKey: table rows render in source order and don't reorder
4949
5460
  /* @__PURE__ */ React2.createElement(Box2, { key: `r-${ri}` }, Array.from({ length: colCount }).map((_, ci) => (
4950
5461
  // biome-ignore lint/suspicious/noArrayIndexKey: same — column axis is fixed by the table shape
4951
- /* @__PURE__ */ React2.createElement(Text2, { key: `c-${ri}-${ci}` }, pad2(row2[ci] ?? "", widths[ci] ?? 3), ci < colCount - 1 ? " \u2502 " : "")
5462
+ /* @__PURE__ */ React2.createElement(Text2, { key: `c-${ri}-${ci}` }, /* @__PURE__ */ React2.createElement(InlineMd, { text: row2[ci] ?? "", padTo: widths[ci] ?? 3 }), ci < colCount - 1 ? " \u2502 " : "")
4952
5463
  )))
4953
5464
  )));
4954
5465
  }
@@ -5593,6 +6104,114 @@ function formatTokens(n) {
5593
6104
 
5594
6105
  // src/cli/ui/slash.ts
5595
6106
  import { spawnSync } from "child_process";
6107
+
6108
+ // src/cli/commands/stats.ts
6109
+ import { existsSync as existsSync7, readFileSync as readFileSync9 } from "fs";
6110
+ function statsCommand(opts) {
6111
+ if (opts.transcript) {
6112
+ transcriptSummary(opts.transcript);
6113
+ return;
6114
+ }
6115
+ dashboard(opts);
6116
+ }
6117
+ function transcriptSummary(path) {
6118
+ if (!existsSync7(path)) {
6119
+ console.error(`no such transcript: ${path}`);
6120
+ process.exit(1);
6121
+ }
6122
+ const lines = readFileSync9(path, "utf8").split(/\r?\n/).filter(Boolean);
6123
+ let assistantTurns = 0;
6124
+ let toolCalls = 0;
6125
+ let lastTurn = 0;
6126
+ for (const line of lines) {
6127
+ try {
6128
+ const rec = JSON.parse(line);
6129
+ if (rec.role === "assistant_final") assistantTurns++;
6130
+ if (rec.role === "tool") toolCalls++;
6131
+ if (typeof rec.turn === "number") lastTurn = Math.max(lastTurn, rec.turn);
6132
+ } catch {
6133
+ }
6134
+ }
6135
+ console.log(`transcript: ${path}`);
6136
+ console.log(`assistant turns: ${assistantTurns}`);
6137
+ console.log(`tool invocations: ${toolCalls}`);
6138
+ console.log(`last turn index: ${lastTurn}`);
6139
+ }
6140
+ function dashboard(opts) {
6141
+ const path = opts.logPath ?? defaultUsageLogPath();
6142
+ const records = readUsageLog(path);
6143
+ if (records.length === 0) {
6144
+ console.log("no usage data yet.");
6145
+ console.log("");
6146
+ console.log(` ${path}`);
6147
+ console.log("");
6148
+ console.log("run `reasonix chat`, `reasonix code`, or `reasonix run <task>` \u2014 every turn");
6149
+ console.log("appends one line to the log and `reasonix stats` will roll it up.");
6150
+ return;
6151
+ }
6152
+ const agg = aggregateUsage(records, { now: opts.now });
6153
+ console.log(renderDashboard(agg, path));
6154
+ }
6155
+ function renderDashboard(agg, logPath) {
6156
+ const lines = [];
6157
+ const size = formatLogSize(logPath);
6158
+ lines.push(`Reasonix usage \u2014 ${logPath}${size ? ` (${size})` : ""}`);
6159
+ lines.push("");
6160
+ lines.push(header());
6161
+ lines.push(divider());
6162
+ for (const b of agg.buckets) {
6163
+ lines.push(bucketRow(b));
6164
+ }
6165
+ lines.push("");
6166
+ if (agg.byModel.length > 0) {
6167
+ const totalTurns = agg.buckets[agg.buckets.length - 1]?.turns ?? 0;
6168
+ const top = agg.byModel[0];
6169
+ if (top && totalTurns > 0) {
6170
+ const pct2 = (top.turns / totalTurns * 100).toFixed(0);
6171
+ lines.push(`most used model: ${top.model} (${pct2}% of turns)`);
6172
+ }
6173
+ }
6174
+ if (agg.bySession.length > 0) {
6175
+ const top = agg.bySession[0];
6176
+ if (top) lines.push(`top session: ${top.session} (${top.turns} turns)`);
6177
+ }
6178
+ if (agg.firstSeen) {
6179
+ lines.push(`tracked since: ${new Date(agg.firstSeen).toISOString().slice(0, 10)}`);
6180
+ }
6181
+ return lines.join("\n");
6182
+ }
6183
+ function header() {
6184
+ return [
6185
+ pad("", 10),
6186
+ pad("turns", 8, "right"),
6187
+ pad("cache hit", 10, "right"),
6188
+ pad("cost (USD)", 14, "right"),
6189
+ pad("vs Claude", 14, "right"),
6190
+ pad("saved", 10, "right")
6191
+ ].join(" ");
6192
+ }
6193
+ function divider() {
6194
+ return "-".repeat(70);
6195
+ }
6196
+ function bucketRow(b) {
6197
+ const hit = bucketCacheHitRatio(b);
6198
+ const savings = bucketSavingsFraction(b);
6199
+ return [
6200
+ pad(b.label, 10),
6201
+ pad(b.turns.toString(), 8, "right"),
6202
+ pad(b.turns > 0 ? `${(hit * 100).toFixed(1)}%` : "\u2014", 10, "right"),
6203
+ pad(b.turns > 0 ? `$${b.costUsd.toFixed(6)}` : "\u2014", 14, "right"),
6204
+ pad(b.turns > 0 ? `$${b.claudeEquivUsd.toFixed(4)}` : "\u2014", 14, "right"),
6205
+ pad(b.turns > 0 && savings > 0 ? `${(savings * 100).toFixed(1)}%` : "\u2014", 10, "right")
6206
+ ].join(" ");
6207
+ }
6208
+ function pad(s, width, align = "left") {
6209
+ if (s.length >= width) return s;
6210
+ const fill = " ".repeat(width - s.length);
6211
+ return align === "right" ? `${fill}${s}` : `${s}${fill}`;
6212
+ }
6213
+
6214
+ // src/cli/ui/slash.ts
5596
6215
  var SLASH_COMMANDS = [
5597
6216
  { cmd: "help", summary: "show the full command reference" },
5598
6217
  { cmd: "status", summary: "current model, flags, context, session" },
@@ -5625,6 +6244,10 @@ var SLASH_COMMANDS = [
5625
6244
  cmd: "update",
5626
6245
  summary: "show current vs latest version + the shell command to upgrade"
5627
6246
  },
6247
+ {
6248
+ cmd: "stats",
6249
+ summary: "cross-session cost dashboard (today / week / month / all-time \xB7 cache hit \xB7 vs Claude)"
6250
+ },
5628
6251
  { cmd: "think", summary: "dump the last turn's full R1 reasoning (reasoner only)" },
5629
6252
  { cmd: "retry", summary: "truncate & resend your last message (fresh sample)" },
5630
6253
  { cmd: "compact", argsHint: "[cap]", summary: "shrink oversized tool results in the log" },
@@ -5805,6 +6428,9 @@ function handleSlash(cmd, args, loop, ctx = {}) {
5805
6428
  case "update": {
5806
6429
  return handleUpdateSlash(ctx);
5807
6430
  }
6431
+ case "stats": {
6432
+ return handleStatsSlash();
6433
+ }
5808
6434
  case "think":
5809
6435
  case "reasoning": {
5810
6436
  const raw = loop.scratch.reasoning;
@@ -6041,6 +6667,24 @@ ${entry.text}`
6041
6667
  return { unknown: true, info: `unknown command: /${cmd} (try /help)` };
6042
6668
  }
6043
6669
  }
6670
+ function handleStatsSlash() {
6671
+ const path = defaultUsageLogPath();
6672
+ const records = readUsageLog(path);
6673
+ if (records.length === 0) {
6674
+ return {
6675
+ info: [
6676
+ "no usage data yet.",
6677
+ "",
6678
+ ` ${path}`,
6679
+ "",
6680
+ "every turn you run here appends one record \u2014 this session's turns",
6681
+ "will show up in the dashboard once you send a message."
6682
+ ].join("\n")
6683
+ };
6684
+ }
6685
+ const agg = aggregateUsage(records);
6686
+ return { info: renderDashboard(agg, path) };
6687
+ }
6044
6688
  function handleUpdateSlash(ctx) {
6045
6689
  const latest = ctx.latestVersion ?? null;
6046
6690
  const lines = [`current: reasonix ${VERSION}`];
@@ -6185,12 +6829,12 @@ function handleSkillSlash(args, ctx) {
6185
6829
  };
6186
6830
  }
6187
6831
  const extra = args.slice(1).join(" ").trim();
6188
- const header = `# Skill: ${skill.name}${skill.description ? `
6832
+ const header2 = `# Skill: ${skill.name}${skill.description ? `
6189
6833
  > ${skill.description}` : ""}`;
6190
6834
  const argsLine = extra ? `
6191
6835
 
6192
6836
  Arguments: ${extra}` : "";
6193
- const payload = `${header}
6837
+ const payload = `${header2}
6194
6838
 
6195
6839
  ${skill.body}${argsLine}`;
6196
6840
  return {
@@ -6368,9 +7012,9 @@ function appendSection(lines, label, section) {
6368
7012
  }
6369
7013
  function formatToolList(history) {
6370
7014
  const total = history.length;
6371
- const header = `Tool calls in this session (${total}, most recent first):`;
7015
+ const header2 = `Tool calls in this session (${total}, most recent first):`;
6372
7016
  const shown = Math.min(total, 10);
6373
- const lines = [header];
7017
+ const lines = [header2];
6374
7018
  for (let i = 0; i < shown; i++) {
6375
7019
  const entry = history[total - 1 - i];
6376
7020
  if (!entry) continue;
@@ -6451,6 +7095,7 @@ function App({
6451
7095
  const abortedThisTurn = useRef2(false);
6452
7096
  const [ongoingTool, setOngoingTool] = useState5(null);
6453
7097
  const [toolProgress, setToolProgress] = useState5(null);
7098
+ const [subagentActivity, setSubagentActivity] = useState5(null);
6454
7099
  const [statusLine, setStatusLine] = useState5(null);
6455
7100
  const [balance, setBalance] = useState5(null);
6456
7101
  const [latestVersion, setLatestVersion] = useState5(null);
@@ -6507,9 +7152,30 @@ function App({
6507
7152
  });
6508
7153
  }, [slashMatches]);
6509
7154
  const loopRef = useRef2(null);
7155
+ const subagentSinkRef = useRef2({ current: null });
6510
7156
  const loop = useMemo(() => {
6511
7157
  if (loopRef.current) return loopRef.current;
6512
7158
  const client = new DeepSeekClient();
7159
+ if (tools && !tools.has("run_skill")) {
7160
+ registerSkillTools(tools, {
7161
+ projectRoot: codeMode?.rootDir,
7162
+ subagentRunner: async (skill, task) => {
7163
+ const result = await spawnSubagent({
7164
+ client,
7165
+ parentRegistry: tools,
7166
+ // Skill body is the subagent's persona/playbook; the user-
7167
+ // supplied task is what to actually do inside it.
7168
+ system: skill.body,
7169
+ task,
7170
+ // Per-skill model override (frontmatter `model: ...`),
7171
+ // else falls through to spawnSubagent's default.
7172
+ model: skill.model,
7173
+ sink: subagentSinkRef.current
7174
+ });
7175
+ return formatSubagentResult(result);
7176
+ }
7177
+ });
7178
+ }
6513
7179
  const prefix = new ImmutablePrefix({
6514
7180
  system,
6515
7181
  toolSpecs: tools?.specs()
@@ -6527,7 +7193,7 @@ function App({
6527
7193
  });
6528
7194
  loopRef.current = l;
6529
7195
  return l;
6530
- }, [model, system, harvest2, branch, session, tools]);
7196
+ }, [model, system, harvest2, branch, session, tools, codeMode]);
6531
7197
  useEffect2(() => {
6532
7198
  loop.hooks = hookList;
6533
7199
  }, [loop, hookList]);
@@ -6567,6 +7233,40 @@ function App({
6567
7233
  if (progressSink.current) progressSink.current = null;
6568
7234
  };
6569
7235
  }, [progressSink]);
7236
+ useEffect2(() => {
7237
+ subagentSinkRef.current.current = (ev) => {
7238
+ if (ev.kind === "start") {
7239
+ setSubagentActivity({
7240
+ task: ev.task,
7241
+ iter: ev.iter ?? 0,
7242
+ elapsedMs: ev.elapsedMs ?? 0
7243
+ });
7244
+ return;
7245
+ }
7246
+ if (ev.kind === "progress") {
7247
+ setSubagentActivity({
7248
+ task: ev.task,
7249
+ iter: ev.iter ?? 0,
7250
+ elapsedMs: ev.elapsedMs ?? 0
7251
+ });
7252
+ return;
7253
+ }
7254
+ setSubagentActivity(null);
7255
+ const seconds = ((ev.elapsedMs ?? 0) / 1e3).toFixed(1);
7256
+ const summary2 = ev.error ? `\u{1F9EC} subagent "${ev.task}" failed after ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \u2014 ${ev.error}` : `\u{1F9EC} subagent "${ev.task}" done in ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \xB7 ${ev.turns ?? 0} turn(s)`;
7257
+ setHistorical((prev) => [
7258
+ ...prev,
7259
+ {
7260
+ id: `subagent-end-${Date.now()}`,
7261
+ role: "info",
7262
+ text: summary2
7263
+ }
7264
+ ]);
7265
+ };
7266
+ return () => {
7267
+ subagentSinkRef.current.current = null;
7268
+ };
7269
+ }, []);
6570
7270
  const sessionBannerShown = useRef2(false);
6571
7271
  useEffect2(() => {
6572
7272
  if (sessionBannerShown.current) return;
@@ -6864,6 +7564,13 @@ function App({
6864
7564
  const repairNote = ev.repair ? describeRepair(ev.repair) : "";
6865
7565
  setStreaming(null);
6866
7566
  setSummary(loop.stats.summary());
7567
+ if (ev.stats?.usage) {
7568
+ appendUsage({
7569
+ session: session ?? null,
7570
+ model: ev.stats.model,
7571
+ usage: ev.stats.usage
7572
+ });
7573
+ }
6867
7574
  const finalText = ev.content || streamRef.text;
6868
7575
  const iterReasoning = streamRef.reasoning || void 0;
6869
7576
  const iterId = `${assistantId}-i${assistantIterCounter.current++}`;
@@ -7006,6 +7713,7 @@ function App({
7006
7713
  mcpSpecs,
7007
7714
  mcpServers,
7008
7715
  planMode,
7716
+ session,
7009
7717
  slashSelected,
7010
7718
  togglePlanMode,
7011
7719
  writeTranscript
@@ -7169,7 +7877,7 @@ Stay in plan mode \u2014 address the feedback (explore more if needed), then sub
7169
7877
  balance,
7170
7878
  updateAvailable
7171
7879
  }
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(
7880
+ ), /* @__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 && subagentActivity ? /* @__PURE__ */ React12.createElement(SubagentRow, { activity: subagentActivity }) : 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(
7173
7881
  PlanRefineInput,
7174
7882
  {
7175
7883
  mode: stagedInput.mode,
@@ -7199,6 +7907,13 @@ function StatusRow({ text }) {
7199
7907
  const elapsed = useElapsedSeconds();
7200
7908
  return /* @__PURE__ */ React12.createElement(Box11, { marginY: 1 }, /* @__PURE__ */ React12.createElement(Text11, { color: "magenta" }, SPINNER_FRAMES[tick % SPINNER_FRAMES.length]), /* @__PURE__ */ React12.createElement(Text11, { color: "magenta" }, ` ${text}`), /* @__PURE__ */ React12.createElement(Text11, { dimColor: true }, ` ${elapsed}s`));
7201
7909
  }
7910
+ function SubagentRow({
7911
+ activity
7912
+ }) {
7913
+ const tick = useTick();
7914
+ const seconds = (activity.elapsedMs / 1e3).toFixed(1);
7915
+ return /* @__PURE__ */ React12.createElement(Box11, { paddingLeft: 2 }, /* @__PURE__ */ React12.createElement(Text11, { color: "magenta" }, SPINNER_FRAMES[tick % SPINNER_FRAMES.length]), /* @__PURE__ */ React12.createElement(Text11, { color: "magenta" }, ` \u{1F9EC} subagent: ${activity.task}`), /* @__PURE__ */ React12.createElement(Text11, { dimColor: true }, ` \xB7 iter ${activity.iter} \xB7 ${seconds}s`));
7916
+ }
7202
7917
  function OngoingToolRow({
7203
7918
  tool,
7204
7919
  progress
@@ -7268,8 +7983,8 @@ function formatEditResults(results) {
7268
7983
  });
7269
7984
  const ok = results.filter((r) => r.status === "applied" || r.status === "created").length;
7270
7985
  const total = results.length;
7271
- const header = `\u25B8 edit blocks: ${ok}/${total} applied \u2014 /undo to roll back, or \`git diff\` to review`;
7272
- return [header, ...lines].join("\n");
7986
+ const header2 = `\u25B8 edit blocks: ${ok}/${total} applied \u2014 /undo to roll back, or \`git diff\` to review`;
7987
+ return [header2, ...lines].join("\n");
7273
7988
  }
7274
7989
  function formatPendingPreview(blocks) {
7275
7990
  const lines = blocks.map((b) => {
@@ -7278,8 +7993,8 @@ function formatPendingPreview(blocks) {
7278
7993
  const tag = b.search === "" ? "NEW " : " ";
7279
7994
  return ` ${tag}${b.path} (-${removed} +${added} lines)`;
7280
7995
  });
7281
- const header = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply (or y) to commit \xB7 /discard (or n) to drop`;
7282
- return [header, ...lines].join("\n");
7996
+ const header2 = `\u25B8 ${blocks.length} pending edit block(s) \u2014 /apply (or y) to commit \xB7 /discard (or n) to drop`;
7997
+ return [header2, ...lines].join("\n");
7283
7998
  }
7284
7999
  function countLines2(s) {
7285
8000
  if (s.length === 0) return 0;
@@ -7517,14 +8232,13 @@ async function chatCommand(opts) {
7517
8232
  if (!opts.seedTools) {
7518
8233
  if (!tools) tools = new ToolRegistry();
7519
8234
  registerMemoryTools(tools, {});
7520
- registerSkillTools(tools);
7521
8235
  }
7522
8236
  let sessionPreview;
7523
8237
  if (opts.session && !opts.forceResume && !opts.forceNew) {
7524
8238
  const prior = loadSessionMessages(opts.session);
7525
8239
  if (prior.length > 0) {
7526
8240
  const p = sessionPath(opts.session);
7527
- const mtime = existsSync6(p) ? statSync3(p).mtime : /* @__PURE__ */ new Date();
8241
+ const mtime = existsSync8(p) ? statSync4(p).mtime : /* @__PURE__ */ new Date();
7528
8242
  sessionPreview = { messageCount: prior.length, lastActive: mtime };
7529
8243
  }
7530
8244
  } else if (opts.session && opts.forceNew) {
@@ -7557,7 +8271,7 @@ async function chatCommand(opts) {
7557
8271
  // src/cli/commands/code.tsx
7558
8272
  import { basename, resolve as resolve5 } from "path";
7559
8273
  async function codeCommand(opts = {}) {
7560
- const { codeSystemPrompt: codeSystemPrompt2 } = await import("./prompt-VDN5U3YE.js");
8274
+ const { codeSystemPrompt: codeSystemPrompt2 } = await import("./prompt-6DMLWG2H.js");
7561
8275
  const rootDir = resolve5(opts.dir ?? process.cwd());
7562
8276
  const session = opts.noSession ? void 0 : `code-${sanitizeName(basename(rootDir))}`;
7563
8277
  const tools = new ToolRegistry();
@@ -7573,7 +8287,6 @@ async function codeCommand(opts = {}) {
7573
8287
  });
7574
8288
  registerPlanTool(tools);
7575
8289
  registerMemoryTools(tools, { projectRoot: rootDir });
7576
- registerSkillTools(tools, { projectRoot: rootDir });
7577
8290
  process.stderr.write(
7578
8291
  `\u25B8 reasonix code: rooted at ${rootDir}, session "${session ?? "(ephemeral)"}" \xB7 ${tools.size} native tool(s)
7579
8292
  `
@@ -7867,7 +8580,7 @@ function mcpListCommand(opts) {
7867
8580
  console.log("Popular MCP servers you can bridge into Reasonix:");
7868
8581
  console.log("");
7869
8582
  for (const entry of MCP_CATALOG) {
7870
- console.log(` ${pad(entry.name, 12)} ${entry.summary}`);
8583
+ console.log(` ${pad2(entry.name, 12)} ${entry.summary}`);
7871
8584
  console.log(` ${mcpCommandFor(entry)}`);
7872
8585
  if (entry.note) console.log(` \xB7 ${entry.note}`);
7873
8586
  console.log("");
@@ -7880,7 +8593,7 @@ function mcpListCommand(opts) {
7880
8593
  " https://mcp.so \u2014 community-maintained catalog"
7881
8594
  );
7882
8595
  }
7883
- function pad(s, width) {
8596
+ function pad2(s, width) {
7884
8597
  return s.length >= width ? s : s + " ".repeat(width - s.length);
7885
8598
  }
7886
8599
 
@@ -8149,6 +8862,9 @@ async function runCommand2(opts) {
8149
8862
  [error] ${ev.error}
8150
8863
  `);
8151
8864
  if (ev.role === "done") process.stdout.write("\n");
8865
+ if (ev.role === "assistant_final" && ev.stats?.usage) {
8866
+ appendUsage({ session: null, model: ev.stats.model, usage: ev.stats.usage });
8867
+ }
8152
8868
  if (transcriptStream && ev.role !== "assistant_delta") {
8153
8869
  writeRecord(transcriptStream, recordFromLoopEvent(ev, { model: opts.model, prefixHash }));
8154
8870
  }
@@ -8545,32 +9261,6 @@ async function setupCommand(_opts = {}) {
8545
9261
  await waitUntilExit();
8546
9262
  }
8547
9263
 
8548
- // src/cli/commands/stats.ts
8549
- import { existsSync as existsSync7, readFileSync as readFileSync8 } from "fs";
8550
- function statsCommand(opts) {
8551
- if (!existsSync7(opts.transcript)) {
8552
- console.error(`no such transcript: ${opts.transcript}`);
8553
- process.exit(1);
8554
- }
8555
- const lines = readFileSync8(opts.transcript, "utf8").split(/\r?\n/).filter(Boolean);
8556
- let assistantTurns = 0;
8557
- let toolCalls = 0;
8558
- let lastTurn = 0;
8559
- for (const line of lines) {
8560
- try {
8561
- const rec = JSON.parse(line);
8562
- if (rec.role === "assistant_final") assistantTurns++;
8563
- if (rec.role === "tool") toolCalls++;
8564
- if (typeof rec.turn === "number") lastTurn = Math.max(lastTurn, rec.turn);
8565
- } catch {
8566
- }
8567
- }
8568
- console.log(`transcript: ${opts.transcript}`);
8569
- console.log(`assistant turns: ${assistantTurns}`);
8570
- console.log(`tool invocations: ${toolCalls}`);
8571
- console.log(`last turn index: ${lastTurn}`);
8572
- }
8573
-
8574
9264
  // src/cli/commands/update.ts
8575
9265
  import { spawn as spawn4 } from "child_process";
8576
9266
  function planUpdate(input) {
@@ -8796,7 +9486,9 @@ program.command("run <task>").description("Run a single task non-interactively,
8796
9486
  mcpPrefix: opts.mcpPrefix
8797
9487
  });
8798
9488
  });
8799
- program.command("stats <transcript>").description("Summarize a JSONL transcript produced by `reasonix chat --transcript`.").action((transcript) => {
9489
+ program.command("stats [transcript]").description(
9490
+ "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)."
9491
+ ).action((transcript) => {
8800
9492
  statsCommand({ transcript });
8801
9493
  });
8802
9494
  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) => {