@nanhara/hara 0.95.2 → 0.98.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/agent/loop.js +68 -0
- package/dist/config.js +28 -2
- package/dist/index.js +207 -17
- package/dist/security/guardian.js +261 -0
- package/dist/tools/ask_user.js +64 -0
- package/dist/tui/App.js +41 -5
- package/package.json +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -7,6 +7,7 @@ import { skillsDigest } from "../skills/skills.js";
|
|
|
7
7
|
import { runHooks } from "../hooks.js";
|
|
8
8
|
import { mapLimit, maxParallel } from "../concurrency.js";
|
|
9
9
|
import { decideCommand, loadPermissionRules } from "../security/permissions.js";
|
|
10
|
+
import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
|
|
10
11
|
import { subdirHint } from "../context/subdir-hints.js";
|
|
11
12
|
import { classifyError, failoverAction, errorHint } from "./failover.js";
|
|
12
13
|
import { currentTodos } from "../tools/todo.js";
|
|
@@ -80,6 +81,11 @@ export async function runAgent(history, opts) {
|
|
|
80
81
|
const toolCounts = new Map();
|
|
81
82
|
let blindShots = 0;
|
|
82
83
|
let nudged = false;
|
|
84
|
+
// Guardian: engaged only on HIGH-RISK actions (see classifyRisk). `on` gates the whole layer so normal
|
|
85
|
+
// work never pays for it; the breaker is per-run (a hard stop after repeated blocks).
|
|
86
|
+
const guardianOn = !!opts.guardian && (opts.guardian.enabled ?? true) && guardianEnabled();
|
|
87
|
+
const breaker = newBreaker();
|
|
88
|
+
let breakerHalt = false; // set when a tripped breaker aborts this run
|
|
83
89
|
for (;;) {
|
|
84
90
|
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
85
91
|
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
@@ -206,6 +212,12 @@ export async function runAgent(history, opts) {
|
|
|
206
212
|
return;
|
|
207
213
|
const plans = [];
|
|
208
214
|
for (const tu of r.toolUses) {
|
|
215
|
+
if (breakerHalt) {
|
|
216
|
+
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
217
|
+
// (no hang, no further tools) so the model + user get a definitive stop.
|
|
218
|
+
plans.push({ tu, tool: getTool(tu.name), denied: "Guardian circuit-breaker halted this run (too many high-risk actions blocked). Ask the user to review and re-run." });
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
209
221
|
const tool = getTool(tu.name);
|
|
210
222
|
if (!tool) {
|
|
211
223
|
plans.push({ tu, tool: undefined, denied: `Unknown tool: ${tu.name}` });
|
|
@@ -224,6 +236,50 @@ export async function runAgent(history, opts) {
|
|
|
224
236
|
plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
|
|
225
237
|
continue;
|
|
226
238
|
}
|
|
239
|
+
// Guardian layer — runs AFTER permission rules, alongside/just before the confirm gate. The
|
|
240
|
+
// deterministic classifier short-circuits FIRST: read tools, in-project edits, and ordinary shell
|
|
241
|
+
// commands classify `low` (pure Node, no LLM) and skip everything below — zero added latency. Only a
|
|
242
|
+
// genuinely HIGH-RISK action pays for a cheap-model veto, and that veto fails OPEN on any glitch.
|
|
243
|
+
if (guardianOn && !breakerHalt) {
|
|
244
|
+
const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
|
|
245
|
+
if (risk.level === "high") {
|
|
246
|
+
const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
|
247
|
+
const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
|
|
248
|
+
if (verdict.decision === "block") {
|
|
249
|
+
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
250
|
+
plans.push({
|
|
251
|
+
tu,
|
|
252
|
+
tool,
|
|
253
|
+
denied: `Guardian blocked this high-risk action: ${verdict.reason || risk.reason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
|
|
254
|
+
});
|
|
255
|
+
if (!opts.quiet) {
|
|
256
|
+
const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || risk.reason}`;
|
|
257
|
+
if (sink)
|
|
258
|
+
sink.notice(note);
|
|
259
|
+
else
|
|
260
|
+
out(c.yellow(` ${note}\n`));
|
|
261
|
+
}
|
|
262
|
+
if (tripped) {
|
|
263
|
+
// Circuit-breaker tripped — a HARDER stop than the soft stuck-guard. On an INTERACTIVE run
|
|
264
|
+
// (an `ask` channel exists), require an explicit human OK to continue. In headless/no-UI
|
|
265
|
+
// (gateway/cron/-p, where `confirm` is auto-yes and there's no real user), abort SAFELY —
|
|
266
|
+
// never auto-continue past the breaker, and never hang.
|
|
267
|
+
const interactive = !!ctx.ask;
|
|
268
|
+
const cont = interactive
|
|
269
|
+
? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
|
|
270
|
+
: false;
|
|
271
|
+
if (cont === false) {
|
|
272
|
+
breakerHalt = true;
|
|
273
|
+
}
|
|
274
|
+
else {
|
|
275
|
+
breaker.tripped = false;
|
|
276
|
+
breaker.blocks = 0; // user vouched → reset the counter, keep classifying
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
continue;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
}
|
|
227
283
|
if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
|
|
228
284
|
const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
|
|
229
285
|
if (reply === false) {
|
|
@@ -288,6 +344,18 @@ export async function runAgent(history, opts) {
|
|
|
288
344
|
}
|
|
289
345
|
await flush();
|
|
290
346
|
history.push({ role: "tool", results });
|
|
347
|
+
if (breakerHalt) {
|
|
348
|
+
// A tripped-and-declined circuit-breaker is a hard stop: end the run cleanly (the denial messages are
|
|
349
|
+
// already in `results` so the model/user see why). Never spin further.
|
|
350
|
+
if (!opts.quiet) {
|
|
351
|
+
const note = "⛔ guardian circuit-breaker: run halted (too many high-risk actions blocked). Review and re-run.";
|
|
352
|
+
if (sink)
|
|
353
|
+
sink.notice(note);
|
|
354
|
+
else
|
|
355
|
+
out(c.red(`${note}\n`));
|
|
356
|
+
}
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
291
359
|
if (guard && !nudged) {
|
|
292
360
|
for (const p of plans)
|
|
293
361
|
if (p.tool && p.tool.kind !== "read")
|
package/dist/config.js
CHANGED
|
@@ -10,9 +10,27 @@ const PROVIDER_DEFAULTS = {
|
|
|
10
10
|
},
|
|
11
11
|
"qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
|
|
12
12
|
openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
|
|
13
|
+
// GLM / DeepSeek / OpenRouter are OpenAI-compatible: buildProvider routes them through the
|
|
14
|
+
// openai path (createOpenAIProvider) using the preset baseURL below. The preset baseURL is
|
|
15
|
+
// applied by loadConfig (merged.baseURL ?? d.baseURL), so the setup wizard never asks for a URL.
|
|
16
|
+
glm: {
|
|
17
|
+
model: "glm-4.6",
|
|
18
|
+
baseURL: "https://open.bigmodel.cn/api/paas/v4",
|
|
19
|
+
envKey: "GLM_API_KEY",
|
|
20
|
+
},
|
|
21
|
+
deepseek: {
|
|
22
|
+
model: "deepseek-chat",
|
|
23
|
+
baseURL: "https://api.deepseek.com",
|
|
24
|
+
envKey: "DEEPSEEK_API_KEY",
|
|
25
|
+
},
|
|
26
|
+
openrouter: {
|
|
27
|
+
model: "openai/gpt-4o-mini",
|
|
28
|
+
baseURL: "https://openrouter.ai/api/v1",
|
|
29
|
+
envKey: "OPENROUTER_API_KEY",
|
|
30
|
+
},
|
|
13
31
|
"hara-gateway": { model: "", envKey: "HARA_GATEWAY_TOKEN" }, // B-end: enrolled device → token in ~/.hara/org.json, routed by the gateway
|
|
14
32
|
};
|
|
15
|
-
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
33
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "routeModel", "routeBaseURL", "routeApiKey", "guardian", "notify", "vimMode", "autoCompact", "fileCheckpoints", "fallbackModel", "fallbackBaseURL", "fallbackApiKey", "reasoningEffort"];
|
|
16
34
|
export const REASONING_EFFORTS = ["off", "low", "medium", "high"];
|
|
17
35
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
18
36
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
@@ -133,6 +151,9 @@ export function loadConfig(opts = {}) {
|
|
|
133
151
|
...(overlay.mcpServers ?? {}),
|
|
134
152
|
};
|
|
135
153
|
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
154
|
+
// Guardian: default ON; env HARA_GUARDIAN=0/off/false or config guardian:"off" disables it.
|
|
155
|
+
const guardianRaw = process.env.HARA_GUARDIAN ?? merged.guardian;
|
|
156
|
+
const guardian = guardianRaw === "0" || guardianRaw === "off" || guardianRaw === "false" ? "off" : "on";
|
|
136
157
|
const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
|
|
137
158
|
const vimMode = process.env.HARA_VIM === "1" || merged.vimMode === true || merged.vimMode === "true";
|
|
138
159
|
const autoCompact = !(process.env.HARA_AUTO_COMPACT === "0" || merged.autoCompact === false || merged.autoCompact === "false"); // default ON
|
|
@@ -144,8 +165,13 @@ export function loadConfig(opts = {}) {
|
|
|
144
165
|
const reasoningEffort = reasoningRaw && ["off", "low", "medium", "high"].includes(reasoningRaw)
|
|
145
166
|
? reasoningRaw
|
|
146
167
|
: undefined;
|
|
147
|
-
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
168
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, routeModel, routeBaseURL, routeApiKey, guardian, hooks, notify, vimMode, autoCompact, fileCheckpoints, fallbackModel, fallbackBaseURL, fallbackApiKey, reasoningEffort, mcpServers, cwd: process.cwd() };
|
|
148
169
|
}
|
|
149
170
|
export function providerEnvKey(provider) {
|
|
150
171
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
|
151
172
|
}
|
|
173
|
+
/** Preset base URL for a provider (undefined for anthropic/openai which use their SDK defaults).
|
|
174
|
+
* Used by `hara setup` to write a self-contained baseURL for GLM/DeepSeek/OpenRouter. */
|
|
175
|
+
export function providerDefaultBaseURL(provider) {
|
|
176
|
+
return PROVIDER_DEFAULTS[provider]?.baseURL;
|
|
177
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
|
14
14
|
import { homedir, tmpdir } from "node:os";
|
|
15
15
|
import { fileURLToPath } from "node:url";
|
|
16
16
|
import { dirname, join, relative } from "node:path";
|
|
17
|
-
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
|
|
17
|
+
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
|
|
18
18
|
import { runAgent } from "./agent/loop.js";
|
|
19
19
|
import { notifyDone } from "./notify.js";
|
|
20
20
|
import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
|
|
@@ -69,6 +69,7 @@ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge b
|
|
|
69
69
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
70
70
|
import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
|
|
71
71
|
import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
|
|
72
|
+
import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
|
|
72
73
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
73
74
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
74
75
|
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
@@ -107,7 +108,10 @@ async function buildProvider(cfg) {
|
|
|
107
108
|
// override (so `--profile`/`HARA_PROFILE`-overridden values flow + sidecar provider builds work).
|
|
108
109
|
const provider = (cfg.provider && cfg.provider !== "hara-gateway" ? cfg.provider : ap.provider) || "anthropic";
|
|
109
110
|
const apiKey = cfg.apiKey ?? ap.apiKey;
|
|
110
|
-
|
|
111
|
+
// Resolve base URL: explicit (cfg → profile) wins; otherwise fall back to the provider's preset
|
|
112
|
+
// (GLM/DeepSeek/OpenRouter). This keeps `profile add --byok --provider glm` working even when the
|
|
113
|
+
// user didn't pass --base-url (anthropic/openai stay undefined → their SDK defaults).
|
|
114
|
+
const baseURL = cfg.baseURL ?? ap.baseURL ?? providerDefaultBaseURL(provider);
|
|
111
115
|
const model = cfg.model || effectiveModel(ap);
|
|
112
116
|
if (provider === "qwen-oauth") {
|
|
113
117
|
const auth = await getValidQwenAuth();
|
|
@@ -131,6 +135,19 @@ async function withRouting(primary, cfg) {
|
|
|
131
135
|
const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
|
|
132
136
|
return alt ? routingProvider(primary, alt) : primary;
|
|
133
137
|
}
|
|
138
|
+
/** Guardian veto model: the CHEAP tier if `routeModel` is configured (a small classifier call, not real
|
|
139
|
+
* work), else the primary provider. Never blocks startup — any build failure just yields the fallback (and
|
|
140
|
+
* the guardian fails open when even that is absent). Returns the `{ provider, enabled }` shape runAgent wants,
|
|
141
|
+
* or undefined when guardian is off in config/env. */
|
|
142
|
+
async function buildGuardian(cfg, primary) {
|
|
143
|
+
if (cfg.guardian === "off")
|
|
144
|
+
return undefined;
|
|
145
|
+
let gp = primary;
|
|
146
|
+
if (cfg.routeModel && cfg.routeModel !== cfg.model) {
|
|
147
|
+
gp = (await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey })) ?? primary;
|
|
148
|
+
}
|
|
149
|
+
return { provider: gp, enabled: true };
|
|
150
|
+
}
|
|
134
151
|
function authHint(cfg) {
|
|
135
152
|
const ap = loadActiveProfile();
|
|
136
153
|
if (ap.kind === "gateway")
|
|
@@ -140,8 +157,121 @@ function authHint(cfg) {
|
|
|
140
157
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
141
158
|
return `Set ${c.bold(providerEnvKey(provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
|
|
142
159
|
}
|
|
143
|
-
const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
|
|
144
|
-
/**
|
|
160
|
+
const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", glm: "glm-4.6", deepseek: "deepseek-chat", openrouter: "openai/gpt-4o-mini", "qwen-oauth": "coder-model" };
|
|
161
|
+
/** Numbered provider menu for `hara setup`. Order is the displayed order; `id` maps to a ProviderId
|
|
162
|
+
* (or the special "custom"/"qwen-oauth" routes). GLM/DeepSeek carry a preset base URL so the user
|
|
163
|
+
* never types one; "custom" prompts for an OpenAI-compatible base URL. */
|
|
164
|
+
const SETUP_MENU = [
|
|
165
|
+
{ label: "Anthropic", id: "anthropic" },
|
|
166
|
+
{ label: "OpenAI", id: "openai" },
|
|
167
|
+
{ label: "GLM (Zhipu)", id: "glm" },
|
|
168
|
+
{ label: "DeepSeek", id: "deepseek" },
|
|
169
|
+
{ label: "Qwen (DashScope key)", id: "qwen" },
|
|
170
|
+
{ label: "OpenAI-compatible (custom base URL)", id: "custom" },
|
|
171
|
+
{ label: "Qwen — free, no key (browser sign-in)", id: "qwen-oauth" },
|
|
172
|
+
];
|
|
173
|
+
/** Read a secret from the TTY without echoing it (shows `*` per char). Falls back to a plain
|
|
174
|
+
* readline question when stdin isn't a raw-capable TTY (piped input / odd terminals) so scripted
|
|
175
|
+
* `printf 'key\n' | hara setup` still works. Handles backspace, Enter, and Ctrl-C/Ctrl-D. */
|
|
176
|
+
function readSecret(prompt, rl) {
|
|
177
|
+
const input = stdin;
|
|
178
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
179
|
+
// Non-TTY (piped/scripted): can't suppress echo at the terminal level; read it plainly.
|
|
180
|
+
return rl.question(prompt);
|
|
181
|
+
}
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
stdout.write(prompt);
|
|
184
|
+
let buf = "";
|
|
185
|
+
const prevRaw = input.isRaw ?? false;
|
|
186
|
+
// Pause the readline interface so it doesn't also consume keystrokes / echo while we read raw.
|
|
187
|
+
// We restore it in cleanup() before the next rl.question() runs.
|
|
188
|
+
rl.pause();
|
|
189
|
+
input.setRawMode(true);
|
|
190
|
+
input.resume();
|
|
191
|
+
const onData = (chunk) => {
|
|
192
|
+
const s = chunk.toString("utf8");
|
|
193
|
+
for (const ch of s) {
|
|
194
|
+
const code = ch.charCodeAt(0);
|
|
195
|
+
if (ch === "\r" || ch === "\n") {
|
|
196
|
+
cleanup();
|
|
197
|
+
stdout.write("\n");
|
|
198
|
+
resolve(buf);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
else if (code === 3) {
|
|
202
|
+
// Ctrl-C → abort the wizard (mirror readline's SIGINT behavior).
|
|
203
|
+
cleanup();
|
|
204
|
+
stdout.write("\n");
|
|
205
|
+
reject(new Error("cancelled"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
else if (code === 4) {
|
|
209
|
+
// Ctrl-D → end of input; resolve with whatever we have.
|
|
210
|
+
cleanup();
|
|
211
|
+
stdout.write("\n");
|
|
212
|
+
resolve(buf);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
else if (code === 127 || code === 8) {
|
|
216
|
+
// Backspace/Delete.
|
|
217
|
+
if (buf.length) {
|
|
218
|
+
buf = buf.slice(0, -1);
|
|
219
|
+
stdout.write("\b \b");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (code >= 32) {
|
|
223
|
+
buf += ch;
|
|
224
|
+
stdout.write("*");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
input.removeListener("data", onData);
|
|
230
|
+
try {
|
|
231
|
+
input.setRawMode(prevRaw);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* best-effort */
|
|
235
|
+
}
|
|
236
|
+
// Hand control back to readline for the next prompt (model question).
|
|
237
|
+
rl.resume();
|
|
238
|
+
};
|
|
239
|
+
input.on("data", onData);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** One-shot validation ping: build the provider exactly as the runtime would (anthropic vs the
|
|
243
|
+
* OpenAI-compatible path with the resolved base URL) and send a tiny prompt with a short timeout.
|
|
244
|
+
* Never throws — returns true on a clean turn, false on any error/timeout. Used only to print a
|
|
245
|
+
* friendly "connected" hint; the wizard saves config regardless. */
|
|
246
|
+
async function pingProvider(args) {
|
|
247
|
+
const { provider, apiKey, model, baseURL } = args;
|
|
248
|
+
if (!apiKey || !model)
|
|
249
|
+
return false;
|
|
250
|
+
const prov = provider === "anthropic"
|
|
251
|
+
? createAnthropicProvider({ apiKey, model, baseURL })
|
|
252
|
+
: createOpenAIProvider({ apiKey, model, baseURL, label: provider });
|
|
253
|
+
const ctrl = new AbortController();
|
|
254
|
+
const timer = setTimeout(() => ctrl.abort(), 12_000);
|
|
255
|
+
try {
|
|
256
|
+
const r = await prov.turn({
|
|
257
|
+
system: "Reply with the single word: ok",
|
|
258
|
+
history: [{ role: "user", content: "ping" }],
|
|
259
|
+
tools: [],
|
|
260
|
+
onText: () => { },
|
|
261
|
+
signal: ctrl.signal,
|
|
262
|
+
});
|
|
263
|
+
return r.stop !== "error";
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
/** Interactive first-run setup: pick a provider (numbered menu), API key (masked), and model →
|
|
273
|
+
* ~/.hara/config.json. GLM/DeepSeek/OpenRouter and "custom" route through the OpenAI-compatible
|
|
274
|
+
* path; "Qwen free" routes to the device-login flow. Storage model is unchanged (config.json 0600). */
|
|
145
275
|
async function runSetup() {
|
|
146
276
|
if (!stdin.isTTY) {
|
|
147
277
|
out(c.yellow("`hara setup` is interactive — run it in a terminal, or use `hara config set <key> <value>` in scripts.\n"));
|
|
@@ -150,14 +280,42 @@ async function runSetup() {
|
|
|
150
280
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
151
281
|
try {
|
|
152
282
|
out(c.bold("hara setup") + c.dim(" — configure a provider, key, and model (Ctrl-C to cancel)\n\n"));
|
|
153
|
-
|
|
283
|
+
SETUP_MENU.forEach((m, i) => out(` ${c.bold(String(i + 1))}) ${m.label}\n`));
|
|
284
|
+
out("\n");
|
|
285
|
+
const pick = (await rl.question(`Provider [1]: `)).trim() || "1";
|
|
286
|
+
const idx = Number.parseInt(pick, 10);
|
|
287
|
+
const choice = Number.isInteger(idx) && idx >= 1 && idx <= SETUP_MENU.length ? SETUP_MENU[idx - 1] : SETUP_MENU[0];
|
|
288
|
+
// Route 7: Qwen free device login — no key, no model prompt. Reuse the existing flow + config writes.
|
|
289
|
+
if (choice.id === "qwen-oauth") {
|
|
290
|
+
try {
|
|
291
|
+
await qwenDeviceLogin((m) => out(m + "\n"));
|
|
292
|
+
writeConfigValue("provider", "qwen-oauth");
|
|
293
|
+
writeConfigValue("model", "coder-model");
|
|
294
|
+
out(c.green("\n✓ Qwen OAuth complete — provider set to qwen-oauth (model coder-model).\n") + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
out(c.red(`\nQwen OAuth failed: ${e?.message ?? e}\n`));
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Resolve the concrete provider id + base URL. "custom" = OpenAI-compatible: ask for the base
|
|
302
|
+
// URL and store the chosen provider as "openai" (the generic OpenAI-compatible dispatch).
|
|
303
|
+
let provider;
|
|
154
304
|
let baseURL = "";
|
|
155
|
-
if (
|
|
156
|
-
|
|
305
|
+
if (choice.id === "custom") {
|
|
306
|
+
provider = "openai";
|
|
307
|
+
baseURL = (await rl.question(`Base URL ${c.dim("(OpenAI-compatible endpoint, e.g. https://your-host/v1)")}: `)).trim();
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
provider = choice.id;
|
|
311
|
+
// GLM/DeepSeek/OpenRouter carry a preset base URL (PROVIDER_DEFAULTS) — written explicitly so
|
|
312
|
+
// the personal profile is self-contained. anthropic/openai use their built-in defaults.
|
|
313
|
+
baseURL = providerDefaultBaseURL(provider) ?? "";
|
|
157
314
|
}
|
|
158
315
|
const envKey = providerEnvKey(provider);
|
|
159
|
-
const apiKey = (await
|
|
160
|
-
const
|
|
316
|
+
const apiKey = (await readSecret(`API key ${c.dim(`(masked; blank = use the ${envKey} env var)`)}: `, rl)).trim();
|
|
317
|
+
const defaultModel = SETUP_DEFAULT_MODEL[choice.id === "custom" ? "openai" : provider] ?? "";
|
|
318
|
+
const model = (await rl.question(`Model [${defaultModel || "?"}]: `)).trim() || defaultModel;
|
|
161
319
|
writeConfigValue("provider", provider);
|
|
162
320
|
if (baseURL)
|
|
163
321
|
writeConfigValue("baseURL", baseURL);
|
|
@@ -165,8 +323,20 @@ async function runSetup() {
|
|
|
165
323
|
writeConfigValue("apiKey", apiKey);
|
|
166
324
|
if (model)
|
|
167
325
|
writeConfigValue("model", model);
|
|
326
|
+
// One-shot validation ping (best-effort; never blocks saving). Only when we have a key + model.
|
|
327
|
+
if (apiKey && model) {
|
|
328
|
+
out(c.dim("\nChecking connection… "));
|
|
329
|
+
const ok = await pingProvider({ provider, apiKey, model, baseURL: baseURL || undefined });
|
|
330
|
+
out(ok ? c.green("✓ connected\n") : c.yellow(`⚠ couldn't reach ${provider} (saved anyway)\n`));
|
|
331
|
+
}
|
|
168
332
|
out(c.green(`\n✓ saved to ${configPath()}\n`) + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
|
|
169
333
|
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
if (e?.message === "cancelled")
|
|
336
|
+
out(c.dim("\n(cancelled)\n"));
|
|
337
|
+
else
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
170
340
|
finally {
|
|
171
341
|
rl.close();
|
|
172
342
|
}
|
|
@@ -1050,7 +1220,7 @@ profileCmd
|
|
|
1050
1220
|
.option("--code <code>", "(gateway) enrollment code from your admin")
|
|
1051
1221
|
.option("--label <label>", "human-friendly label for the profile")
|
|
1052
1222
|
.option("--byok", "(byok) BYOK profile — bring your own provider key")
|
|
1053
|
-
.option("--provider <id>", "(byok) anthropic |
|
|
1223
|
+
.option("--provider <id>", "(byok) anthropic | openai | glm | deepseek | openrouter | qwen | qwen-oauth")
|
|
1054
1224
|
.option("--key <key>", "(byok) API key (else read from the provider's env var at use-time)")
|
|
1055
1225
|
.option("--base-url <url>", "(byok) override the provider base URL (OpenAI-compatible endpoints)")
|
|
1056
1226
|
.option("--model <model>", "(byok) default model for this profile")
|
|
@@ -1109,7 +1279,7 @@ profileCmd
|
|
|
1109
1279
|
out(c.dim(`Switch to it with \`hara profile use ${id}\`.\n`));
|
|
1110
1280
|
return;
|
|
1111
1281
|
}
|
|
1112
|
-
out(c.red("usage:\n") + c.dim(" hara profile add <id> --gateway <url> --code <code> [--label …]\n") + c.dim(" hara profile add <id> --byok --provider anthropic|
|
|
1282
|
+
out(c.red("usage:\n") + c.dim(" hara profile add <id> --gateway <url> --code <code> [--label …]\n") + c.dim(" hara profile add <id> --byok --provider anthropic|openai|glm|deepseek|openrouter|qwen|qwen-oauth [--key … --base-url … --model …]\n"));
|
|
1113
1283
|
process.exit(1);
|
|
1114
1284
|
});
|
|
1115
1285
|
profileCmd
|
|
@@ -1847,6 +2017,7 @@ program.action(async (opts) => {
|
|
|
1847
2017
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1848
2018
|
const fallbackProvider = provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model ? await buildProvider({ ...cfg, model: cfg.fallbackModel, baseURL: cfg.fallbackBaseURL ?? cfg.baseURL, apiKey: cfg.fallbackApiKey ?? cfg.apiKey }) : null;
|
|
1849
2019
|
const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
|
|
2020
|
+
const guardianOpt = await buildGuardian(cfg, provider0); // internal safety layer (high-risk actions only)
|
|
1850
2021
|
if (!provider0) {
|
|
1851
2022
|
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1852
2023
|
if (stdin.isTTY && !opts.print) {
|
|
@@ -1994,6 +2165,7 @@ program.action(async (opts) => {
|
|
|
1994
2165
|
projectContext,
|
|
1995
2166
|
memory: memoryDigest(cwd),
|
|
1996
2167
|
stats,
|
|
2168
|
+
guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
|
|
1997
2169
|
});
|
|
1998
2170
|
if (meta) {
|
|
1999
2171
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
@@ -2022,6 +2194,22 @@ program.action(async (opts) => {
|
|
|
2022
2194
|
},
|
|
2023
2195
|
});
|
|
2024
2196
|
const confirm = async (q) => (await rl.question(`${q} ${c.dim("[y/N]")} `)).trim().toLowerCase().startsWith("y");
|
|
2197
|
+
// ask_user (classic REPL): print the question + a numbered menu (matching the setup menu look) and read the
|
|
2198
|
+
// answer through the SAME rl.question channel confirm uses. A bare option number selects it; any other text
|
|
2199
|
+
// is taken as a free-text answer — so the user can always type their own response.
|
|
2200
|
+
const askUser = async (q, options) => {
|
|
2201
|
+
out(c.bold("\n? ") + q + "\n");
|
|
2202
|
+
const opts = (options ?? []).map((o) => o.trim()).filter(Boolean);
|
|
2203
|
+
opts.forEach((o, i) => out(` ${c.bold(String(i + 1))}) ${o}\n`));
|
|
2204
|
+
const hint = opts.length ? c.dim(`(1-${opts.length} to pick, or type your own answer) `) : c.dim("(type your answer) ");
|
|
2205
|
+
const raw = (await rl.question(`${c.cyan("›")} ${hint}`)).trim();
|
|
2206
|
+
if (opts.length) {
|
|
2207
|
+
const n = Number.parseInt(raw, 10);
|
|
2208
|
+
if (Number.isInteger(n) && n >= 1 && n <= opts.length)
|
|
2209
|
+
return opts[n - 1];
|
|
2210
|
+
}
|
|
2211
|
+
return raw;
|
|
2212
|
+
};
|
|
2025
2213
|
// shift+tab cycles the approval mode (classic REPL only; the TUI handles its own keys).
|
|
2026
2214
|
// Bare /approval is the reliable fallback everywhere.
|
|
2027
2215
|
if (stdin.isTTY && !useTui) {
|
|
@@ -2804,7 +2992,7 @@ program.action(async (opts) => {
|
|
|
2804
2992
|
// fall back to "suggest" so we keep the user's confirm gate without crashing the type check.
|
|
2805
2993
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
2806
2994
|
try {
|
|
2807
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt });
|
|
2995
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
2808
2996
|
}
|
|
2809
2997
|
catch (e) {
|
|
2810
2998
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
@@ -2850,7 +3038,7 @@ program.action(async (opts) => {
|
|
|
2850
3038
|
const pout = stats.output;
|
|
2851
3039
|
await runAgent(history, {
|
|
2852
3040
|
provider,
|
|
2853
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3041
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2854
3042
|
approval: "suggest",
|
|
2855
3043
|
confirm: h.confirm,
|
|
2856
3044
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
@@ -2879,7 +3067,7 @@ program.action(async (opts) => {
|
|
|
2879
3067
|
const xout = stats.output;
|
|
2880
3068
|
await runAgent(history, {
|
|
2881
3069
|
provider,
|
|
2882
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3070
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2883
3071
|
approval: choice,
|
|
2884
3072
|
memory: buildMemory(),
|
|
2885
3073
|
confirm: h.confirm,
|
|
@@ -2888,6 +3076,7 @@ program.action(async (opts) => {
|
|
|
2888
3076
|
stats,
|
|
2889
3077
|
signal: h.signal,
|
|
2890
3078
|
pendingInput,
|
|
3079
|
+
guardian: guardianOpt,
|
|
2891
3080
|
});
|
|
2892
3081
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
2893
3082
|
saveSession(meta, history);
|
|
@@ -2907,7 +3096,7 @@ program.action(async (opts) => {
|
|
|
2907
3096
|
const beforeOut = stats.output;
|
|
2908
3097
|
await runAgent(history, {
|
|
2909
3098
|
provider,
|
|
2910
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3099
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2911
3100
|
approval: appr,
|
|
2912
3101
|
memory: buildMemory(),
|
|
2913
3102
|
confirm: h.confirm,
|
|
@@ -2917,6 +3106,7 @@ program.action(async (opts) => {
|
|
|
2917
3106
|
signal: h.signal,
|
|
2918
3107
|
pendingInput,
|
|
2919
3108
|
fallback: fbOpt,
|
|
3109
|
+
guardian: guardianOpt,
|
|
2920
3110
|
});
|
|
2921
3111
|
if (!meta.title) {
|
|
2922
3112
|
meta.title = await nameSession(provider, history);
|
|
@@ -2969,7 +3159,7 @@ program.action(async (opts) => {
|
|
|
2969
3159
|
});
|
|
2970
3160
|
currentTurn = new AbortController();
|
|
2971
3161
|
try {
|
|
2972
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
|
|
3162
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
2973
3163
|
}
|
|
2974
3164
|
catch (e) {
|
|
2975
3165
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -3000,7 +3190,7 @@ program.action(async (opts) => {
|
|
|
3000
3190
|
currentTurn = new AbortController();
|
|
3001
3191
|
const t0 = Date.now();
|
|
3002
3192
|
try {
|
|
3003
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
|
|
3193
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
3004
3194
|
}
|
|
3005
3195
|
catch (e) {
|
|
3006
3196
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Guardian — an internal safety layer that sits ON TOP of the existing stack (permission rules →
|
|
2
|
+
// PreToolUse hooks → approval gate → soft stuck-guard). It exists for the one failure mode those don't
|
|
3
|
+
// cover: a genuinely dangerous, irreversible action that slips through (no deny rule, auto-approved mode,
|
|
4
|
+
// a cron/gateway run with `confirm: () => true`). Two moving parts:
|
|
5
|
+
//
|
|
6
|
+
// 1. A DETERMINISTIC risk classifier that runs FIRST and is the whole point of "zero latency on normal
|
|
7
|
+
// work": read tools, in-project edits, and ordinary shell commands are classified `low` in pure Node
|
|
8
|
+
// (no LLM, no I/O) and skip the guardian entirely. Only a handful of genuinely destructive shapes
|
|
9
|
+
// (rm -rf, dd, mkfs, curl|sh, sudo, force-push, broad chmod -R, writes outside the project root, …)
|
|
10
|
+
// are classified `high` and escalate.
|
|
11
|
+
// 2. A conservative LLM veto that runs ONLY on `high` actions: it asks a cheap model "is this action
|
|
12
|
+
// clearly dangerous or clearly off-task?" and defaults to ALLOW. Any glitch (timeout, error, no model
|
|
13
|
+
// configured) FAILS OPEN — a guardian hiccup must never break legitimate work, because the permission
|
|
14
|
+
// rules + the user's own approval gate still apply independently.
|
|
15
|
+
//
|
|
16
|
+
// Plus a deterministic circuit-breaker: N guardian BLOCKS in one run (or an escalating-destructive runaway)
|
|
17
|
+
// trips a hard stop that requires explicit user confirmation to continue — a harder stop than the soft
|
|
18
|
+
// stuck-guard nudge, and one that aborts safely (never hangs) when there's no interactive user.
|
|
19
|
+
import { resolve, isAbsolute } from "node:path";
|
|
20
|
+
import { canonicalize, splitCompound } from "./permissions.js";
|
|
21
|
+
// ── Deterministic risk classifier ────────────────────────────────────────────────────────────────────
|
|
22
|
+
// The classifier is intentionally NARROW: false positives here cost latency + an LLM call + potential
|
|
23
|
+
// annoyance, so we only flag shapes that are destructive/irreversible in practice. Everything else is `low`.
|
|
24
|
+
// A path is "broad" (a whole tree / outside the sandbox) when it's `/`, a top-level system dir, a home dir,
|
|
25
|
+
// `.`/`*`/`~`, or absolute-outside-cwd. `chmod -R`/`chown -R`/`rm -rf` on a broad path is the danger, not on
|
|
26
|
+
// a project-local subdir.
|
|
27
|
+
const SYSTEM_ROOTS = /^(\/|\/(bin|boot|dev|etc|home|lib|opt|proc|root|sbin|srv|sys|usr|var|Users|Applications|System|Library|Volumes)(\/|$)|~|\$HOME)/;
|
|
28
|
+
/** Genuinely destructive/irreversible single-command shapes (checked against the canonical form of each
|
|
29
|
+
* part of a compound command). Kept tight — see the module header. */
|
|
30
|
+
function isDestructiveCommand(canonical) {
|
|
31
|
+
const c = canonical;
|
|
32
|
+
if (!c)
|
|
33
|
+
return false;
|
|
34
|
+
// rm -rf / rm -fr / recursive-forced removal (the classic).
|
|
35
|
+
if (/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)\b/.test(c))
|
|
36
|
+
return true;
|
|
37
|
+
// rm -r/-rf targeting a broad/system path (rm -rf / , rm -rf ~ , rm -r /etc …).
|
|
38
|
+
if (/\brm\s+-[a-z]*r/.test(c) && /\brm\s+-[a-z]*r[a-z]*\s+(\/|~|\$HOME|\*|\.\s|\.$)/.test(c))
|
|
39
|
+
return true;
|
|
40
|
+
// Raw-disk / filesystem-destroying tools.
|
|
41
|
+
if (/\b(dd)\b/.test(c) && /\bof=\/dev\//.test(c))
|
|
42
|
+
return true;
|
|
43
|
+
if (/\b(mkfs(\.\w+)?|mke2fs|fdisk|parted|wipefs|shred)\b/.test(c))
|
|
44
|
+
return true;
|
|
45
|
+
if (/\bdiskutil\s+(erase|reformat|partitionDisk)/i.test(c))
|
|
46
|
+
return true;
|
|
47
|
+
// Fork bomb.
|
|
48
|
+
if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c) || /\.\(\)\{\s*\.\|\.&\s*\}/.test(c))
|
|
49
|
+
return true;
|
|
50
|
+
// Pipe-to-shell from the network (curl … | sh, wget … | bash) — arbitrary remote code execution.
|
|
51
|
+
if (/\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/.test(c))
|
|
52
|
+
return true;
|
|
53
|
+
// Privilege escalation.
|
|
54
|
+
if (/\bsudo\b/.test(c))
|
|
55
|
+
return true;
|
|
56
|
+
if (/\bkillall\b/.test(c))
|
|
57
|
+
return true;
|
|
58
|
+
// Force-push (rewrites shared history irreversibly).
|
|
59
|
+
if (/\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|(^|\s)-f\b)/.test(c))
|
|
60
|
+
return true;
|
|
61
|
+
// Broad recursive permission/ownership change.
|
|
62
|
+
if (/\b(chmod|chown|chgrp)\s+(-[a-z]*R|--recursive)/.test(c)) {
|
|
63
|
+
if (SYSTEM_ROOTS.test(commandTarget(c)) || /\s(\/|~|\$HOME|\*|\.)(\s|$)/.test(c))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// Destructive git tree resets / clean.
|
|
67
|
+
if (/\bgit\s+clean\b[^\n]*-[a-z]*f/.test(c) && /-[a-z]*d/.test(c))
|
|
68
|
+
return true;
|
|
69
|
+
// History overwrite: `> /path` (truncation) onto a system/absolute-outside path is handled by the
|
|
70
|
+
// out-of-project write check below (redirection carries a path); nothing extra here.
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
/** The last whitespace token that looks like a path target of a command (best-effort, for chmod/chown). */
|
|
74
|
+
function commandTarget(canonical) {
|
|
75
|
+
const toks = canonical.split(/\s+/).filter((t) => t && !t.startsWith("-"));
|
|
76
|
+
return toks[toks.length - 1] ?? "";
|
|
77
|
+
}
|
|
78
|
+
/** Extract `> file` / `>> file` redirection targets from a canonical command (quote-naive; classifier only). */
|
|
79
|
+
function redirectionTargets(canonical) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const re = />>?\s*("([^"]*)"|'([^']*)'|([^\s;|&]+))/g;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(canonical))) {
|
|
84
|
+
const p = m[2] ?? m[3] ?? m[4];
|
|
85
|
+
if (p && p !== "/dev/null" && !p.startsWith("&") && !/^\d$/.test(p))
|
|
86
|
+
out.push(p);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** True if `p` resolves OUTSIDE the project/cwd root (a write/delete escaping the sandbox). Non-file
|
|
91
|
+
* pseudo-paths (/dev/null) and empties are treated as in-scope. */
|
|
92
|
+
export function isOutsideRoot(p, cwd) {
|
|
93
|
+
if (!p || p === "/dev/null" || p === "-")
|
|
94
|
+
return false;
|
|
95
|
+
const expanded = p.replace(/^~(?=\/|$)/, process.env.HOME ?? "~");
|
|
96
|
+
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
97
|
+
const root = resolve(cwd);
|
|
98
|
+
return abs !== root && !abs.startsWith(root + "/");
|
|
99
|
+
}
|
|
100
|
+
/** Collect the file paths an edit-kind tool would write/delete, from its tool input. */
|
|
101
|
+
export function editPaths(name, input) {
|
|
102
|
+
const paths = [];
|
|
103
|
+
if (typeof input.path === "string")
|
|
104
|
+
paths.push(input.path);
|
|
105
|
+
// apply_patch: { changes: [{ path, ... }] }
|
|
106
|
+
if (Array.isArray(input.changes)) {
|
|
107
|
+
for (const ch of input.changes)
|
|
108
|
+
if (ch && typeof ch.path === "string")
|
|
109
|
+
paths.push(ch.path);
|
|
110
|
+
}
|
|
111
|
+
return paths;
|
|
112
|
+
}
|
|
113
|
+
/** Deterministic risk classifier. Runs FIRST, in pure Node — this is what keeps normal work at zero cost.
|
|
114
|
+
* Returns `low` for read tools, in-project edits, and ordinary shell commands (→ guardian is skipped
|
|
115
|
+
* entirely); `high` only for destructive/irreversible bash or writes/deletes outside the project root. */
|
|
116
|
+
export function classifyRisk(toolName, toolKind, input, cwd) {
|
|
117
|
+
if (toolKind === "read" || !input)
|
|
118
|
+
return { level: "low", reason: "" };
|
|
119
|
+
if (toolKind === "edit") {
|
|
120
|
+
for (const p of editPaths(toolName, input)) {
|
|
121
|
+
if (isOutsideRoot(p, cwd))
|
|
122
|
+
return { level: "high", reason: `writes/deletes outside the project root: ${p}` };
|
|
123
|
+
}
|
|
124
|
+
return { level: "low", reason: "" };
|
|
125
|
+
}
|
|
126
|
+
if (toolKind === "exec" && typeof input.command === "string") {
|
|
127
|
+
const command = input.command;
|
|
128
|
+
const whole = canonicalize(command);
|
|
129
|
+
// Whole-command check FIRST — catches cross-part shapes a compound split would hide (curl … | sh, where
|
|
130
|
+
// the danger is the pipe connecting two individually-benign parts).
|
|
131
|
+
if (isDestructiveCommand(whole))
|
|
132
|
+
return { level: "high", reason: `destructive/irreversible command: ${whole.slice(0, 120)}` };
|
|
133
|
+
// Then per-part: strictest part wins; if we can't safely parse it, fall back to the whole canonical form.
|
|
134
|
+
const parts = splitCompound(command) ?? [whole];
|
|
135
|
+
for (const part of parts) {
|
|
136
|
+
if (isDestructiveCommand(part))
|
|
137
|
+
return { level: "high", reason: `destructive/irreversible command: ${part.slice(0, 120)}` };
|
|
138
|
+
for (const t of redirectionTargets(part)) {
|
|
139
|
+
if (isOutsideRoot(t, cwd))
|
|
140
|
+
return { level: "high", reason: `writes outside the project root via redirection: ${t}` };
|
|
141
|
+
if (SYSTEM_ROOTS.test(t))
|
|
142
|
+
return { level: "high", reason: `writes to a system path: ${t}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { level: "low", reason: "" };
|
|
146
|
+
}
|
|
147
|
+
// computer / unknown non-read kinds are already gated hard elsewhere (always-confirm); leave them to that.
|
|
148
|
+
return { level: "low", reason: "" };
|
|
149
|
+
}
|
|
150
|
+
// ── LLM veto (conservative; high-risk only) ──────────────────────────────────────────────────────────
|
|
151
|
+
const GUARDIAN_SYSTEM = `You are a safety guardian for a coding agent. You are shown a SINGLE high-risk action the agent is about ` +
|
|
152
|
+
`to take, plus a short summary of the user's task. Decide whether to ALLOW or BLOCK it. Default to ALLOW. ` +
|
|
153
|
+
`Only BLOCK when the action is CLEARLY dangerous/irreversible AND not plausibly part of the stated task ` +
|
|
154
|
+
`(e.g. deleting unrelated files, wiping a disk, exfiltrating data, force-pushing over shared history when ` +
|
|
155
|
+
`the task never asked for it), or when it is CLEARLY misaligned with the task. If the action is a ` +
|
|
156
|
+
`reasonable step toward the task — even a destructive one the user likely wants — ALLOW it. When unsure, ` +
|
|
157
|
+
`ALLOW. Reply with ONLY compact JSON: {"decision":"allow"|"block","reason":"<one short sentence>"}.`;
|
|
158
|
+
/** Parse the model's reply into a verdict. Anything we can't confidently read as a BLOCK is treated as
|
|
159
|
+
* ALLOW (fail-open on ambiguity). */
|
|
160
|
+
export function parseVerdict(text) {
|
|
161
|
+
const raw = (text ?? "").trim();
|
|
162
|
+
// Try strict JSON first, then the first {...} blob.
|
|
163
|
+
const tryParse = (s) => {
|
|
164
|
+
try {
|
|
165
|
+
const j = JSON.parse(s);
|
|
166
|
+
if (j && (j.decision === "block" || j.decision === "allow")) {
|
|
167
|
+
return { decision: j.decision, reason: typeof j.reason === "string" ? j.reason : "" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* fall through */
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
};
|
|
175
|
+
const direct = tryParse(raw);
|
|
176
|
+
if (direct)
|
|
177
|
+
return direct;
|
|
178
|
+
const m = /\{[\s\S]*\}/.exec(raw);
|
|
179
|
+
if (m) {
|
|
180
|
+
const blob = tryParse(m[0]);
|
|
181
|
+
if (blob)
|
|
182
|
+
return blob;
|
|
183
|
+
}
|
|
184
|
+
// No parseable verdict → fail open.
|
|
185
|
+
return { decision: "allow", reason: "" };
|
|
186
|
+
}
|
|
187
|
+
/** A brief, safe task-context summary for the guardian prompt: the most recent genuine user message,
|
|
188
|
+
* truncated. Kept short (cheap call, and we don't want to leak the whole transcript). */
|
|
189
|
+
export function taskSummary(history) {
|
|
190
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
191
|
+
const m = history[i];
|
|
192
|
+
if (m.role === "user" && typeof m.content === "string" && m.content.trim()) {
|
|
193
|
+
return m.content.trim().replace(/\s+/g, " ").slice(0, 500);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return "(no task context available)";
|
|
197
|
+
}
|
|
198
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
199
|
+
/** Ask the cheap model to veto a single high-risk action. Conservative + fail-open:
|
|
200
|
+
* - no provider → allow (guardian effectively off; deterministic layers still apply)
|
|
201
|
+
* - timeout / error → allow (a guardian glitch must never break legit work)
|
|
202
|
+
* - unparseable reply → allow
|
|
203
|
+
* Only a clean, parsed `block` blocks. */
|
|
204
|
+
export async function guardianVeto(provider, action, history, opts = {}) {
|
|
205
|
+
if (!provider)
|
|
206
|
+
return { decision: "allow", reason: "" }; // fail-open: no model → deterministic layers still guard
|
|
207
|
+
const prompt = `TASK CONTEXT:\n${taskSummary(history)}\n\n` +
|
|
208
|
+
`HIGH-RISK ACTION (flagged: ${action.classifierReason}):\n` +
|
|
209
|
+
`tool: ${action.tool}\n${action.detail}\n\n` +
|
|
210
|
+
`Allow or block this action? Reply with only the JSON verdict.`;
|
|
211
|
+
const ac = new AbortController();
|
|
212
|
+
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
213
|
+
// Chain the caller's interrupt signal so Esc also aborts the guardian call.
|
|
214
|
+
if (opts.signal) {
|
|
215
|
+
if (opts.signal.aborted)
|
|
216
|
+
ac.abort();
|
|
217
|
+
else
|
|
218
|
+
opts.signal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const r = await provider.turn({
|
|
222
|
+
system: GUARDIAN_SYSTEM,
|
|
223
|
+
history: [{ role: "user", content: prompt }],
|
|
224
|
+
tools: [],
|
|
225
|
+
onText: () => { },
|
|
226
|
+
signal: ac.signal,
|
|
227
|
+
});
|
|
228
|
+
if (r.stop === "error")
|
|
229
|
+
return { decision: "allow", reason: "" }; // fail-open on model error
|
|
230
|
+
return parseVerdict(r.text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return { decision: "allow", reason: "" }; // fail-open on timeout/abort/throw
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function newBreaker() {
|
|
240
|
+
return { blocks: 0, tripped: false };
|
|
241
|
+
}
|
|
242
|
+
/** Record a guardian BLOCK; trip the breaker at the threshold. Returns the (possibly updated) state's
|
|
243
|
+
* tripped flag. Deterministic — a harder stop than the soft stuck-guard nudge. */
|
|
244
|
+
export function recordBlock(state, threshold = GUARDIAN_BLOCK_THRESHOLD) {
|
|
245
|
+
state.blocks += 1;
|
|
246
|
+
if (state.blocks >= threshold)
|
|
247
|
+
state.tripped = true;
|
|
248
|
+
return state.tripped;
|
|
249
|
+
}
|
|
250
|
+
export const GUARDIAN_BLOCK_THRESHOLD = 3;
|
|
251
|
+
// ── Config ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
/** Guardian is ON by default and only engages on high-risk actions (so normal turns are untouched).
|
|
253
|
+
* Off via `HARA_GUARDIAN=0` (or config key `guardian: "off"`). Any other value / unset → on. */
|
|
254
|
+
export function guardianEnabled(config) {
|
|
255
|
+
const env = process.env.HARA_GUARDIAN;
|
|
256
|
+
if (env !== undefined)
|
|
257
|
+
return !(env === "0" || env.toLowerCase() === "off" || env.toLowerCase() === "false");
|
|
258
|
+
if (config?.guardian !== undefined)
|
|
259
|
+
return !(config.guardian === "off" || config.guardian === "false");
|
|
260
|
+
return true; // default on
|
|
261
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ask_user — pause mid-turn to ask the user a structured question and continue with their answer.
|
|
2
|
+
// Mirrors Claude Code's AskUserQuestion / cc-haha + hermes `ask_user`: use it ONLY when genuinely blocked
|
|
3
|
+
// on a decision only the user can make (an ambiguous requirement, a real fork in approach) — never for
|
|
4
|
+
// anything you can derive from the code/context. The question (and optional numbered choices) is shown
|
|
5
|
+
// through the SAME input channel as the approval prompt (ctx.ask), so it works in both the classic REPL and
|
|
6
|
+
// the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
|
|
7
|
+
// tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
|
|
8
|
+
// itself triggers the approval gate (the interaction IS the prompt).
|
|
9
|
+
import { registerTool } from "./registry.js";
|
|
10
|
+
/** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
|
|
11
|
+
* keeps going on its own judgment rather than re-asking or stalling. */
|
|
12
|
+
export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
|
|
13
|
+
registerTool({
|
|
14
|
+
name: "ask_user",
|
|
15
|
+
description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
|
|
16
|
+
"Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
|
|
17
|
+
"requirement, a missing preference, or a real fork in approach where guessing wrong is costly. " +
|
|
18
|
+
"Do NOT use it for anything you can infer from the code, files, or context, and do NOT use it to narrate " +
|
|
19
|
+
"or ask permission for an action (the approval gate already handles that). " +
|
|
20
|
+
"Provide `options` (a short list of likely answers) when the choice is constrained — they are shown as a " +
|
|
21
|
+
"numbered menu — but the user may always type a free-text answer instead. The tool returns the user's " +
|
|
22
|
+
"answer (chosen option or free text) as its result. " +
|
|
23
|
+
"In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
|
|
24
|
+
"blocking, so prefer making a reasonable call over asking when context already answers the question.",
|
|
25
|
+
kind: "read", // the prompt itself is the interaction; never route it through the approval gate
|
|
26
|
+
input_schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
question: { type: "string", description: "the single, specific question to put to the user" },
|
|
30
|
+
options: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: { type: "string" },
|
|
33
|
+
description: "optional likely answers, shown as a numbered menu (the user may still type their own answer)",
|
|
34
|
+
},
|
|
35
|
+
header: { type: "string", description: "optional short label/topic for the question (e.g. 'Database choice')" },
|
|
36
|
+
context: { type: "string", description: "optional one-line context shown before the question (keep it short)" },
|
|
37
|
+
},
|
|
38
|
+
required: ["question"],
|
|
39
|
+
},
|
|
40
|
+
async run(input, ctx) {
|
|
41
|
+
const question = typeof input.question === "string" ? input.question.trim() : "";
|
|
42
|
+
if (!question)
|
|
43
|
+
return "ask_user needs a non-empty `question`.";
|
|
44
|
+
// No interactive user (headless / non-TTY / -p / gateway / sub-agent): do NOT block — let the model proceed.
|
|
45
|
+
if (typeof ctx.ask !== "function")
|
|
46
|
+
return NO_INTERACTIVE_USER;
|
|
47
|
+
const options = Array.isArray(input.options)
|
|
48
|
+
? input.options.map((o) => String(o ?? "").trim()).filter((o) => o.length > 0)
|
|
49
|
+
: undefined;
|
|
50
|
+
const header = typeof input.header === "string" ? input.header.trim() : "";
|
|
51
|
+
const context = typeof input.context === "string" ? input.context.trim() : "";
|
|
52
|
+
// Compose a compact prompt: [header] (context) question — the channel renders it.
|
|
53
|
+
const prompt = [header ? `[${header}] ` : "", context ? `${context}\n` : "", question].join("");
|
|
54
|
+
try {
|
|
55
|
+
const answer = await ctx.ask(prompt, options && options.length ? options : undefined);
|
|
56
|
+
const text = typeof answer === "string" ? answer.trim() : "";
|
|
57
|
+
return text || "(the user gave an empty answer)";
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// If the interactive prompt fails for any reason, degrade gracefully rather than crash the turn.
|
|
61
|
+
return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
package/dist/tui/App.js
CHANGED
|
@@ -217,6 +217,9 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
217
217
|
const [status, setStatus] = useState({ ...initialStatus, agents: 0 });
|
|
218
218
|
const [prompt, setPrompt] = useState(null);
|
|
219
219
|
const [promptSel, setPromptSel] = useState(0);
|
|
220
|
+
// Free-text question prompt (ask_user with no/declined options): re-enables the InputBox to capture one
|
|
221
|
+
// line, then resolves the awaiting tool with that text. Separate from `prompt` (the select-only path).
|
|
222
|
+
const [askText, setAskText] = useState(null);
|
|
220
223
|
const [reasoningOpen, setReasoningOpen] = useState(false);
|
|
221
224
|
const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
|
|
222
225
|
// Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
|
|
@@ -290,6 +293,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
290
293
|
}, [pushCurrent, noteVisionIfNeeded]);
|
|
291
294
|
const handleSubmit = useCallback(async (line, images) => {
|
|
292
295
|
const t = line.trim();
|
|
296
|
+
// A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
|
|
297
|
+
if (askText) {
|
|
298
|
+
const r = askText.resolve;
|
|
299
|
+
setAskText(null);
|
|
300
|
+
setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]);
|
|
301
|
+
r(t);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
293
304
|
if ((!t && !images?.length) || prompt)
|
|
294
305
|
return; // nothing to send, or a choice is pending
|
|
295
306
|
if (working) {
|
|
@@ -323,9 +334,25 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
323
334
|
{ label: "No (esc)", value: false, key: "n" },
|
|
324
335
|
]);
|
|
325
336
|
const selectFn = (title, options) => openPrompt(title, options);
|
|
337
|
+
// Free-text question: re-enable the InputBox to read one line (resolves via handleSubmit's askText branch).
|
|
338
|
+
const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
|
|
339
|
+
// ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
|
|
340
|
+
// when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
|
|
341
|
+
const OTHER = "__ask_other__"; // sentinel value for the "type my own" option
|
|
342
|
+
const askFn = async (question, options) => {
|
|
343
|
+
if (options && options.length) {
|
|
344
|
+
const choice = await openPrompt(question, [
|
|
345
|
+
...options.map((o) => ({ label: o, value: o })),
|
|
346
|
+
{ label: "✎ Type my own answer", value: OTHER },
|
|
347
|
+
]);
|
|
348
|
+
if (choice !== OTHER)
|
|
349
|
+
return choice;
|
|
350
|
+
}
|
|
351
|
+
return askTextFn(question);
|
|
352
|
+
};
|
|
326
353
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
327
354
|
try {
|
|
328
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
355
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
329
356
|
}
|
|
330
357
|
catch (e) {
|
|
331
358
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -357,11 +384,11 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
357
384
|
collapseTimerRef.current = null;
|
|
358
385
|
}, 30_000);
|
|
359
386
|
}
|
|
360
|
-
}, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
387
|
+
}, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
|
|
361
388
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
362
389
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
363
390
|
useEffect(() => {
|
|
364
|
-
if (working || prompt || drainingRef.current || !queueRef.current.length)
|
|
391
|
+
if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
|
|
365
392
|
return;
|
|
366
393
|
drainingRef.current = true;
|
|
367
394
|
const batch = queueRef.current;
|
|
@@ -372,12 +399,21 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
372
399
|
void Promise.resolve(handleSubmit(line, images.length ? images : undefined)).finally(() => {
|
|
373
400
|
drainingRef.current = false;
|
|
374
401
|
});
|
|
375
|
-
}, [working, prompt, handleSubmit]);
|
|
402
|
+
}, [working, prompt, askText, handleSubmit]);
|
|
376
403
|
useInput((input, key) => {
|
|
377
404
|
if (key.ctrl && input === "t")
|
|
378
405
|
return setShowTranscript((x) => !x); // open/close the full-transcript overlay
|
|
379
406
|
if (showTranscript)
|
|
380
407
|
return; // while open, the overlay's own useInput owns every key (scroll / esc)
|
|
408
|
+
// Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
|
|
409
|
+
if (askText) {
|
|
410
|
+
if (key.escape) {
|
|
411
|
+
const r = askText.resolve;
|
|
412
|
+
setAskText(null);
|
|
413
|
+
r("");
|
|
414
|
+
}
|
|
415
|
+
return;
|
|
416
|
+
}
|
|
381
417
|
if (prompt) {
|
|
382
418
|
const opts = prompt.options;
|
|
383
419
|
if (key.upArrow)
|
|
@@ -420,5 +456,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
420
456
|
});
|
|
421
457
|
if (showTranscript)
|
|
422
458
|
return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
|
|
423
|
-
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), !prompt && _jsx(TodoPanel, { todos: todos }), working && !prompt && _jsx(Working, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
459
|
+
return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), !prompt && _jsx(TodoPanel, { todos: todos }), working && !prompt && _jsx(Working, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), askText && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ? ${stripAnsi(askText.title)}` }), _jsx(Text, { dimColor: true, children: " type your answer below · Enter to send · Esc cancels" })] })), pool.length > 0 && !prompt && !askText && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working && !askText, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
|
|
424
460
|
}
|