@vextlabs/theron-cli 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (169) hide show
  1. package/dist/api.d.ts +8 -0
  2. package/dist/api.js +3 -0
  3. package/dist/api.js.map +1 -1
  4. package/dist/auth.js +51 -1
  5. package/dist/auth.js.map +1 -1
  6. package/dist/banner.js +3 -2
  7. package/dist/banner.js.map +1 -1
  8. package/dist/checkpoints.d.ts +32 -0
  9. package/dist/checkpoints.js +61 -0
  10. package/dist/checkpoints.js.map +1 -0
  11. package/dist/index.js +59 -4
  12. package/dist/index.js.map +1 -1
  13. package/dist/input.d.ts +61 -0
  14. package/dist/input.js +574 -0
  15. package/dist/input.js.map +1 -0
  16. package/dist/profiles/index.js +5 -0
  17. package/dist/profiles/index.js.map +1 -1
  18. package/dist/profiles/methodologies/operate_domains.d.ts +8 -0
  19. package/dist/profiles/methodologies/operate_domains.js +1239 -0
  20. package/dist/profiles/methodologies/operate_domains.js.map +1 -0
  21. package/dist/profiles/seeds.js +57 -36
  22. package/dist/profiles/seeds.js.map +1 -1
  23. package/dist/receipt.d.ts +17 -0
  24. package/dist/receipt.js +46 -0
  25. package/dist/receipt.js.map +1 -0
  26. package/dist/render.d.ts +4 -1
  27. package/dist/render.js +95 -28
  28. package/dist/render.js.map +1 -1
  29. package/dist/repl.d.ts +8 -1
  30. package/dist/repl.js +420 -62
  31. package/dist/repl.js.map +1 -1
  32. package/dist/sessions.d.ts +14 -0
  33. package/dist/sessions.js +100 -0
  34. package/dist/sessions.js.map +1 -1
  35. package/dist/ship.d.ts +2 -0
  36. package/dist/ship.js +62 -0
  37. package/dist/ship.js.map +1 -0
  38. package/dist/skills/catalog.d.ts +13 -0
  39. package/dist/skills/catalog.js +86 -0
  40. package/dist/skills/catalog.js.map +1 -0
  41. package/dist/tools/bash.js +81 -14
  42. package/dist/tools/bash.js.map +1 -1
  43. package/dist/tools/edit.js +21 -1
  44. package/dist/tools/edit.js.map +1 -1
  45. package/dist/tools/glob.js +4 -1
  46. package/dist/tools/glob.js.map +1 -1
  47. package/dist/tools/grep.d.ts +5 -0
  48. package/dist/tools/grep.js +101 -2
  49. package/dist/tools/grep.js.map +1 -1
  50. package/dist/tools/index.d.ts +22 -0
  51. package/dist/tools/index.js +177 -41
  52. package/dist/tools/index.js.map +1 -1
  53. package/dist/tools/ls.d.ts +3 -0
  54. package/dist/tools/ls.js +23 -12
  55. package/dist/tools/ls.js.map +1 -1
  56. package/dist/tools/multiedit.d.ts +12 -0
  57. package/dist/tools/multiedit.js +79 -0
  58. package/dist/tools/multiedit.js.map +1 -0
  59. package/dist/tools/stoa.d.ts +1 -1
  60. package/dist/tools/stoa.js +7 -3
  61. package/dist/tools/stoa.js.map +1 -1
  62. package/dist/tools/task.d.ts +9 -0
  63. package/dist/tools/task.js +166 -0
  64. package/dist/tools/task.js.map +1 -0
  65. package/dist/tools/todowrite.d.ts +12 -0
  66. package/dist/tools/todowrite.js +38 -0
  67. package/dist/tools/todowrite.js.map +1 -0
  68. package/dist/tools/webfetch.d.ts +6 -0
  69. package/dist/tools/webfetch.js +98 -0
  70. package/dist/tools/webfetch.js.map +1 -0
  71. package/dist/tools/websearch.d.ts +7 -0
  72. package/dist/tools/websearch.js +83 -0
  73. package/dist/tools/websearch.js.map +1 -0
  74. package/dist/tools/write.js +17 -1
  75. package/dist/tools/write.js.map +1 -1
  76. package/dist/verifiers/confidence_marked.d.ts +2 -0
  77. package/dist/verifiers/confidence_marked.js +49 -0
  78. package/dist/verifiers/confidence_marked.js.map +1 -0
  79. package/dist/verifiers/disclaimer_gate.d.ts +2 -0
  80. package/dist/verifiers/disclaimer_gate.js +57 -0
  81. package/dist/verifiers/disclaimer_gate.js.map +1 -0
  82. package/dist/verifiers/index.d.ts +5 -0
  83. package/dist/verifiers/index.js +20 -7
  84. package/dist/verifiers/index.js.map +1 -1
  85. package/dist/verifiers/lint.js +4 -3
  86. package/dist/verifiers/lint.js.map +1 -1
  87. package/dist/verifiers/promoted_kernels.d.ts +8 -0
  88. package/dist/verifiers/promoted_kernels.js +190 -0
  89. package/dist/verifiers/promoted_kernels.js.map +1 -0
  90. package/dist/verifiers/source_gate.js +2 -3
  91. package/dist/verifiers/source_gate.js.map +1 -1
  92. package/dist/verifiers/test_smoke.js +30 -0
  93. package/dist/verifiers/test_smoke.js.map +1 -1
  94. package/dist/verifiers/types.d.ts +3 -0
  95. package/package.json +4 -2
  96. package/skills/README.md +123 -0
  97. package/skills/ab-test.md +89 -0
  98. package/skills/api-design.md +175 -0
  99. package/skills/architecture-design.md +185 -0
  100. package/skills/business-case.md +77 -0
  101. package/skills/causal-inference.md +77 -0
  102. package/skills/clinical-guideline.md +98 -0
  103. package/skills/code-review.md +98 -0
  104. package/skills/cold-outreach.md +268 -0
  105. package/skills/competitive-teardown.md +223 -0
  106. package/skills/component-spec.md +121 -0
  107. package/skills/content-calendar.md +280 -0
  108. package/skills/contract-review.md +155 -0
  109. package/skills/data-analysis.md +187 -0
  110. package/skills/debug.md +91 -0
  111. package/skills/design-audit.md +121 -0
  112. package/skills/differential-diagnosis.md +79 -0
  113. package/skills/discovery-call.md +206 -0
  114. package/skills/edit-pass.md +80 -0
  115. package/skills/engineering-calc.md +101 -0
  116. package/skills/estimate.md +70 -0
  117. package/skills/experiment-design.md +105 -0
  118. package/skills/fact-check.md +82 -0
  119. package/skills/financial-model.md +104 -0
  120. package/skills/grant-proposal.md +93 -0
  121. package/skills/harmony-analysis.md +93 -0
  122. package/skills/hypothesis-generation.md +99 -0
  123. package/skills/incident-response.md +134 -0
  124. package/skills/interview-loop.md +62 -0
  125. package/skills/job-scorecard.md +92 -0
  126. package/skills/kb-article.md +174 -0
  127. package/skills/launch-plan.md +85 -0
  128. package/skills/lease-review.md +93 -0
  129. package/skills/lesson-plan.md +198 -0
  130. package/skills/literature-review.md +69 -0
  131. package/skills/market-entry.md +137 -0
  132. package/skills/market-sizing.md +159 -0
  133. package/skills/meta-analysis.md +140 -0
  134. package/skills/migrate.md +117 -0
  135. package/skills/optimize.md +88 -0
  136. package/skills/options-strategy.md +166 -0
  137. package/skills/peer-review.md +96 -0
  138. package/skills/pentest-plan.md +193 -0
  139. package/skills/pitch-review.md +132 -0
  140. package/skills/plan.md +88 -0
  141. package/skills/policy-brief.md +124 -0
  142. package/skills/positioning.md +192 -0
  143. package/skills/postmortem.md +168 -0
  144. package/skills/prd.md +105 -0
  145. package/skills/prioritize.md +162 -0
  146. package/skills/proof.md +91 -0
  147. package/skills/property-underwrite.md +159 -0
  148. package/skills/recipe-develop.md +109 -0
  149. package/skills/red-team.md +142 -0
  150. package/skills/refactor.md +58 -0
  151. package/skills/reflection-session.md +115 -0
  152. package/skills/regulatory-compliance.md +136 -0
  153. package/skills/reproduce.md +87 -0
  154. package/skills/runbook.md +344 -0
  155. package/skills/security-audit.md +154 -0
  156. package/skills/seo-brief.md +201 -0
  157. package/skills/sql-query.md +161 -0
  158. package/skills/story-craft.md +163 -0
  159. package/skills/tdd.md +59 -0
  160. package/skills/term-sheet.md +298 -0
  161. package/skills/theory-of-change.md +88 -0
  162. package/skills/threat-model.md +104 -0
  163. package/skills/ticket-triage.md +200 -0
  164. package/skills/tolerance-analysis.md +149 -0
  165. package/skills/training-program.md +151 -0
  166. package/skills/translate.md +64 -0
  167. package/skills/unit-economics.md +238 -0
  168. package/skills/valuation.md +112 -0
  169. package/skills/write-tests.md +77 -0
