@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.
- package/dist/api.d.ts +8 -0
- package/dist/api.js +3 -0
- package/dist/api.js.map +1 -1
- package/dist/auth.js +51 -1
- package/dist/auth.js.map +1 -1
- package/dist/banner.js +3 -2
- package/dist/banner.js.map +1 -1
- package/dist/checkpoints.d.ts +32 -0
- package/dist/checkpoints.js +61 -0
- package/dist/checkpoints.js.map +1 -0
- package/dist/index.js +59 -4
- package/dist/index.js.map +1 -1
- package/dist/input.d.ts +61 -0
- package/dist/input.js +574 -0
- package/dist/input.js.map +1 -0
- package/dist/profiles/index.js +5 -0
- package/dist/profiles/index.js.map +1 -1
- package/dist/profiles/methodologies/operate_domains.d.ts +8 -0
- package/dist/profiles/methodologies/operate_domains.js +1239 -0
- package/dist/profiles/methodologies/operate_domains.js.map +1 -0
- package/dist/profiles/seeds.js +57 -36
- package/dist/profiles/seeds.js.map +1 -1
- package/dist/receipt.d.ts +17 -0
- package/dist/receipt.js +46 -0
- package/dist/receipt.js.map +1 -0
- package/dist/render.d.ts +4 -1
- package/dist/render.js +95 -28
- package/dist/render.js.map +1 -1
- package/dist/repl.d.ts +8 -1
- package/dist/repl.js +420 -62
- package/dist/repl.js.map +1 -1
- package/dist/sessions.d.ts +14 -0
- package/dist/sessions.js +100 -0
- package/dist/sessions.js.map +1 -1
- package/dist/ship.d.ts +2 -0
- package/dist/ship.js +62 -0
- package/dist/ship.js.map +1 -0
- package/dist/skills/catalog.d.ts +13 -0
- package/dist/skills/catalog.js +86 -0
- package/dist/skills/catalog.js.map +1 -0
- package/dist/tools/bash.js +81 -14
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/edit.js +21 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/glob.js +4 -1
- package/dist/tools/glob.js.map +1 -1
- package/dist/tools/grep.d.ts +5 -0
- package/dist/tools/grep.js +101 -2
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/index.d.ts +22 -0
- package/dist/tools/index.js +177 -41
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/ls.d.ts +3 -0
- package/dist/tools/ls.js +23 -12
- package/dist/tools/ls.js.map +1 -1
- package/dist/tools/multiedit.d.ts +12 -0
- package/dist/tools/multiedit.js +79 -0
- package/dist/tools/multiedit.js.map +1 -0
- package/dist/tools/stoa.d.ts +1 -1
- package/dist/tools/stoa.js +7 -3
- package/dist/tools/stoa.js.map +1 -1
- package/dist/tools/task.d.ts +9 -0
- package/dist/tools/task.js +166 -0
- package/dist/tools/task.js.map +1 -0
- package/dist/tools/todowrite.d.ts +12 -0
- package/dist/tools/todowrite.js +38 -0
- package/dist/tools/todowrite.js.map +1 -0
- package/dist/tools/webfetch.d.ts +6 -0
- package/dist/tools/webfetch.js +98 -0
- package/dist/tools/webfetch.js.map +1 -0
- package/dist/tools/websearch.d.ts +7 -0
- package/dist/tools/websearch.js +83 -0
- package/dist/tools/websearch.js.map +1 -0
- package/dist/tools/write.js +17 -1
- package/dist/tools/write.js.map +1 -1
- package/dist/verifiers/confidence_marked.d.ts +2 -0
- package/dist/verifiers/confidence_marked.js +49 -0
- package/dist/verifiers/confidence_marked.js.map +1 -0
- package/dist/verifiers/disclaimer_gate.d.ts +2 -0
- package/dist/verifiers/disclaimer_gate.js +57 -0
- package/dist/verifiers/disclaimer_gate.js.map +1 -0
- package/dist/verifiers/index.d.ts +5 -0
- package/dist/verifiers/index.js +20 -7
- package/dist/verifiers/index.js.map +1 -1
- package/dist/verifiers/lint.js +4 -3
- package/dist/verifiers/lint.js.map +1 -1
- package/dist/verifiers/promoted_kernels.d.ts +8 -0
- package/dist/verifiers/promoted_kernels.js +190 -0
- package/dist/verifiers/promoted_kernels.js.map +1 -0
- package/dist/verifiers/source_gate.js +2 -3
- package/dist/verifiers/source_gate.js.map +1 -1
- package/dist/verifiers/test_smoke.js +30 -0
- package/dist/verifiers/test_smoke.js.map +1 -1
- package/dist/verifiers/types.d.ts +3 -0
- package/package.json +4 -2
- package/skills/README.md +123 -0
- package/skills/ab-test.md +89 -0
- package/skills/api-design.md +175 -0
- package/skills/architecture-design.md +185 -0
- package/skills/business-case.md +77 -0
- package/skills/causal-inference.md +77 -0
- package/skills/clinical-guideline.md +98 -0
- package/skills/code-review.md +98 -0
- package/skills/cold-outreach.md +268 -0
- package/skills/competitive-teardown.md +223 -0
- package/skills/component-spec.md +121 -0
- package/skills/content-calendar.md +280 -0
- package/skills/contract-review.md +155 -0
- package/skills/data-analysis.md +187 -0
- package/skills/debug.md +91 -0
- package/skills/design-audit.md +121 -0
- package/skills/differential-diagnosis.md +79 -0
- package/skills/discovery-call.md +206 -0
- package/skills/edit-pass.md +80 -0
- package/skills/engineering-calc.md +101 -0
- package/skills/estimate.md +70 -0
- package/skills/experiment-design.md +105 -0
- package/skills/fact-check.md +82 -0
- package/skills/financial-model.md +104 -0
- package/skills/grant-proposal.md +93 -0
- package/skills/harmony-analysis.md +93 -0
- package/skills/hypothesis-generation.md +99 -0
- package/skills/incident-response.md +134 -0
- package/skills/interview-loop.md +62 -0
- package/skills/job-scorecard.md +92 -0
- package/skills/kb-article.md +174 -0
- package/skills/launch-plan.md +85 -0
- package/skills/lease-review.md +93 -0
- package/skills/lesson-plan.md +198 -0
- package/skills/literature-review.md +69 -0
- package/skills/market-entry.md +137 -0
- package/skills/market-sizing.md +159 -0
- package/skills/meta-analysis.md +140 -0
- package/skills/migrate.md +117 -0
- package/skills/optimize.md +88 -0
- package/skills/options-strategy.md +166 -0
- package/skills/peer-review.md +96 -0
- package/skills/pentest-plan.md +193 -0
- package/skills/pitch-review.md +132 -0
- package/skills/plan.md +88 -0
- package/skills/policy-brief.md +124 -0
- package/skills/positioning.md +192 -0
- package/skills/postmortem.md +168 -0
- package/skills/prd.md +105 -0
- package/skills/prioritize.md +162 -0
- package/skills/proof.md +91 -0
- package/skills/property-underwrite.md +159 -0
- package/skills/recipe-develop.md +109 -0
- package/skills/red-team.md +142 -0
- package/skills/refactor.md +58 -0
- package/skills/reflection-session.md +115 -0
- package/skills/regulatory-compliance.md +136 -0
- package/skills/reproduce.md +87 -0
- package/skills/runbook.md +344 -0
- package/skills/security-audit.md +154 -0
- package/skills/seo-brief.md +201 -0
- package/skills/sql-query.md +161 -0
- package/skills/story-craft.md +163 -0
- package/skills/tdd.md +59 -0
- package/skills/term-sheet.md +298 -0
- package/skills/theory-of-change.md +88 -0
- package/skills/threat-model.md +104 -0
- package/skills/ticket-triage.md +200 -0
- package/skills/tolerance-analysis.md +149 -0
- package/skills/training-program.md +151 -0
- package/skills/translate.md +64 -0
- package/skills/unit-economics.md +238 -0
- package/skills/valuation.md +112 -0
- 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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ·
|
|
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
|
-
|
|
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
|
-
|
|
251
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1173
|
-
*
|
|
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
|
-
|
|
1191
|
-
//
|
|
1192
|
-
//
|
|
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(
|
|
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
|
-
//
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
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)
|