@vextlabs/theron-cli 0.1.1 → 0.2.1

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/repl.js CHANGED
@@ -11,8 +11,11 @@ import { streamChat, fetchInteractionPlan } from "./api.js";
11
11
  import { loadCapConfig, resolveCapPolicy } from "./cap_config.js";
12
12
  import { loadProjectMemory, formatProjectMemoryForRequest } from "./project_memory.js";
13
13
  import { rankProfilesForPrompt } from "./profile_match.js";
14
- import { TOOL_REGISTRY, TOOL_SCHEMAS } from "./tools/index.js";
14
+ import { TOOL_REGISTRY, TOOL_SCHEMAS, READONLY_TOOL_SCHEMAS, MUTATING_TOOLS } from "./tools/index.js";
15
15
  import { renderMarkdown, ui } from "./render.js";
16
+ import { loadCustomCommands, substituteArgs } from "./slash_commands.js";
17
+ import { resolveFileRefs } from "./file_refs.js";
18
+ import { sessionIdForCwd, loadSession, saveSession, deleteSession, listSessions, } from "./sessions.js";
16
19
  import { getProfileOrDefault, listProfiles, DEFAULT_PROFILE_SLUG } from "./profiles/index.js";
17
20
  import { runVerifiers, summarizeIssues, formatForNextTurn } from "./verifiers/index.js";
18
21
  import { connectionsCommand } from "./connections.js";
@@ -49,11 +52,40 @@ export async function runRepl(opts) {
49
52
  * the model honors repo-local rules without the user re-typing them
50
53
  * — Theron's CLAUDE.md analogue. */
51
54
  projectMemory: loadProjectMemory(opts.cwd),
55
+ /** Plan mode — read-only tools + plan instruction + hard write deny.
56
+ * Toggled by --plan / /plan / "approve". */
57
+ planMode: opts.planMode === true,
58
+ /** Custom slash commands loaded from ~/.theron/commands + ./.theron/
59
+ * commands. Reloaded on /cd and /commands reload. */
60
+ customCommands: loadCustomCommands(opts.cwd),
61
+ /** Pretty-render the assistant's markdown at end of turn. */
62
+ renderMode: opts.renderMode === true,
63
+ /** Stable session id keyed to cwd, for save/resume. Updated on /cd. */
64
+ sessionId: sessionIdForCwd(opts.cwd),
65
+ /** Creation timestamp of the active on-disk session. */
66
+ sessionCreated: new Date().toISOString(),
52
67
  };
53
68
  const updateCtx = () => {
54
69
  ctx.cwd = session.cwd;
55
70
  ctx.yolo = session.yolo;
56
71
  };
72
+ // Persist the conversation under the cwd-keyed session id. Called after
73
+ // every turn (and after /clear truncation). Never throws — saveSession
74
+ // fails open. Skipped in headless mode where we don't want to mutate
75
+ // the user's saved history from a one-shot pipe.
76
+ const persistSession = () => {
77
+ if (opts.headless)
78
+ return;
79
+ const state = {
80
+ id: session.sessionId,
81
+ cwd: session.cwd,
82
+ created: session.sessionCreated,
83
+ updated: new Date().toISOString(),
84
+ profile: session.profile.slug,
85
+ messages,
86
+ };
87
+ saveSession(state);
88
+ };
57
89
  // The memory text we inject as a leading system note + send as the
58
90
  // `project_context` body field. Recomputed when memory reloads.
59
91
  let projectContext = formatProjectMemoryForRequest(session.projectMemory);
@@ -64,6 +96,80 @@ export async function runRepl(opts) {
64
96
  projectContext = formatProjectMemoryForRequest(session.projectMemory);
65
97
  return session.projectMemory.sources.length > 0;
66
98
  };