package/dist/repl.js CHANGED
@@ -5,17 +5,23 @@
5
5
  import readline from "node:readline";
6
6
  import path from "node:path";
7
7
  import process from "node:process";
8
+ import os from "node:os";
8
9
  import chalk from "chalk";
9
10
  import { spawnSync } from "node:child_process";
11
+ import { promptMultiline, promptLine } from "./input.js";
10
12
  import { streamChat, fetchInteractionPlan } from "./api.js";
11
13
  import { loadCapConfig, resolveCapPolicy } from "./cap_config.js";
12
14
  import { loadProjectMemory, formatProjectMemoryForRequest } from "./project_memory.js";
13
15
  import { rankProfilesForPrompt } from "./profile_match.js";
14
- import { TOOL_REGISTRY, TOOL_SCHEMAS, READONLY_TOOL_SCHEMAS, MUTATING_TOOLS } from "./tools/index.js";
16
+ import { TOOL_REGISTRY, TOOL_SCHEMAS, READONLY_TOOL_SCHEMAS, MUTATING_TOOLS, setLoadedSkills } from "./tools/index.js";
17
+ import { loadAllMarkdownSkills, loadMarkdownSkills } from "@vextlabs/theron-agent-sdk";
18
+ import { fileURLToPath } from "node:url";
19
+ import { groupByEntrance } from "./skills/catalog.js";
15
20
  import { renderMarkdown, ui } from "./render.js";
16
21
  import { loadCustomCommands, substituteArgs } from "./slash_commands.js";
17
22
  import { resolveFileRefs } from "./file_refs.js";
18
- import { sessionIdForCwd, loadSession, saveSession, deleteSession, listSessions, } from "./sessions.js";
23
+ import { sessionIdForCwd, loadSession, saveSession, deleteSession, listSessions, pushSession, pullSession, } from "./sessions.js";
24
+ import { rewindLast, checkpointCount } from "./checkpoints.js";
19
25
  import { getProfileOrDefault, listProfiles, DEFAULT_PROFILE_SLUG } from "./profiles/index.js";
20
26
  import { runVerifiers, summarizeIssues, formatForNextTurn } from "./verifiers/index.js";
21
27
  import { connectionsCommand } from "./connections.js";
@@ -26,18 +32,70 @@ export async function runRepl(opts) {
26
32
  cwd: opts.cwd,
27
33
  maxBytes: 64 * 1024,
28
34
  yolo: opts.yolo,
35
+ apiUrl: opts.apiUrl,
36
+ apiKey: opts.apiKey,
29
37
  };
30
38
  const messages = [];
31
39
  let pendingActions = [];
32
- const rl = opts.oneShot
33
- ? null
34
- : readline.createInterface({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY === true });
40
+ // Input routing:
41
+ // - Interactive TTY → the raw-mode multiline editor (src/input.ts).
42
+ // It owns raw mode itself, so we do NOT also stand up a readline
43
+ // Interface (two stdin consumers would double-handle keystrokes).
44
+ // - Piped / non-TTY → a line-buffered readline Interface, used to
45
+ // pull one prompt per line off the pipe.
46
+ // - One-shot → neither; the prompt is handed straight in.
47
+ const isInteractiveTTY = !opts.oneShot && process.stdin.isTTY === true;
48
+ const rl = !opts.oneShot && !isInteractiveTTY
49
+ ? readline.createInterface({ input: process.stdin, output: process.stdout, terminal: false })
50
+ : null;
35
51
  // Track whether stdin has ended (happens when piping input — stdin
