@nanhara/hara 0.95.2 → 0.98.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
- const baseURL = cfg.baseURL ?? ap.baseURL;
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
- /** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model ~/.hara/config.json. */
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
- const provider = ((await rl.question(`Provider ${c.dim("anthropic / qwen / openai")} [anthropic]: `)).trim() || "anthropic").toLowerCase();
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 (provider === "qwen" || provider === "openai") {
156
- baseURL = (await rl.question(`Base URL ${c.dim("(blank = default; set for an OpenAI-compatible endpoint, e.g. GLM/Kimi/DashScope)")}: `)).trim();
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 rl.question(`API key ${c.dim(`(blank = use the ${envKey} env var)`)}: `)).trim();
160
- const model = (await rl.question(`Model [${SETUP_DEFAULT_MODEL[provider] ?? "?"}]: `)).trim() || SETUP_DEFAULT_MODEL[provider] || "";
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 | qwen | openai | qwen-oauth")
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|qwen|openai|qwen-oauth [--key … --base-url … --model …]\n"));
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
@@ -18,6 +18,22 @@ import { currentTodos, onTodosChange } from "../tools/todo.js";
18
18
  let _id = 0;
19
19
  const nid = () => ++_id;
20
20
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
21
+ /** Prepare a finalized turn item for the append-only `<Static>` scrollback. A completed reasoning
22
+ * block collapses to a single-line "✻ thought · N lines" notice (its full text preserved in `full`
23
+ * for the Ctrl+T overlay) — mirroring codex writing finalized rows to scrollback ONCE. Every other
24
+ * kind passes through unchanged. Used both mid-turn (as blocks finalize) and at turn end so a given
25
+ * turn's reasoning is emitted to Static exactly once and never re-rendered/re-emitted. */
26
+ function foldForHistory(it) {
27
+ if (it.kind !== "reasoning")
28
+ return it;
29
+ const lines = it.text.split("\n").filter((l) => l.trim()).length;
30
+ return { ...it, kind: "notice", text: `✻ thought · ${lines} lines`, full: it.text };
31
+ }
32
+ /** Redraw throttle for the LIVE region (~30fps). A fast token stream or a slow/remote terminal can
33
+ * push deltas far faster than a human perceives; without a cap ink re-diffs the growing dynamic
34
+ * block on every token, which over a laggy link leaves stale duplicate lines + jitter. 33ms clamps
35
+ * the live re-render rate while state itself stays exact (nothing is dropped, only coalesced). */
36
+ const LIVE_FRAME_MS = 33;
21
37
  function Block({ item, open }) {
22
38
  switch (item.kind) {
23
39
  case "user":
@@ -217,6 +233,9 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
217
233
  const [status, setStatus] = useState({ ...initialStatus, agents: 0 });
218
234
  const [prompt, setPrompt] = useState(null);
219
235
  const [promptSel, setPromptSel] = useState(0);
236
+ // Free-text question prompt (ask_user with no/declined options): re-enables the InputBox to capture one
237
+ // line, then resolves the awaiting tool with that text. Separate from `prompt` (the select-only path).
238
+ const [askText, setAskText] = useState(null);
220
239
  const [reasoningOpen, setReasoningOpen] = useState(false);
221
240
  const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
222
241
  // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
@@ -230,15 +249,25 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
230
249
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
231
250
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
232
251
  const drainingRef = useRef(false); // idempotency guard so the drain effect can't double-send one item
233
- const currentRef = useRef([]);
234
- currentRef.current = current;
235
252
  const statusRef = useRef(status);
236
253
  statusRef.current = status;
254
+ // Live-region write path (codex-style): deltas mutate `liveRef` synchronously (exact, never dropped),
255
+ // and a throttled flush (~30fps) reconciles it into the `current` React state that actually renders.
256
+ // As each block finalizes (a new block kind begins) its predecessors are appended to `history`
257
+ // (`<Static>`, rendered once) and dropped from the live buffer — so only the tail block stays dynamic.
258
+ const liveRef = useRef([]);
259
+ const flushTimerRef = useRef(null);
260
+ const toStaticRef = useRef([]); // finalized blocks awaiting append to <Static> on the next flush
237
261
  useEffect(() => {
238
262
  const fn = () => setStatus((s) => ({ ...s, agents: activity.running }));
239
263
  activity.onChange(fn);
240
264
  return () => activity.onChange(null);
241
265
  }, []);
266
+ // Cancel any pending live-region flush on unmount so a timer can't fire after teardown.
267
+ useEffect(() => () => {
268
+ if (flushTimerRef.current)
269
+ clearTimeout(flushTimerRef.current);
270
+ }, []);
242
271
  // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
243
272
  useEffect(() => {
244
273
  const unsub = onTodosChange((list) => {
@@ -255,14 +284,46 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
255
284
  clearTimeout(collapseTimerRef.current);
256
285
  };
257
286
  }, []);
258
- const pushCurrent = useCallback((kind, text, merge = false) => {
259
- setCurrent((cur) => {
260
- const last = cur[cur.length - 1];
261
- if (merge && last && last.kind === kind)
262
- return [...cur.slice(0, -1), { ...last, text: last.text + text }];
263
- return [...cur, { id: nid(), kind, text }];
264
- });
287
+ // Reconcile the synchronously-mutated live buffer into React state, at most once per ~33ms. First
288
+ // append any finalized blocks to <Static> (once, in order), then publish the remaining live tail.
289
+ // The array identities are fresh each flush so React/ink re-render exactly the minimal live region.
290
+ const flushLive = useCallback(() => {
291
+ flushTimerRef.current = null;
292
+ if (toStaticRef.current.length) {
293
+ const graduated = toStaticRef.current;
294
+ toStaticRef.current = [];
295
+ setHistory((h) => [...h, ...graduated]);
296
+ }
297
+ setCurrent(liveRef.current.slice());
265
298
  }, []);
299
+ // Schedule a throttled flush. Coalesces a burst of deltas into one re-render per LIVE_FRAME_MS —
300
+ // the leading edge is immediate (snappy first paint) and subsequent deltas ride the timer.
301
+ const scheduleFlush = useCallback(() => {
302
+ if (flushTimerRef.current)
303
+ return;
304
+ flushTimerRef.current = setTimeout(flushLive, LIVE_FRAME_MS);
305
+ }, [flushLive]);
306
+ const pushCurrent = useCallback((kind, text, merge = false) => {
307
+ const live = liveRef.current;
308
+ const last = live[live.length - 1];
309
+ if (merge && last && last.kind === kind) {
310
+ // Same streaming block continues — grow it in place in the live buffer (no new React item).
311
+ live[live.length - 1] = { ...last, text: last.text + text };
312
+ }
313
+ else {
314
+ // A new block begins. Everything before it in the live buffer is now finalized: graduate it
315
+ // to <Static> (folding reasoning) so it's written to scrollback ONCE and never re-rendered.
316
+ if (live.length) {
317
+ for (const it of live)
318
+ toStaticRef.current.push(foldForHistory(it));
319
+ liveRef.current = [{ id: nid(), kind, text }];
320
+ }
321
+ else {
322
+ live.push({ id: nid(), kind, text });
323
+ }
324
+ }
325
+ scheduleFlush();
326
+ }, [scheduleFlush]);
266
327
  // Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
267
328
  // Instead, the first time an image attachment shows up in this session, we print the
268
329
  // routing notice once (inline). `visionShownRef` is the session-scoped flag.
@@ -290,6 +351,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
290
351
  }, [pushCurrent, noteVisionIfNeeded]);
291
352
  const handleSubmit = useCallback(async (line, images) => {
292
353
  const t = line.trim();
354
+ // A free-text question (ask_user) is awaiting an answer: this submission IS the answer, not a new turn.
355
+ if (askText) {
356
+ const r = askText.resolve;
357
+ setAskText(null);
358
+ setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]);
359
+ r(t);
360
+ return;
361
+ }
293
362
  if ((!t && !images?.length) || prompt)
294
363
  return; // nothing to send, or a choice is pending
295
364
  if (working) {
@@ -323,24 +392,43 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
323
392
  { label: "No (esc)", value: false, key: "n" },
324
393
  ]);
325
394
  const selectFn = (title, options) => openPrompt(title, options);
395
+ // Free-text question: re-enable the InputBox to read one line (resolves via handleSubmit's askText branch).
396
+ const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
397
+ // ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
398
+ // when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
399
+ const OTHER = "__ask_other__"; // sentinel value for the "type my own" option
400
+ const askFn = async (question, options) => {
401
+ if (options && options.length) {
402
+ const choice = await openPrompt(question, [
403
+ ...options.map((o) => ({ label: o, value: o })),
404
+ { label: "✎ Type my own answer", value: OTHER },
405
+ ]);
406
+ if (choice !== OTHER)
407
+ return choice;
408
+ }
409
+ return askTextFn(question);
410
+ };
326
411
  const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
327
412
  try {
328
- await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
413
+ await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, ask: askFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
329
414
  }
330
415
  catch (e) {
331
416
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
332
417
  }
333
- // Commit this turn's items to scrollback. Read the LIVE current via the updater — currentRef only
334
- // syncs on render (line ~225), so a fast slash-only turn (/design, /help, /skills…) that pushes a
335
- // notice and returns before any re-render would otherwise commit nothing and lose the notice.
336
- setCurrent((cur) => {
337
- const committed = cur.map((it) => it.kind === "reasoning"
338
- ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines`, full: it.text }
339
- : it);
340
- if (committed.length)
341
- setHistory((h) => [...h, ...committed]);
342
- return [];
343
- });
418
+ // Commit this turn's items to scrollback. The authoritative source is the synchronous live
419
+ // buffer (liveRef + any blocks already graduated to toStaticRef), NOT the throttled `current`
420
+ // React state so a fast slash-only turn (/design, /help, /skills…) that pushes a notice and
421
+ // returns before a flush fires still commits it, and a pending throttle timer can't double-emit.
422
+ if (flushTimerRef.current) {
423
+ clearTimeout(flushTimerRef.current);
424
+ flushTimerRef.current = null;
425
+ }
426
+ const committed = [...toStaticRef.current, ...liveRef.current.map(foldForHistory)];
427
+ toStaticRef.current = [];
428
+ liveRef.current = [];
429
+ if (committed.length)
430
+ setHistory((h) => [...h, ...committed]);
431
+ setCurrent([]);
344
432
  setWorking(false);
345
433
  ctrlRef.current = null;
346
434
  // Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
@@ -357,11 +445,11 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
357
445
  collapseTimerRef.current = null;
358
446
  }, 30_000);
359
447
  }
360
- }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
448
+ }, [working, prompt, askText, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
361
449
  // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
362
450
  // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
363
451
  useEffect(() => {
364
- if (working || prompt || drainingRef.current || !queueRef.current.length)
452
+ if (working || prompt || askText || drainingRef.current || !queueRef.current.length)
365
453
  return;
366
454
  drainingRef.current = true;
367
455
  const batch = queueRef.current;
@@ -372,12 +460,21 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
372
460
  void Promise.resolve(handleSubmit(line, images.length ? images : undefined)).finally(() => {
373
461
  drainingRef.current = false;
374
462
  });
375
- }, [working, prompt, handleSubmit]);
463
+ }, [working, prompt, askText, handleSubmit]);
376
464
  useInput((input, key) => {
377
465
  if (key.ctrl && input === "t")
378
466
  return setShowTranscript((x) => !x); // open/close the full-transcript overlay
379
467
  if (showTranscript)
380
468
  return; // while open, the overlay's own useInput owns every key (scroll / esc)
469
+ // Free-text question awaiting an answer: Esc cancels (empty answer); all other keys belong to the InputBox.
470
+ if (askText) {
471
+ if (key.escape) {
472
+ const r = askText.resolve;
473
+ setAskText(null);
474
+ r("");
475
+ }
476
+ return;
477
+ }
381
478
  if (prompt) {
382
479
  const opts = prompt.options;
383
480
  if (key.upArrow)
@@ -420,5 +517,5 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
420
517
  });
421
518
  if (showTranscript)
422
519
  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 })] }));
520
+ 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
521
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.95.2",
3
+ "version": "0.98.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"