99
+ // After a /cd the new directory may carry its own .theron/commands and
100
+ // maps to a different session id. Refresh both so custom commands and
101
+ // save/resume track the directory the user is actually in.
102
+ const reloadDirScopedState = () => {
103
+ session.customCommands = loadCustomCommands(session.cwd);
104
+ session.sessionId = sessionIdForCwd(session.cwd);
105
+ };
106
+ // ── Session resume ────────────────────────────────────────────────
107
+ // --continue → the cwd-keyed session; --resume [id] → that session or a
108
+ // picker. Seed the loaded messages before the loop. Headless skips
109
+ // resume (a one-shot pipe shouldn't replay a stale conversation).
110
+ //
111
+ // Numbered session picker for `--resume` with no id. Reuses the REPL's
112
+ // own readline (never opens a competing Interface on the same stdin).
113
+ // Returns the chosen session id, or null on empty list / invalid pick.
114
+ const pickSession = async () => {
115
+ const sessions = listSessions();
116
+ if (sessions.length === 0) {
117
+ process.stdout.write(ui.info("no saved sessions to resume.\n"));
118
+ return null;
119
+ }
120
+ process.stdout.write(ui.info("\nsaved sessions — pick a number to resume:\n"));
121
+ const home = process.env.HOME || "";
122
+ sessions.slice(0, 20).forEach((s, i) => {
123
+ const shortCwd = home && s.cwd.startsWith(home) ? "~" + s.cwd.slice(home.length) : s.cwd;
124
+ process.stdout.write(` ${ui.actionChip(i + 1, `${s.id} · ${s.messageCount} msgs · ${s.profile} · ${shortCwd}`)}\n`);
125
+ });
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
+ });
136
+ const n = Number(answer.trim());
137
+ if (Number.isInteger(n) && n >= 1 && n <= Math.min(sessions.length, 20)) {
138
+ return sessions[n - 1].id;
139
+ }
140
+ process.stdout.write(ui.info("no valid selection — starting fresh.\n"));
141
+ return null;
142
+ };
143
+ let resumedNotice = "";
144
+ if (!opts.headless && (opts.continueSession || opts.resumeSession)) {
145
+ let toLoad = null;
146
+ if (opts.continueSession) {
147
+ toLoad = session.sessionId;
148
+ }
149
+ else if (opts.resumeSession) {
150
+ if (opts.resumeId) {
151
+ toLoad = opts.resumeId;
152
+ }
153
+ else {
154
+ // Numbered picker — reuse a transient readline over stdin.
155
+ toLoad = await pickSession();
156
+ }
157
+ }
158
+ if (toLoad) {
159
+ const loaded = loadSession(toLoad);
160
+ if (loaded && loaded.messages.length > 0) {
161
+ messages.push(...loaded.messages);
162
+ session.sessionId = loaded.id;
163
+ session.sessionCreated = loaded.created;
164
+ resumedNotice = `◉ resumed session ${loaded.id} (${loaded.messages.length} messages)`;
165
+ }
166
+ else {
167
+ resumedNotice = opts.continueSession
168
+ ? "◉ no saved session for this directory yet — starting fresh"
169
+ : `◉ session not found: ${toLoad} — starting fresh`;
170
+ }
171
+ }
172
+ }
67
173
  if (!opts.oneShot) {
68
174
  // Branded welcome — block-letter THERON banner + pill + numbered
69
175
  // security notes + quickstart status line. Same flow Claude Code
@@ -116,6 +222,19 @@ export async function runRepl(opts) {
116
222
  const names = session.projectMemory.sources.map((s) => path.basename(s)).join(", ");
117
223
  process.stdout.write(ui.info(`◉ loaded project memory: ${names}${session.projectMemory.truncated ? " (truncated)" : ""}\n`));
118
224
  }
225
+ // Custom-command notice — tell the user which /<name> commands are live.
226
+ if (session.customCommands.map.size > 0) {
227
+ const names = Array.from(session.customCommands.map.keys()).map((n) => "/" + n).join(", ");
228
+ process.stdout.write(ui.info(`◉ custom commands: ${names}\n`));
229
+ }
230
+ // Resume notice — which session we restored (if any).
231
+ if (resumedNotice) {
232
+ process.stdout.write(ui.info(resumedNotice + "\n"));
233
+ }
234
+ // Plan-mode notice — make the read-only stance visible on launch.
235
+ if (session.planMode) {
236
+ process.stdout.write(ui.warn("plan mode — read-only. Write / Edit / Bash / Stoa are blocked until you /plan or type 'approve'.\n"));
237
+ }
119
238
  process.stdout.write("\n");
120
239
  process.stdout.write(ui.info("type a message · /help for commands · /mode list to see all 33 · Ctrl-C to quit\n\n"));
121
240
  }