36
52
  // EOFs after the last line is consumed). Without this guard we hit
37
53
  // ERR_USE_AFTER_CLOSE on the next question() call. Listening for
38
54
  // 'close' lets the loop bail gracefully instead of throwing.
39
55
  let rlClosed = false;
40
56
  rl?.on("close", () => { rlClosed = true; });
57
+ // Shared input history for the multiline editor (Up/Down recall).
58
+ // Load from ~/.theron/history on start; append on submit.
59
+ const HISTORY_FILE = path.join(process.env.HOME ?? os.homedir(), ".theron", "history");
60
+ const MAX_HISTORY = 500;
61
+ const inputHistory = [];
62
+ // Load history from disk (ignore errors — first run, missing file, etc.)
63
+ try {
64
+ const { promises: fsp } = await import("node:fs");
65
+ const raw = await fsp.readFile(HISTORY_FILE, "utf-8").catch(() => "");
66
+ const lines = raw.split("\n").filter((l) => l.trim());
67
+ inputHistory.push(...lines.slice(-MAX_HISTORY));
68
+ }
69
+ catch { /* ignore */ }
70
+ /** Append one entry to the on-disk history file, keeping last MAX_HISTORY. */
71
+ const appendHistory = async (entry) => {
72
+ if (!entry.trim() || opts.headless)
73
+ return;
74
+ try {
75
+ const { promises: fsp } = await import("node:fs");
76
+ await fsp.mkdir(path.dirname(HISTORY_FILE), { recursive: true });
77
+ await fsp.appendFile(HISTORY_FILE, entry.replace(/\n/g, " ") + "\n", "utf-8");
78
+ }
79
+ catch { /* best effort */ }
80
+ };
81
+ // Read one line for pickers / confirmations. TTY uses the raw
82
+ // single-line reader (consistent with the multiline editor); piped
83
+ // input reuses the readline Interface; otherwise there's no input.
84
+ const askLine = async (question) => {
85
+ if (isInteractiveTTY)
86
+ return await promptLine(question);
87
+ if (rl && !rlClosed) {
88
+ return await new Promise((resolve) => {
89
+ try {
90
+ rl.question(question, (a) => resolve(a));
91
+ }
92
+ catch {
93
+ resolve(null);
94
+ }
95
+ });
96
+ }
97
+ return null;
98
+ };
41
99
  // Mutable session state — slash commands rewrite these, REPL reads.
