@vextlabs/theron-cli 0.1.1 → 0.2.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.
- package/README.md +29 -5
- package/dist/banner.d.ts +8 -1
- package/dist/banner.js +14 -2
- package/dist/banner.js.map +1 -1
- package/dist/file_refs.d.ts +38 -0
- package/dist/file_refs.js +219 -0
- package/dist/file_refs.js.map +1 -0
- package/dist/index.js +112 -2
- package/dist/index.js.map +1 -1
- package/dist/repl.d.ts +15 -2
- package/dist/repl.js +420 -44
- package/dist/repl.js.map +1 -1
- package/dist/sessions.d.ts +47 -0
- package/dist/sessions.js +200 -0
- package/dist/sessions.js.map +1 -0
- package/dist/slash_commands.d.ts +47 -0
- package/dist/slash_commands.js +194 -0
- package/dist/slash_commands.js.map +1 -0
- package/dist/tools/index.d.ts +9 -0
- package/dist/tools/index.js +11 -0
- package/dist/tools/index.js.map +1 -1
- package/package.json +5 -2
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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
949
|
+
if (!opts.headless)
|
|
950
|
+
process.stdout.write(ui.info(`✓ verifiers (${session.profile.verifiers.join(", ")}) green\n\n`));
|
|
660
951
|
}
|
|
661
952
|
else {
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
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.
|
|
703
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
717
|
-
|
|
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
|
-
|
|
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
|
-
|
|
733
|
-
|
|
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
|
-
|
|
739
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1129
|
+
if (!headless)
|
|
1130
|
+
toolSpin.start();
|
|
786
1131
|
let result;
|
|
787
1132
|
try {
|
|
788
1133
|
result = await tool.execute(call.args, args.ctx);
|
|
789
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|