@@ -151,7 +270,76 @@ export async function runRepl(opts) {
151
270
  if (trimmed === "/quit" || trimmed === "/exit")
152
271
  break;
153
272
  if (trimmed === "/help") {
154
- process.stdout.write("\n" + renderSlashHelp() + "\n\n");
273
+ const customHelp = Array.from(session.customCommands.map.values()).map((c) => ({
274
+ trigger: "/" + c.name,
275
+ desc: c.description ?? `custom command (${c.source}) — ${path.basename(c.file)}`,
276
+ }));
277
+ process.stdout.write("\n" + renderSlashHelp(customHelp) + "\n\n");
278
+ continue;
279
+ }
280
+ // /plan — toggle plan mode. Also exits on the bare word "approve"
281
+ // (handled further down so it can't be a slash). Read-only tools +
282
+ // plan instruction when ON; normal policy when OFF.
283
+ if (trimmed === "/plan") {
284
+ session.planMode = !session.planMode;
285
+ if (session.planMode) {
286
+ process.stdout.write(ui.warn("plan mode ON — read-only. Write / Edit / Bash / Stoa are blocked (even with --yes). " +
287
+ "The model will investigate and propose a plan. Type 'approve' or /plan to exit.\n\n"));
288
+ }
289
+ else {
290
+ process.stdout.write(ui.info("plan mode OFF — normal tool policy restored.\n\n"));
291
+ }
292
+ continue;
293
+ }
294
+ // /render — toggle end-of-turn markdown rendering.
295
+ if (trimmed === "/render") {
296
+ session.renderMode = !session.renderMode;
297
+ process.stdout.write(session.renderMode
298
+ ? ui.info("markdown rendering ON — the reply is pretty-printed once the turn ends.\n\n")
299
+ : ui.info("markdown rendering OFF — raw streamed text.\n\n"));
300
+ continue;
301
+ }
302
+ // /commands — list custom slash commands; `/commands reload` re-scans.
303
+ if (trimmed === "/commands" || trimmed === "/commands reload") {
304
+ if (trimmed === "/commands reload") {
305
+ session.customCommands = loadCustomCommands(session.cwd);
306
+ process.stdout.write(ui.info("re-scanned commands directories.\n"));
307
+ }
308
+ const cmds = Array.from(session.customCommands.map.values());
309
+ if (cmds.length === 0) {
310
+ process.stdout.write(ui.info("\nno custom commands. Add a markdown file at ./.theron/commands/<name>.md " +
311
+ "(or ~/.theron/commands/<name>.md) — typing /<name> sends its body as the prompt, " +
312
+ "with $ARGUMENTS / $1 / $2 substituted.\n\n"));
313
+ }
314
+ else {
315
+ process.stdout.write(ui.info(`\ncustom commands (${cmds.length}):\n`));
316
+ for (const c of cmds) {
317
+ const desc = c.description ? ` — ${c.description}` : "";
318
+ process.stdout.write(ui.info(` /${c.name.padEnd(14)} (${c.source})${desc}\n`));
319
+ }
320
+ if (session.customCommands.dirs.length > 0) {
321
+ process.stdout.write(ui.info(`\nscanned: ${session.customCommands.dirs.join(", ")}\n`));
322
+ }
323
+ process.stdout.write("\n");
324
+ }
325
+ continue;
326
+ }
327
+ // /sessions — list saved sessions so the user knows what --resume can pick.
328
+ if (trimmed === "/sessions") {
329
+ const sessions = listSessions();
330
+ if (sessions.length === 0) {
331
+ process.stdout.write(ui.info("\nno saved sessions yet. They're written under ~/.theron/sessions/.\n\n"));
332
+ }
333
+ else {
334
+ process.stdout.write(ui.info(`\nsaved sessions (${sessions.length}) — resume with \`theron --resume <id>\`:\n`));
335
+ for (const s of sessions.slice(0, 30)) {
336
+ const here = s.id === session.sessionId ? "◉ " : " ";
337
+ const home = process.env.HOME || "";
338
+ const shortCwd = home && s.cwd.startsWith(home) ? "~" + s.cwd.slice(home.length) : s.cwd;
339
+ process.stdout.write(` ${here}${ui.toolLabel(s.id, "")} ${ui.info(`${s.messageCount} msgs · ${s.profile} · ${shortCwd}`)}\n`);
340
+ }
341
+ process.stdout.write("\n");
342
+ }
155
343
  continue;
156
344
  }
157
345
  if (trimmed === "/status") {
@@ -172,6 +360,11 @@ export async function runRepl(opts) {
172
360
  else {
173
361
  process.stdout.write(ui.info(`memory: none (add a THERON.md to this repo)\n`));
174
362
  }
363
+ process.stdout.write(ui.info(`plan mode: ${session.planMode ? "ON (read-only)" : "off"} · render: ${session.renderMode ? "on" : "off"}\n`));
364
+ process.stdout.write(ui.info(`session: ${session.sessionId} (${messages.length} messages)\n`));
365
+ if (session.planMode) {
366
+ process.stdout.write(ui.warn("Write / Edit / Bash / Stoa are blocked. /plan or 'approve' to exit.\n"));
367
+ }
175
368
  process.stdout.write("\n");
176
369
  continue;
177
370
  }
@@ -235,6 +428,12 @@ export async function runRepl(opts) {
235
428
  if (trimmed === "/clear") {
236
429
  messages.length = 0;
237
430
  pendingActions = [];
431
+ // Truncate the on-disk session too, otherwise the next save would
432
+ // re-persist an empty conversation under the same id and a later
433
+ // --continue would resume nothing useful. Deleting fully resets it.
434
+ if (!opts.headless)
435
+ deleteSession(session.sessionId);
436
+ session.sessionCreated = new Date().toISOString();
238
437
  process.stdout.write(ui.info("conversation cleared\n\n"));
239
438
  continue;
240
439
  }
@@ -261,8 +460,10 @@ export async function runRepl(opts) {
261
460
  session.cwd = next;
262
461
  updateCtx();
263
462
  // New directory may carry a different (or no) project-memory
264
- // file — reload so the model honors THIS repo's rules.
463
+ // file — reload so the model honors THIS repo's rules. Also
464
+ // re-scan custom commands and re-key the save/resume session id.
265
465
  reloadProjectMemory();
466
+ reloadDirScopedState();
266
467
  process.stdout.write(ui.info(`cwd → ${session.cwd}\n`));
267
468
  if (session.projectMemory.sources.length > 0) {
268
469
  const names = session.projectMemory.sources.map((s) => path.basename(s)).join(", ");
@@ -522,11 +723,45 @@ export async function runRepl(opts) {
522
723
  process.stdout.write(ui.info("include `theron --version` output + the prompt that broke.\n\n"));
523
724
  continue;
524
725
  }
525
- // Unknown slash → friendly nudge.
726
+ // Custom slash command substitute args into its body and FALL
727
+ // THROUGH into the normal prompt path (set `trimmed`, do NOT
728
+ // continue) so it becomes this turn's prompt and runs through pins /
729
+ // verifier / message-push like any typed message. Runs AFTER every
730
+ // built-in check + the unknown-slash guard is below, so a custom
731
+ // command can never shadow a built-in (and load-time rejects
732
+ // reserved names anyway).
733
+ let isExpandedCommand = false;
526
734
  if (trimmed.startsWith("/")) {
735
+ const [head, ...argTokens] = trimmed.slice(1).split(/\s+/);
736
+ const cmdName = (head || "").toLowerCase();
737
+ const custom = session.customCommands.map.get(cmdName);
738
+ if (custom) {
739
+ const argString = argTokens.join(" ");
740
+ const expanded = substituteArgs(custom.body, argString).trim();
741
+ if (expanded) {
742
+ process.stdout.write(ui.info(`▸ /${cmdName}${argString ? " " + argString : ""}\n`));
743
+ trimmed = expanded;
744
+ isExpandedCommand = true;
745
+ }
746
+ else {
747
+ process.stdout.write(ui.error(`/${cmdName} expanded to an empty prompt — nothing to send.\n\n`));
748
+ continue;
749
+ }
750
+ }
751
+ }
752
+ // Unknown slash → friendly nudge. (Custom commands already matched
753
+ // above and set isExpandedCommand; only a real unknown reaches here.)
754
+ if (!isExpandedCommand && trimmed.startsWith("/")) {
527
755
  process.stdout.write(ui.error(`unknown command: ${trimmed.split(/\s/)[0]}. type /help for the list.\n\n`));
528
756
  continue;
529
757
  }
758
+ // "approve" — exit plan mode and restore the normal tool policy. Only
759
+ // meaningful while in plan mode; otherwise it's just a chat message.
760
+ if (session.planMode && /^approve$/i.test(trimmed)) {
761
+ session.planMode = false;
762
+ process.stdout.write(ui.info("plan approved — plan mode OFF, normal tool policy restored. Re-send your go-ahead to execute.\n\n"));
763
+ continue;
764
+ }
530
765
  // If the user typed a bare 1-4 and we just rendered action chips,
531
766
  // expand it into the action's prompt — terminal analogue of
532
767
  // clicking an amber chip on web.
@@ -547,6 +782,26 @@ export async function runRepl(opts) {
547
782
  // forced-spec extractor (interaction.ts) picks them up. Cleared
548
783
  // after one turn — same shape the web composer uses.
549
784
  let toSend = trimmed;
785
+ // @-FILE refs — resolve BEFORE the pin/specialist @-scan so any
786
+ // @<path> that names a real file is inlined (and stripped) rather
787
+ // than mistaken for a specialist. Path traversal is confined to cwd
788
+ // and secrets/binaries are skipped (see file_refs.ts). Bare @words
789
+ // that aren't files are left untouched for the @-mention router.
790
+ {
791
+ const refs = resolveFileRefs(toSend, session.cwd);
792
+ if (refs.attachments.length > 0) {
793
+ toSend = refs.text;
794
+ for (const a of refs.attachments) {
795
+ const kb = (Buffer.byteLength(a.content, "utf8") / 1024).toFixed(1);
796
+ process.stdout.write(ui.info(`◉ inlined @${a.token} (${kb} KB${a.truncated ? ", truncated" : ""})\n`));
797
+ }
798
+ }
799
+ // Surface refused path-like tokens so a silently-skipped @file
800
+ // doesn't look like it worked.
801
+ for (const s of refs.skipped) {
802
+ process.stdout.write(ui.warn(`@${s.token}: ${s.reason}\n`));
803
+ }
804
+ }
550
805
  let activePins = [];
551
806
  if (session.pinnedSpecs.length > 0) {
552
807
  activePins = [...session.pinnedSpecs];
@@ -589,7 +844,13 @@ export async function runRepl(opts) {
589
844
  if (messages.length === 0 && projectContext) {
590
845
  messages.push({ role: "user", content: projectContext });
591
846
  }
592
- messages.push({ role: "user", content: toSend });
847
+ // Plan-mode instruction. The CLI message schema has no `system` role,
848
+ // so — exactly like projectContext — the instruction rides as a
849
+ // leading note on this turn's user message. The model MAY ignore it;
850
+ // the executor-side hard deny (see runOneTurn) is the real safety net,
851
+ // so correctness never depends on the model honoring this text.
852
+ const planPrefixed = session.planMode ? PLAN_MODE_INSTRUCTION + "\n\n" + toSend : toSend;
853
+ messages.push({ role: "user", content: planPrefixed });
593
854
  // Fire the interaction-plan classifier in parallel with the first
594
855
  // model turn. The plan is shared across web/CLI/IDE — if it wins
595
856
  // the race we print the amber headline above the streaming text
@@ -605,17 +866,22 @@ export async function runRepl(opts) {
605
866
  });
606
867
  let planPrinted = false;
607
868
  void planPromise.then((p) => {
608
- if (p && !planPrinted) {
869
+ // Headline is interactive chrome — suppress it in headless mode so
870
+ // stdout stays clean for piping / JSON.
871
+ if (p && !planPrinted && !opts.headless) {
609
872
  planPrinted = true;
610
873
  process.stdout.write("\n" + ui.planHeadline(p.headline) + "\n");
611
874
  }
612
875
  });
613
876
  // Inner loop: run turn, execute tool calls, repeat until end_turn.
614
877
  // We also accumulate the files the model touched (Write/Edit args)
615
- // so the verifier pass can scope itself to just this turn's edits.
878
+ // so the verifier pass can scope itself to just this turn's edits,
879
+ // and the names of every tool the model invoked (for headless JSON).
616
880
  let turnGuard = 0;
617
881
  const touchedFiles = new Set();
882
+ const toolsUsed = [];
618
883
  let lastAssistantText = "";
884
+ let turnErrored = false;
619
885
  while (turnGuard < 20) {
620
886
  turnGuard += 1;
621
887
  const res = await runOneTurn({
@@ -631,9 +897,21 @@ export async function runRepl(opts) {
631
897
  profile: session.profile.slug,
632
898
  projectContext: projectContext || undefined,
633
899
  touchedFilesSink: touchedFiles,
900
+ toolsUsedSink: toolsUsed,
901
+ // Plan mode: hard-deny mutating tools at the executor (even with
902
+ // --yes) and send the model only the read-only tool subset.
903
+ planMode: session.planMode,
904
+ tools: session.planMode ? READONLY_TOOL_SCHEMAS : TOOL_SCHEMAS,
905
+ // Render / headless control the streaming sink: when render-mode
906
+ // is on (or headless), buffer the text instead of echoing raw
907
+ // deltas — the answer is emitted once at end (rendered or JSON).
908
+ bufferText: session.renderMode || !!opts.headless,
909
+ headless: !!opts.headless,
634
910
  });
635
911
  if (res.kind === "error") {
636
- process.stdout.write(ui.error(res.message) + "\n\n");
912
+ if (!opts.headless)
913
+ process.stdout.write(ui.error(res.message) + "\n\n");
914
+ turnErrored = true;
637
915
  break;
638
916
  }
639
917
  if (res.kind === "end_turn") {
@@ -642,11 +920,22 @@ export async function runRepl(opts) {
642
920
  }
643
921
  // tool_use — keep looping
644
922
  }
923
+ // Render the assistant's markdown once the turn settles, when render
924
+ // mode is on and we're an interactive TTY. We buffered the raw deltas
925
+ // (bufferText above), so this is the ONLY place the answer prints —
926
+ // double-printing is structurally impossible. Skipped in headless
927
+ // (JSON/text payload handles output) and when there's no text.
928
+ if (session.renderMode && !opts.headless && lastAssistantText) {
929
+ const useColor = !!process.stdout.isTTY && !process.env.NO_COLOR;
930
+ const rendered = useColor ? renderMarkdown(lastAssistantText) : lastAssistantText;
931
+ process.stdout.write("\n" + rendered.replace(/\n+$/, "") + "\n\n");
932
+ }
645
933
  // ── Verifier pass ─────────────────────────────────────────────
646
934
  // After the turn settles, run the active profile's verifier kernels
647
935
  // against the assistant output + the files it touched. Blocking
648
936
  // issues get fed back into the NEXT user message so the model can
649
937
  // self-correct. Warnings + info surface inline as a chip.
938
+ let verifierPayload = null;
650
939
  if (session.profile.verifiers && session.profile.verifiers.length > 0) {
651
940
  const issues = await runVerifiers(session.profile.verifiers, {
652
941
  cwd: session.cwd,
@@ -655,17 +944,21 @@ export async function runRepl(opts) {
655
944
  profile: session.profile.slug,
656
945
  });
657
946
  const sum = summarizeIssues(issues);
947
+ verifierPayload = { ok: sum.ok, summary: sum.summary, details: sum.details };
658
948
  if (issues.length === 0) {
659
- process.stdout.write(ui.info(`✓ verifiers (${session.profile.verifiers.join(", ")}) green\n\n`));
949
+ if (!opts.headless)
950
+ process.stdout.write(ui.info(`✓ verifiers (${session.profile.verifiers.join(", ")}) green\n\n`));
660
951
  }
661
952
  else {
662
- const head = sum.ok
663
- ? ui.info(`verifiers · ${sum.summary}\n`)
664
- : ui.error(`verifiers · ${sum.summary}\n`);
665
- process.stdout.write("\n" + head);
666
- for (const line of sum.details)
667
- process.stdout.write(ui.info(line) + "\n");
668
- process.stdout.write("\n");
953
+ if (!opts.headless) {
954
+ const head = sum.ok
955
+ ? ui.info(`verifiers · ${sum.summary}\n`)
956
+ : ui.error(`verifiers · ${sum.summary}\n`);
957
+ process.stdout.write("\n" + head);
958
+ for (const line of sum.details)
959
+ process.stdout.write(ui.info(line) + "\n");
960
+ process.stdout.write("\n");
961
+ }
669
962
  // Stage blocking issues for the next turn — model self-corrects
670
963
  // on the user's next prompt.
671
964
  session.pendingVerifierBlock = formatForNextTurn(issues);
@@ -673,12 +966,28 @@ export async function runRepl(opts) {
673
966
  }
674
967
  // After the turn settles, surface suggested actions if the plan
675
968
  // came back. They render as numbered chips; on the next prompt
676
- // the user can type "1" / "2" / "3" to fire one.
969
+ // the user can type "1" / "2" / "3" to fire one. Suppressed in
970
+ // headless mode (chips are interactive chrome).
677
971
  const plan = await planPromise.catch(() => null);
678
- if (plan && plan.suggested_actions.length > 0) {
972
+ if (!opts.headless && plan && plan.suggested_actions.length > 0) {
679
973
  renderSuggestedActions(plan);
680
974
  pendingActions = plan.suggested_actions.slice(0, 4);
681
975
  }
976
+ // Persist the conversation after every turn so a later --continue
977
+ // resumes from here. No-op in headless mode.
978
+ persistSession();
979
+ // Headless mode: emit the single payload and exit. The wire format
980
+ // carries no usage/cost frame, so `cost` is null (never fabricated).
981
+ if (opts.headless) {
982
+ emitHeadlessPayload({
983
+ outputFormat: opts.outputFormat ?? "text",
984
+ answer: lastAssistantText,
985
+ toolsUsed,
986
+ verifier: verifierPayload,
987
+ sessionId: session.sessionId,
988
+ });
989
+ return turnErrored ? 1 : 0;
990
+ }
682
991
  if (opts.oneShot)
683
992
  break;
684
993
  }
@@ -693,50 +1002,65 @@ async function runOneTurn(args) {
693
1002
  const toolCalls = [];
694
1003
  let stopReason = null;
695
1004
  let firstDelta = true;
1005
+ const headless = args.headless === true;
1006
+ const bufferText = args.bufferText === true;
696
1007
  // Show the pin header BEFORE thinking spinner so the user knows
697
- // immediately that their /pin took effect.
698
- if (args.pinnedSpecs && args.pinnedSpecs.length > 0) {
1008
+ // immediately that their /pin took effect. (Suppressed in headless.)
1009
+ if (!headless && args.pinnedSpecs && args.pinnedSpecs.length > 0) {
699
1010
  process.stdout.write(announcePin(args.pinnedSpecs) + "\n");
700
1011
  }
701
1012
  // "thinking…" spinner — fires immediately, clears the moment the
702
- // first text delta lands. Removes the awkward silent gap between
703
- // prompt submission and first token.
1013
+ // first text delta lands. Spinner writes to stderr; we still skip it in
1014
+ // headless so a `2>&1` redirect can't contaminate parseable output.
704
1015
  const spinner = new Spinner("thinking…");
705
- spinner.start();
1016
+ if (!headless)
1017
+ spinner.start();
706
1018
  await streamChat({
707
1019
  apiUrl: args.apiUrl,
708
1020
  apiKey: args.apiKey,
709
1021
  messages: args.messages,
710
- tools: TOOL_SCHEMAS,
1022
+ tools: args.tools ?? TOOL_SCHEMAS,
711
1023
  profile: args.profile,
712
1024
  projectContext: args.projectContext,
713
1025
  }, {
714
1026
  onTextDelta: (d) => {
715
1027
  if (firstDelta) {
716
- spinner.stop();
717
- process.stdout.write("\n");
1028
+ if (!headless) {
1029
+ spinner.stop();
1030
+ if (!bufferText)
1031
+ process.stdout.write("\n");
1032
+ }
718
1033
  firstDelta = false;
719
1034
  }
720
1035
  assistantText += d;
721
- process.stdout.write(d);
1036
+ // Buffer-only when render mode / headless is on, so the answer is
1037
+ // emitted once at the end (rendered or as JSON). Otherwise stream
1038
+ // raw deltas live.
1039
+ if (!bufferText)
1040
+ process.stdout.write(d);
722
1041
  },
723
1042
  onToolCall: (call) => {
724
1043
  toolCalls.push(call);
725
1044
  // Update the spinner label so the user sees what's queued.
726
- if (firstDelta)
1045
+ if (firstDelta && !headless)
727
1046
  spinner.setLabel(`${call.name}…`);
728
1047
  },
729
1048
  onTurnEnd: (reason) => { stopReason = reason; },
730
1049
  onError: (msg) => {
731
1050
  stopReason = "error";
732
- spinner.stop();
733
- process.stdout.write("\n" + announceError(msg) + "\n");
1051
+ if (!headless) {
1052
+ spinner.stop();
1053
+ process.stdout.write("\n" + announceError(msg) + "\n");
1054
+ }
734
1055
  },
735
1056
  });
736
1057
  // Always stop the spinner in case neither delta nor error fired
737
1058
  // (e.g. immediate turn_end with no content — empty model response).
738
- spinner.stop();
739
- if (assistantText)
1059
+ if (!headless)
1060
+ spinner.stop();
1061
+ // When streaming raw, close the answer block with spacing. When
1062
+ // buffering, the caller owns final spacing (render / JSON).
1063
+ if (assistantText && !bufferText && !headless)
740
1064
  process.stdout.write("\n\n");
741
1065
  args.messages.push({ role: "assistant", content: assistantText, tool_calls: toolCalls });
742
1066
  if (stopReason === "error")
@@ -754,6 +1078,24 @@ async function runOneTurn(args) {
754
1078
  });
755
1079
  continue;
756
1080
  }
1081
+ // ── PLAN-MODE HARD DENY ───────────────────────────────────────
1082
+ // This runs BEFORE the confirm()/yolo check, so it is the GUARANTEE
1083
+ // (not the schema filter) that no mutating tool can execute in plan
1084
+ // mode — even under --yes / yolo. Stoa (real SaaS side effects) and
1085
+ // Bash (can write via the shell) are denied alongside Write/Edit. The
1086
+ // deny is fed back as a tool result so the model can pivot to a plan.
1087
+ if (args.planMode && MUTATING_TOOLS.has(call.name)) {
1088
+ if (!headless) {
1089
+ process.stdout.write(announceTool(call.name, tool.describe(call.args)) + "\n");
1090
+ process.stdout.write(announceWarn(`[plan mode] ${call.name} is disabled — read-only until you /plan or 'approve'`) + "\n\n");
1091
+ }
1092
+ args.messages.push({
1093
+ role: "tool",
1094
+ tool_call_id: call.id,
1095
+ content: `[plan-mode] ${call.name} is disabled in plan mode. Investigate with read-only tools (Read/Glob/Grep/LS) and propose a numbered plan; do not modify files or run commands.`,
1096
+ });
1097
+ continue;
1098
+ }
757
1099
  // Record Write/Edit paths so the post-turn verifier pass can
758
1100
  // scope itself to just the files this turn touched.
759
1101
  if (args.touchedFilesSink && (call.name === "Write" || call.name === "Edit")) {
@@ -765,8 +1107,9 @@ async function runOneTurn(args) {
765
1107
  }
766
1108
  // Tool announcement — bullet style matches a list of actions
767
1109
  // rather than CLI chrome. Single line, brand-amber name + dim
768
- // detail.
769
- process.stdout.write(announceTool(call.name, tool.describe(call.args)) + "\n");
1110
+ // detail. (Suppressed in headless so stdout stays parseable.)
1111
+ if (!headless)
1112
+ process.stdout.write(announceTool(call.name, tool.describe(call.args)) + "\n");
770
1113
  if (!args.ctx.yolo && tool.confirmPolicy !== "never") {
771
1114
  const ok = await confirm(` Allow ${call.name}?`, args.rl);
772
1115
  if (!ok) {
@@ -775,37 +1118,74 @@ async function runOneTurn(args) {
775
1118
  tool_call_id: call.id,
776
1119
  content: `[user denied] User declined to run ${call.name}.`,
777
1120
  });
778
- process.stdout.write(announceWarn("denied") + "\n\n");
1121
+ if (!headless)
1122
+ process.stdout.write(announceWarn("denied") + "\n\n");
779
1123
  continue;
780
1124
  }
781
1125
  }
782
1126
  // Spinner during tool execution — Bash/Read on a large file can
783
1127
  // take seconds. Without this the user stares at silence.
784
1128
  const toolSpin = new Spinner(`running ${call.name}…`);
785
- toolSpin.start();
1129
+ if (!headless)
1130
+ toolSpin.start();
786
1131
  let result;
787
1132
  try {
788
1133
  result = await tool.execute(call.args, args.ctx);
789
- toolSpin.stop();
1134
+ if (!headless)
1135
+ toolSpin.stop();
1136
+ // Record only tools that actually ran (not denied / plan-blocked).
1137
+ if (args.toolsUsedSink)
1138
+ args.toolsUsedSink.push(call.name);
790
1139
  }
791
1140
  catch (err) {
792
- toolSpin.stop();
1141
+ if (!headless)
1142
+ toolSpin.stop();
793
1143
  // Hardened: malformed args / tool throws / fs errors all turn
794
1144
  // into a structured tool-result string the model can RECOVER
795
1145
  // from instead of crashing the REPL. The error gets fed back in
796
1146
  // the conversation so the model can fix its call and try again.
797
1147
  const errMsg = err instanceof Error ? err.message : String(err);
798
1148
  result = `[error] ${call.name} failed: ${errMsg}\n\nThe tool call was rejected. Common causes: missing required args, invalid path, file too large, command refused. You can retry with corrected args.`;
799
- process.stdout.write(announceError(`${call.name} failed: ${errMsg}`) + "\n");
1149
+ if (!headless)
1150
+ process.stdout.write(announceError(`${call.name} failed: ${errMsg}`) + "\n");
800
1151
  }
801
1152
  // Show more of each tool's output in the local CLI preview. The
802
1153
  // model always sees the full output server-side; the truncation
803
1154
  // only affects what the user sees in their terminal.
804
- process.stdout.write(ui.info(truncatePreview(result, 4000)) + "\n\n");
1155
+ if (!headless)
1156
+ process.stdout.write(ui.info(truncatePreview(result, 4000)) + "\n\n");
805
1157
  args.messages.push({ role: "tool", tool_call_id: call.id, content: result });
806
1158
  }
807
1159
  return { kind: "tool_use" };
808
1160
  }
1161
+ /** Plan-mode instruction. Rides as a leading user note (the CLI message
1162
+ * schema has no `system` role) — same mechanism as projectContext. The
1163
+ * executor-side hard deny is the real guarantee; this just biases the
1164
+ * model toward investigate-and-plan behavior. */
1165
+ const PLAN_MODE_INSTRUCTION = "You are in PLAN MODE. Investigate the task using ONLY read-only tools " +
1166
+ "(Read, Glob, Grep, LS). Do NOT modify files or run shell commands — " +
1167
+ "Write, Edit, Bash, and Stoa are disabled and any attempt is rejected. " +
1168
+ "Produce a numbered, ordered plan of the changes you WOULD make, then STOP " +
1169
+ "and wait for the user to approve before executing.";
1170
+ /** Emit the single headless payload. For json this is ONE JSON object on
1171
+ * 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. */
1174
+ function emitHeadlessPayload(p) {
1175
+ if (p.outputFormat === "json") {
1176
+ const obj = {
1177
+ answer: p.answer,
1178
+ tools_used: p.toolsUsed,
1179
+ verifier: p.verifier,
1180
+ cost: null,
1181
+ session_id: p.sessionId,
1182
+ };
1183
+ process.stdout.write(JSON.stringify(obj) + "\n");
1184
+ }
1185
+ else {
1186
+ process.stdout.write(p.answer.replace(/\n+$/, "") + "\n");
1187
+ }
1188
+ }
809
1189
  async function confirm(question, rl) {
810
1190
  // CRITICAL: reuse the OUTER REPL's readline.Interface. Creating a
811
1191
  // new Interface + closing it would close stdin under the outer rl
@@ -885,8 +1265,4 @@ function expandActionToPrompt(a) {
885
1265
  function chalkBoldThronWord() {
886
1266
  return chalk.bold.hex("#FFAE00")("Theron");
887
1267
  }
888
- // Expose renderMarkdown so `theron --markdown` mode can pretty-print
889
- // after the stream finishes if someone wants that flow. Currently the
890
- // REPL streams raw deltas to keep latency snappy.
891
- export { renderMarkdown };
892
1268
  //# sourceMappingURL=repl.js.map