42
100
  const session = {
43
101
  cwd: opts.cwd,
@@ -73,6 +131,11 @@ export async function runRepl(opts) {
73
131
  // every turn (and after /clear truncation). Never throws — saveSession
74
132
  // fails open. Skipped in headless mode where we don't want to mutate
75
133
  // the user's saved history from a one-shot pipe.
134
+ //
135
+ // When --cloud is set, also fire-and-forget a push to /api/sessions/sync
136
+ // so the session is portable across devices (CLI to IDE and back). The
137
+ // cloud push is non-blocking: it resolves in the background and a failure
138
+ // is silently ignored (the local save is the source of truth).
76
139
  const persistSession = () => {
77
140
  if (opts.headless)
78
141
  return;
@@ -85,6 +148,13 @@ export async function runRepl(opts) {
85
148
  messages,
86
149
  };
87
150
  saveSession(state);
151
+ if (opts.cloud && opts.apiKey) {
152
+ // Fire-and-forget: never blocks the REPL, never throws.
153
+ void pushSession(state, {
154
+ apiUrl: opts.apiUrl,
155
+ apiKey: opts.apiKey,
156
+ }).catch(() => { });
157
+ }
88
158
  };
89
159
  // The memory text we inject as a leading system note + send as the
90
160
  // `project_context` body field. Recomputed when memory reloads.
@@ -123,16 +193,7 @@ export async function runRepl(opts) {
123
193
  const shortCwd = home && s.cwd.startsWith(home) ? "~" + s.cwd.slice(home.length) : s.cwd;
124
194
  process.stdout.write(` ${ui.actionChip(i + 1, `${s.id} · ${s.messageCount} msgs · ${s.profile} · ${shortCwd}`)}\n`);
125
195
  });
126
- if (!rl || rlClosed)
127
- return null;
128
- const answer = await new Promise((resolve) => {
129
- try {
130
- rl.question(ui.prompt(), (a) => resolve(a));
131
- }
132
- catch {
133
- resolve("");
134
- }
135
- });
196
+ const answer = (await askLine(ui.prompt())) ?? "";
136
197
  const n = Number(answer.trim());
137
198
  if (Number.isInteger(n) && n >= 1 && n <= Math.min(sessions.length, 20)) {
138
199
  return sessions[n - 1].id;
@@ -156,7 +217,21 @@ export async function runRepl(opts) {
156
217
  }
157
218
  }
158
219
  if (toLoad) {
159
- const loaded = loadSession(toLoad);
220
+ let loaded = loadSession(toLoad);
221
+ // Cloud fallback: when --cloud is set and the local session is
222
+ // missing or empty, try pulling from /api/sessions/:id. This is the
223
+ // cross-device resume path (start in IDE, continue in CLI).
224
+ if ((!loaded || loaded.messages.length === 0) && opts.cloud && opts.apiKey) {
225
+ const cloudState = await pullSession(toLoad, {
226
+ apiUrl: opts.apiUrl,
227
+ apiKey: opts.apiKey,
228
+ });
229
+ if (cloudState && cloudState.messages.length > 0) {
230
+ loaded = cloudState;
231
+ // Persist locally so future resumes don't need the network.
232
+ saveSession(cloudState);
233
+ }
234
+ }
160
235
  if (loaded && loaded.messages.length > 0) {
161
236
  messages.push(...loaded.messages);
162
237
  session.sessionId = loaded.id;
@@ -170,7 +245,7 @@ export async function runRepl(opts) {
170
245
  }
171
246
  }
172
247
  }
173
- if (!opts.oneShot) {
248
+ if (!opts.oneShot && !opts.noBanner) {
174
249
  // Branded welcome — block-letter THERON banner + pill + numbered
175
250
  // security notes + quickstart status line. Same flow Claude Code
176
251
  // uses on first launch, in our amber-on-paper palette.
@@ -236,30 +311,82 @@ export async function runRepl(opts) {
236
311
  process.stdout.write(ui.warn("plan mode — read-only. Write / Edit / Bash / Stoa are blocked until you /plan or type 'approve'.\n"));
237
312
  }
238
313
  process.stdout.write("\n");
239
- process.stdout.write(ui.info("type a message · /help for commands · /mode list to see all 33 · Ctrl-C to quit\n\n"));
314
+ process.stdout.write(ui.info("type a message · Enter sends · Shift+Tab or \\+Enter for a new line · /help for commands · Ctrl-C to clear or quit\n\n"));
315
+ }
316
+ // ── Skills ────────────────────────────────────────────────────────────────
317
+ // Three sources, lowest→highest precedence (later overrides earlier by name):
318
+ // 1. BUNDLED — the curated skill library shipped with the CLI (../skills,
319
+ // sibling of dist/). Elite playbooks: literature-review, fact-check,
320
+ // experiment-design, data-analysis, tdd, refactor, optimize, api-design,
321
+ // plan, red-team, write-tests, reproduce.
322
+ // 2. USER — ~/.theron/skills/*.md
323
+ // 3. PROJECT — <cwd>/.theron/skills/*.md
324
+ // So users can extend or override any built-in by dropping a same-named file.
325
+ const bundledSkillsDir = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "skills");
326
+ const [bundledSkills, userProjectSkills] = await Promise.all([
327
+ loadMarkdownSkills(bundledSkillsDir).catch(() => []),
328
+ loadAllMarkdownSkills(opts.cwd).catch(() => []),
329
+ ]);
330
+ const mergedSkills = new Map();
331
+ for (const s of bundledSkills)
332
+ mergedSkills.set(s.name, s);
333
+ for (const s of userProjectSkills)
334
+ mergedSkills.set(s.name, s);
335
+ const loadedSkills = [...mergedSkills.values()];
336
+ setLoadedSkills(loadedSkills);
337
+ // Fast O(1) map for slash-command lookup.
338
+ const skillsMap = new Map(loadedSkills.map((s) => [s.name, s]));
339
+ if (!opts.oneShot && loadedSkills.length > 0) {
340
+ process.stdout.write(ui.info(`◉ skills loaded: ${loadedSkills.map((s) => "/" + s.name).join(", ")}\n`));
240
341
  }
241
- const promptOnce = () => new Promise((resolve) => {
342
+ // ── Session token totals (for /status + per-turn cost display) ────────────
343
+ let sessionInputTokens = 0;
344
+ let sessionOutputTokens = 0;
345
+ // ── SIGINT handling ────────────────────────────────────────────────────────
346
+ // Idle prompt → exit 130 (POSIX Ctrl-C convention).
347
+ // During a streaming turn → cancel that turn, return to prompt.
348
+ // The per-turn handler is installed in the turn loop below; this is the
349
+ // idle fallback re-installed after each turn ends.
350
+ // We removeAllListeners first so the global handler in bin/theron.js
351
+ // doesn't also fire, causing a double-exit or premature exit during a turn.
352
+ const idleSigintHandler = () => process.exit(130);
353
+ process.removeAllListeners("SIGINT");
354
+ process.on("SIGINT", idleSigintHandler);
355
+ const promptOnce = () => {
242
356
  if (opts.oneShot && messages.length === 0) {
243
- resolve(opts.oneShot);
244
- return;
245
- }
246
- if (!rl || rlClosed) {
247
- resolve(null);
248
- return;
357
+ return Promise.resolve(opts.oneShot);
249
358
  }
250
- try {
251
- rl.question(ui.prompt(), (answer) => resolve(answer));
359
+ // Interactive TTY → the multiline editor with a visible input box.
360
+ if (isInteractiveTTY) {
361
+ return promptMultiline({
362
+ label: "theron",
363
+ placeholder: session.profile.promptStarters?.[0]
364
+ ? `try: ${session.profile.promptStarters[0]}`
365
+ : "type a message — /help for commands",
366
+ hint: "Enter to send · Shift+Tab or \\+Enter for newline · /help · Ctrl+C to clear",
367
+ history: inputHistory,
368
+ });
252
369
  }
253
- catch (err) {
254
- // ERR_USE_AFTER_CLOSE — stdin EOFed (typical when piping
255
- // input). Bail the loop cleanly instead of throwing.
256
- if (err?.code === "ERR_USE_AFTER_CLOSE") {
370
+ // Piped / non-TTY → one line per prompt off the readline Interface.
371
+ return new Promise((resolve) => {
372
+ if (!rl || rlClosed) {
257
373
  resolve(null);
258
374
  return;
259
375
  }
260
- throw err;
261
- }
262
- });
376
+ try {
377
+ rl.question(ui.prompt(), (answer) => resolve(answer));
378
+ }
379
+ catch (err) {
380
+ // ERR_USE_AFTER_CLOSE — stdin EOFed (typical when piping
381
+ // input). Bail the loop cleanly instead of throwing.
382
+ if (err?.code === "ERR_USE_AFTER_CLOSE") {
383
+ resolve(null);
384
+ return;
385
+ }
386
+ throw err;
387
+ }
388
+ });
389
+ };
263
390
  while (true) {
264
391
  const input = await promptOnce();
265
392
  if (input == null)
@@ -342,6 +469,36 @@ export async function runRepl(opts) {
342
469
  }
343
470
  continue;
344
471
  }
472
+ // /rewind — restore the most recently snapshotted file to the content it
473
+ // had BEFORE Theron's last Write or Edit. If the file was newly created
474
+ // (before === null), it is deleted. Fail-safe: if the restore write
475
+ // fails the error is shown but the session continues.
476
+ if (trimmed === "/rewind") {
477
+ const cp = rewindLast();
478
+ if (!cp) {
479
+ process.stdout.write(ui.info("nothing to rewind — no checkpoints in this session.\n\n"));
480
+ }
481
+ else {
482
+ const remaining = checkpointCount();
483
+ try {
484
+ const fsModule = await import("node:fs");
485
+ const { promises: fsp } = fsModule;
486
+ if (cp.before === null) {
487
+ // File was created from scratch by Theron — delete it.
488
+ await fsp.unlink(cp.path);
489
+ process.stdout.write(ui.info(`deleted ${cp.path} (${remaining} checkpoint${remaining === 1 ? "" : "s"} left)\n\n`));
490
+ }
491
+ else {
492
+ await fsp.writeFile(cp.path, cp.before, "utf-8");
493
+ process.stdout.write(ui.info(`reverted ${cp.path} (${remaining} checkpoint${remaining === 1 ? "" : "s"} left)\n\n`));
494
+ }
495
+ }
496
+ catch (err) {
497
+ process.stdout.write(ui.error(`rewind failed: ${err instanceof Error ? err.message : String(err)}\n\n`));
498
+ }
499
+ }
500
+ continue;
501
+ }
345
502
  if (trimmed === "/status") {
346
503
  process.stdout.write("\n" + renderStatus({
347
504
  cwd: session.cwd,
@@ -362,6 +519,9 @@ export async function runRepl(opts) {
362
519
  }
363
520
  process.stdout.write(ui.info(`plan mode: ${session.planMode ? "ON (read-only)" : "off"} · render: ${session.renderMode ? "on" : "off"}\n`));
364
521
  process.stdout.write(ui.info(`session: ${session.sessionId} (${messages.length} messages)\n`));
522
+ if (sessionInputTokens > 0 || sessionOutputTokens > 0) {
523
+ process.stdout.write(ui.info(`tokens: ${sessionInputTokens.toLocaleString()} in → ${sessionOutputTokens.toLocaleString()} out (session total)\n`));
524
+ }
365
525
  if (session.planMode) {
366
526
  process.stdout.write(ui.warn("Write / Edit / Bash / Stoa are blocked. /plan or 'approve' to exit.\n"));
367
527
  }
@@ -425,6 +585,62 @@ export async function runRepl(opts) {
425
585
  process.stdout.write("\n");
426
586
  continue;
427
587
  }
588
+ // /compact — summarize the conversation to save context. POSTs to
589
+ // /api/cli/compact if present; else asks the model to summarize older
590
+ // messages in-loop. The result replaces the conversation with a
591
+ // summary message + the most recent N messages, then persists.
592
+ if (trimmed === "/compact") {
593
+ if (messages.length < 4) {
594
+ process.stdout.write(ui.info("conversation too short to compact.\n\n"));
595
+ continue;
596
+ }
597
+ // Keep the last 6 messages, summarize everything before that.
598
+ const KEEP = 6;
599
+ const toSummarize = messages.slice(0, Math.max(0, messages.length - KEEP));
600
+ const recent = messages.slice(Math.max(0, messages.length - KEEP));
601
+ if (toSummarize.length === 0) {
602
+ process.stdout.write(ui.info("nothing old enough to compact.\n\n"));
603
+ continue;
604
+ }
605
+ // Try the server's /api/cli/compact endpoint first.
606
+ let summary = null;
607
+ try {
608
+ const r = await fetch(`${opts.apiUrl.replace(/\/$/, "")}/api/cli/compact`, {
609
+ method: "POST",
610
+ headers: {
611
+ "content-type": "application/json",
612
+ ...(opts.apiKey ? { authorization: `Bearer ${opts.apiKey}` } : {}),
613
+ },
614
+ body: JSON.stringify({ messages: toSummarize }),
615
+ signal: AbortSignal.timeout(30_000),
616
+ });
617
+ if (r.ok) {
618
+ const data = (await r.json());
619
+ if (typeof data.summary === "string" && data.summary.trim()) {
620
+ summary = data.summary.trim();
621
+ }
622
+ }
623
+ }
624
+ catch { /* fall through to local summarize */ }
625
+ if (!summary) {
626
+ // Fallback: build a local summary from the messages text.
627
+ const textParts = [];
628
+ for (const m of toSummarize) {
629
+ if (m.role === "user" || m.role === "assistant") {
630
+ const prefix = m.role === "user" ? "User" : "Assistant";
631
+ textParts.push(`${prefix}: ${m.content.slice(0, 400)}`);
632
+ }
633
+ }
634
+ summary = `[Conversation summary — earlier ${toSummarize.length} messages compacted]\n${textParts.join("\n\n").slice(0, 2000)}`;
635
+ }
636
+ // Replace the conversation with summary + recent messages.
637
+ const summaryMsg = { role: "user", content: summary };
638
+ messages.length = 0;
639
+ messages.push(summaryMsg, ...recent);
640
+ persistSession();
641
+ process.stdout.write(ui.info(`compacted: ${toSummarize.length} messages → 1 summary (${messages.length} total now)\n\n`));
642
+ continue;
643
+ }
428
644
  if (trimmed === "/clear") {
429
645
  messages.length = 0;
430
646
  pendingActions = [];
@@ -529,6 +745,23 @@ export async function runRepl(opts) {
529
745
  process.stdout.write("\n");
530
746
  continue;
531
747
  }
748
+ if (trimmed === "/skills") {
749
+ if (loadedSkills.length === 0) {
750
+ process.stdout.write(ui.info("\nno skills loaded. Add SKILL.md files to ~/.theron/skills/ or .theron/skills/.\n" +
751
+ "Each file needs YAML frontmatter with 'name' and 'description', then the body.\n\n"));
752
+ }
753
+ else {
754
+ process.stdout.write(ui.info(`\nloaded skills (${loadedSkills.length}) — invoke via /<name> [args]:\n`));
755
+ for (const group of groupByEntrance(loadedSkills)) {
756
+ process.stdout.write(`\n ${ui.toolLabel(group.title, "")}\n`);
757
+ for (const s of group.items) {
758
+ process.stdout.write(` ${ui.toolLabel("/" + s.name, "")} ${ui.info(s.description || "(no description)")}\n`);
759
+ }
760
+ }
761
+ process.stdout.write(ui.info("\n add your own in ~/.theron/skills/ or .theron/skills/ (a same-named file overrides a built-in)\n\n"));
762
+ }
763
+ continue;
764
+ }
532
765
  // /suggest <prompt> — rank profiles by similarity to a prompt and
533
766
  // show the top 3 candidates so the user can switch with /mode <slug>
534
767
  // without scrolling /mode list. Embedding-keyed injector pattern
@@ -748,6 +981,27 @@ export async function runRepl(opts) {
748
981
  continue;
749
982
  }
750
983
  }
984
+ // Skill slash command — check after custom commands so custom commands
985
+ // can override skills of the same name, but skills extend the built-in set.
986
+ if (!isExpandedCommand) {
987
+ const skill = skillsMap.get(cmdName);
988
+ if (skill) {
989
+ const argString = argTokens.join(" ");
990
+ const toolNote = skill.allowedTools && skill.allowedTools.length > 0
991
+ ? `\n\n(Prefer these tools: ${skill.allowedTools.join(", ")})`
992
+ : "";
993
+ const injected = (skill.body + toolNote + (argString ? `\n\nArgs: ${argString}` : "")).trim();
994
+ if (injected) {
995
+ process.stdout.write(ui.info(`▸ /${cmdName} (skill)\n`));
996
+ trimmed = injected;
997
+ isExpandedCommand = true;
998
+ }
999
+ else {
1000
+ process.stdout.write(ui.error(`/${cmdName} skill has an empty body — nothing to send.\n\n`));
1001
+ continue;
1002
+ }
1003
+ }
1004
+ }
751
1005
  }
752
1006
  // Unknown slash → friendly nudge. (Custom commands already matched
753
1007
  // above and set isExpandedCommand; only a real unknown reaches here.)
@@ -850,6 +1104,8 @@ export async function runRepl(opts) {
850
1104
  // the executor-side hard deny (see runOneTurn) is the real safety net,
851
1105
  // so correctness never depends on the model honoring this text.
852
1106
  const planPrefixed = session.planMode ? PLAN_MODE_INSTRUCTION + "\n\n" + toSend : toSend;
1107
+ // Append to persistent history.
1108
+ void appendHistory(trimmed);
853
1109
  messages.push({ role: "user", content: planPrefixed });
854
1110
  // Fire the interaction-plan classifier in parallel with the first
855
1111
  // model turn. The plan is shared across web/CLI/IDE — if it wins
@@ -879,10 +1135,25 @@ export async function runRepl(opts) {
879
1135
  // and the names of every tool the model invoked (for headless JSON).
880
1136
  let turnGuard = 0;
881
1137
  const touchedFiles = new Set();
1138
+ const readFiles = new Set();
882
1139
  const toolsUsed = [];
883
1140
  let lastAssistantText = "";
884
1141
  let turnErrored = false;
885
- while (turnGuard < 20) {
1142
+ let turnCanceled = false;
1143
+ // ── Per-turn SIGINT (Ctrl-C cancels streaming turn, not the process) ──
1144
+ // Install a turn-scoped handler that aborts the turn; after the turn the
1145
+ // idle handler is restored so Ctrl-C at the prompt still exits 130.
1146
+ const turnAbort = new AbortController();
1147
+ process.removeAllListeners("SIGINT");
1148
+ process.once("SIGINT", () => {
1149
+ turnCanceled = true;
1150
+ turnAbort.abort();
1151
+ // Restore idle handler immediately so a second Ctrl-C exits cleanly.
1152
+ process.removeAllListeners("SIGINT");
1153
+ process.on("SIGINT", idleSigintHandler);
1154
+ });
1155
+ const maxTurnsPerPrompt = opts.maxTurns ?? 20;
1156
+ while (turnGuard < maxTurnsPerPrompt) {
886
1157
  turnGuard += 1;
887
1158
  const res = await runOneTurn({
888
1159
  apiUrl: opts.apiUrl,
@@ -897,6 +1168,7 @@ export async function runRepl(opts) {
897
1168
  profile: session.profile.slug,
898
1169
  projectContext: projectContext || undefined,
899
1170
  touchedFilesSink: touchedFiles,
1171
+ readFilesSink: readFiles,
900
1172
  toolsUsedSink: toolsUsed,
901
1173
  // Plan mode: hard-deny mutating tools at the executor (even with
902
1174
  // --yes) and send the model only the read-only tool subset.
@@ -907,7 +1179,26 @@ export async function runRepl(opts) {
907
1179
  // deltas — the answer is emitted once at end (rendered or JSON).
908
1180
  bufferText: session.renderMode || !!opts.headless,
909
1181
  headless: !!opts.headless,
1182
+ // Thread the abort signal for Ctrl-C mid-turn cancel.
1183
+ signal: turnAbort.signal,
1184
+ outputFormat: opts.outputFormat,
1185
+ // Token/cost accumulation — fires when server emits a usage frame.
1186
+ onUsageCb: (usage) => {
1187
+ sessionInputTokens += usage.input_tokens;
1188
+ sessionOutputTokens += usage.output_tokens;
1189
+ if (!opts.headless) {
1190
+ const costStr = usage.cost_usd != null ? ` · $${usage.cost_usd.toFixed(4)}` : "";
1191
+ process.stdout.write(chalk.dim(`↳ ${usage.input_tokens}→${usage.output_tokens} tokens${costStr}\n\n`));
1192
+ }
1193
+ },
910
1194
  });
1195
+ // Restore idle SIGINT after the turn completes (normal path).
1196
+ process.removeAllListeners("SIGINT");
1197
+ process.on("SIGINT", idleSigintHandler);
1198
+ if (res.kind === "canceled") {
1199
+ turnCanceled = true;
1200
+ break;
1201
+ }
911
1202
  if (res.kind === "error") {
912
1203
  if (!opts.headless)
913
1204
  process.stdout.write(ui.error(res.message) + "\n\n");
@@ -918,7 +1209,27 @@ export async function runRepl(opts) {
918
1209
  lastAssistantText = res.assistantText ?? "";
919
1210
  break;
920
1211
  }
921
- // tool_use — keep looping
1212
+ // tool_use — keep looping (SIGINT handler is already restored above)
1213
+ // Re-install turn handler for the next tool-loop iteration.
1214
+ process.removeAllListeners("SIGINT");
1215
+ process.once("SIGINT", () => {
1216
+ turnCanceled = true;
1217
+ turnAbort.abort();
1218
+ process.removeAllListeners("SIGINT");
1219
+ process.on("SIGINT", idleSigintHandler);
1220
+ });
1221
+ }
1222
+ // Ensure idle handler is restored even if we exited the loop another way.
1223
+ process.removeAllListeners("SIGINT");
1224
+ process.on("SIGINT", idleSigintHandler);
1225
+ // Warn if we hit the max-turn cap.
1226
+ if (turnGuard >= maxTurnsPerPrompt && !turnCanceled) {
1227
+ process.stdout.write(ui.warn(`[max-turns] reached the ${maxTurnsPerPrompt}-turn cap — Theron stopped. Use --max-turns N to increase.\n\n`));
1228
+ }
1229
+ // Ctrl-C mid-turn: show ⊘ canceled, skip verifiers, go back to prompt.
1230
+ if (turnCanceled) {
1231
+ process.stdout.write(chalk.dim("\n⊘ canceled\n\n"));
1232
+ continue;
922
1233
  }
923
1234
  // Render the assistant's markdown once the turn settles, when render
924
1235
  // mode is on and we're an interactive TTY. We buffered the raw deltas
@@ -941,6 +1252,7 @@ export async function runRepl(opts) {
941
1252
  cwd: session.cwd,
942
1253
  assistantText: lastAssistantText,
943
1254
  touchedFiles: Array.from(touchedFiles),
1255
+ readFiles: Array.from(readFiles),
944
1256
  profile: session.profile.slug,
945
1257
  });
946
1258
  const sum = summarizeIssues(issues);
@@ -992,6 +1304,9 @@ export async function runRepl(opts) {
992
1304
  break;
993
1305
  }
994
1306
  rl?.close();
1307
+ // Remove our SIGINT handler so main()'s process.exit(code) path
1308
+ // after runRepl returns doesn't leave a ghost listener.
1309
+ process.removeListener("SIGINT", idleSigintHandler);
995
1310
  // Final newline so the user's shell prompt lands on a clean line
996
1311
  // instead of the readline `> ` getting % -terminated by zsh.
997
1312
  process.stdout.write("\n");
@@ -1004,6 +1319,11 @@ async function runOneTurn(args) {
1004
1319
  let firstDelta = true;
1005
1320
  const headless = args.headless === true;
1006
1321
  const bufferText = args.bufferText === true;
1322
+ const streamJson = args.outputFormat === "stream-json";
1323
+ // Thread the per-turn abort signal into the ToolContext so long-running
1324
+ // tools (Bash) can be killed on Ctrl-C.
1325
+ if (args.signal)
1326
+ args.ctx.signal = args.signal;
1007
1327
  // Show the pin header BEFORE thinking spinner so the user knows
1008
1328
  // immediately that their /pin took effect. (Suppressed in headless.)
1009
1329
  if (!headless && args.pinnedSpecs && args.pinnedSpecs.length > 0) {
@@ -1022,6 +1342,7 @@ async function runOneTurn(args) {
1022
1342
  tools: args.tools ?? TOOL_SCHEMAS,
1023
1343
  profile: args.profile,
1024
1344
  projectContext: args.projectContext,
1345
+ signal: args.signal,
1025
1346
  }, {
1026
1347
  onTextDelta: (d) => {
1027
1348
  if (firstDelta) {
@@ -1038,20 +1359,39 @@ async function runOneTurn(args) {
1038
1359
  // raw deltas live.
1039
1360
  if (!bufferText)
1040
1361
  process.stdout.write(d);
1362
+ // stream-json: emit one NDJSON frame per text delta.
1363
+ if (streamJson)
1364
+ process.stdout.write(JSON.stringify({ type: "text_delta", delta: d }) + "\n");
1041
1365
  },
1042
1366
  onToolCall: (call) => {
1043
1367
  toolCalls.push(call);
1044
1368
  // Update the spinner label so the user sees what's queued.
1045
1369
  if (firstDelta && !headless)
1046
1370
  spinner.setLabel(`${call.name}…`);
1371
+ // stream-json: emit the tool_use frame.
1372
+ if (streamJson)
1373
+ process.stdout.write(JSON.stringify({ type: "tool_use", id: call.id, name: call.name, args: call.args }) + "\n");
1374
+ },
1375
+ onTurnEnd: (reason) => {
1376
+ stopReason = reason;
1377
+ if (streamJson)
1378
+ process.stdout.write(JSON.stringify({ type: "turn_end", stop_reason: reason }) + "\n");
1047
1379
  },
1048
- onTurnEnd: (reason) => { stopReason = reason; },
1049
1380
  onError: (msg) => {
1050
1381
  stopReason = "error";
1051
1382
  if (!headless) {
1052
1383
  spinner.stop();
1053
- process.stdout.write("\n" + announceError(msg) + "\n");
1384
+ // Suppress the "canceled" error message the outer loop prints ⊘ canceled.
1385
+ if (msg !== "canceled")
1386
+ process.stdout.write("\n" + announceError(msg) + "\n");
1054
1387
  }
1388
+ if (streamJson)
1389
+ process.stdout.write(JSON.stringify({ type: "error", message: msg }) + "\n");
1390
+ },
1391
+ onUsage: (usage) => {
1392
+ args.onUsageCb?.(usage);
1393
+ if (streamJson)
1394
+ process.stdout.write(JSON.stringify({ type: "usage", ...usage }) + "\n");
1055
1395
  },
1056
1396
  });
1057
1397
  // Always stop the spinner in case neither delta nor error fired
@@ -1063,6 +1403,10 @@ async function runOneTurn(args) {
1063
1403
  if (assistantText && !bufferText && !headless)
1064
1404
  process.stdout.write("\n\n");
1065
1405
  args.messages.push({ role: "assistant", content: assistantText, tool_calls: toolCalls });
1406
+ // Ctrl-C mid-turn: signal was aborted → return "canceled" so the outer
1407
+ // loop can print ⊘ canceled and return to the prompt cleanly.
1408
+ if (args.signal?.aborted)
1409
+ return { kind: "canceled" };
1066
1410
  if (stopReason === "error")
1067
1411
  return { kind: "error", message: "stream error" };
1068
1412
  if (toolCalls.length === 0)
@@ -1098,13 +1442,22 @@ async function runOneTurn(args) {
1098
1442
  }
1099
1443
  // Record Write/Edit paths so the post-turn verifier pass can
1100
1444
  // scope itself to just the files this turn touched.
1101
- if (args.touchedFilesSink && (call.name === "Write" || call.name === "Edit")) {
1445
+ if (args.touchedFilesSink && (call.name === "Write" || call.name === "Edit" || call.name === "MultiEdit")) {
1102
1446
  const path = call.args?.file_path
1103
1447
  ?? call.args?.path;
1104
1448
  if (typeof path === "string" && path.length > 0) {
1105
1449
  args.touchedFilesSink.add(path);
1106
1450
  }
1107
1451
  }
1452
+ // Record Read/Grep paths so source_gate credits the model for
1453
+ // consulting the source (reading is correct behavior, not a violation).
1454
+ if (args.readFilesSink && (call.name === "Read" || call.name === "Grep")) {
1455
+ const path = call.args?.file_path
1456
+ ?? call.args?.path;
1457
+ if (typeof path === "string" && path.length > 0) {
1458
+ args.readFilesSink.add(path);
1459
+ }
1460
+ }
1108
1461
  // Tool announcement — bullet style matches a list of actions
1109
1462
  // rather than CLI chrome. Single line, brand-amber name + dim
1110
1463
  // detail. (Suppressed in headless so stdout stays parseable.)
@@ -1154,6 +1507,9 @@ async function runOneTurn(args) {
1154
1507
  // only affects what the user sees in their terminal.
1155
1508
  if (!headless)
1156
1509
  process.stdout.write(ui.info(truncatePreview(result, 4000)) + "\n\n");
1510
+ // stream-json: emit tool_result frame.
1511
+ if (streamJson)
1512
+ process.stdout.write(JSON.stringify({ type: "tool_result", tool_call_id: call.id, content: result }) + "\n");
1157
1513
  args.messages.push({ role: "tool", tool_call_id: call.id, content: result });
1158
1514
  }
1159
1515
  return { kind: "tool_use" };
@@ -1169,8 +1525,10 @@ const PLAN_MODE_INSTRUCTION = "You are in PLAN MODE. Investigate the task using
1169
1525
  "and wait for the user to approve before executing.";
1170
1526
  /** Emit the single headless payload. For json this is ONE JSON object on
1171
1527
  * stdout (no other stdout writes happen in headless mode, so it parses
1172
- * cleanly). For text it's just the answer. `cost` is null because the
1173
- * NDJSON wire format carries no usage/cost frame we never fabricate it. */
1528
+ * cleanly). For text it's just the answer. For stream-json the events were
1529
+ * already emitted live; we only emit a final `result` frame here.
1530
+ * `cost` is null because the NDJSON wire format carries no usage/cost frame
1531
+ * — we never fabricate it. */
1174
1532
  function emitHeadlessPayload(p) {
1175
1533
  if (p.outputFormat === "json") {
1176
1534
  const obj = {
@@ -1182,20 +1540,29 @@ function emitHeadlessPayload(p) {
1182
1540
  };
1183
1541
  process.stdout.write(JSON.stringify(obj) + "\n");
1184
1542
  }
1543
+ else if (p.outputFormat === "stream-json") {
1544
+ // Events were streamed live during the turn. Emit a final `result` frame.
1545
+ process.stdout.write(JSON.stringify({
1546
+ type: "result",
1547
+ answer: p.answer,
1548
+ tools_used: p.toolsUsed,
1549
+ verifier: p.verifier,
1550
+ cost: null,
1551
+ session_id: p.sessionId,
1552
+ }) + "\n");
1553
+ }
1185
1554
  else {
1186
1555
  process.stdout.write(p.answer.replace(/\n+$/, "") + "\n");
1187
1556
  }
1188
1557
  }
1189
1558
  async function confirm(question, rl) {
1190
- // CRITICAL: reuse the OUTER REPL's readline.Interface. Creating a
1191
- // new Interface + closing it would close stdin under the outer rl
1192
- // on some terminals (zsh observed) the REPL would die after the
1193
- // first tool call. Pass through the existing rl, share the same
1194
- // input stream.
1559
+ const full = `${question} ${ui.info("(y/N) ")}`;
1560
+ // Piped input reuse the REPL's line-buffered readline Interface so
1561
+ // we read exactly one line off the pipe.
1195
1562
  if (rl) {
1196
1563
  return await new Promise((resolve) => {
1197
1564
  try {
1198
- rl.question(`${question} ${ui.info("(y/N) ")}`, (a) => {
1565
+ rl.question(full, (a) => {
1199
1566
  const yes = a.trim().toLowerCase();
1200
1567
  resolve(yes === "y" || yes === "yes");
1201
1568
  });
@@ -1211,21 +1578,12 @@ async function confirm(question, rl) {
1211
1578
  }
1212
1579
  });
1213
1580
  }
1214
- // Fallback for one-shot mode where rl is null — read stdin once.
1215
- return await new Promise((resolve) => {
1216
- process.stdout.write(`${question} ${ui.info("(y/N) ")}`);
1217
- let buf = "";
1218
- const onData = (chunk) => {
1219
- buf += chunk.toString("utf8");
1220
- const nl = buf.indexOf("\n");
1221
- if (nl >= 0) {
1222
- process.stdin.off("data", onData);
1223
- const yes = buf.slice(0, nl).trim().toLowerCase();
1224
- resolve(yes === "y" || yes === "yes");
1225
- }
1226
- };
1227
- process.stdin.on("data", onData);
1228
- });
1581
+ // Interactive TTY (or one-shot) the raw single-line reader, which
1582
+ // shares stdin handling with the multiline editor and falls back to a
1583
+ // plain data read when stdin isn't a TTY.
1584
+ const a = await promptLine(full);
1585
+ const yes = a.trim().toLowerCase();
1586
+ return yes === "y" || yes === "yes";
1229
1587
  }
1230
1588
  function truncatePreview(s, max) {
1231
1589
  if (s.length <= max)