@nanhara/hara 0.0.1 → 0.33.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.
Files changed (55) hide show
  1. package/CHANGELOG.md +431 -0
  2. package/CLA.md +51 -0
  3. package/LICENSE +201 -21
  4. package/README.md +203 -7
  5. package/dist/activity.js +30 -0
  6. package/dist/agent/loop.js +184 -0
  7. package/dist/config.js +114 -0
  8. package/dist/context/agents-md.js +64 -0
  9. package/dist/context/mentions.js +90 -0
  10. package/dist/diff.js +103 -0
  11. package/dist/fs-walk.js +103 -0
  12. package/dist/fuzzy.js +62 -0
  13. package/dist/images.js +146 -0
  14. package/dist/index.js +1362 -0
  15. package/dist/mcp/client.js +54 -0
  16. package/dist/md.js +52 -0
  17. package/dist/memory/guard.js +51 -0
  18. package/dist/memory/store.js +93 -0
  19. package/dist/org/planner.js +155 -0
  20. package/dist/org/roles.js +140 -0
  21. package/dist/org/router.js +39 -0
  22. package/dist/plugins/plugins.js +124 -0
  23. package/dist/providers/anthropic.js +83 -0
  24. package/dist/providers/openai.js +125 -0
  25. package/dist/providers/qwen-oauth.js +139 -0
  26. package/dist/providers/types.js +2 -0
  27. package/dist/recall.js +76 -0
  28. package/dist/sandbox.js +78 -0
  29. package/dist/search/embed.js +42 -0
  30. package/dist/search/hybrid.js +38 -0
  31. package/dist/search/semindex.js +141 -0
  32. package/dist/session/store.js +95 -0
  33. package/dist/skills/skills.js +141 -0
  34. package/dist/statusbar.js +69 -0
  35. package/dist/tools/agent.js +26 -0
  36. package/dist/tools/apply-core.js +63 -0
  37. package/dist/tools/builtin.js +106 -0
  38. package/dist/tools/codebase.js +102 -0
  39. package/dist/tools/computer.js +236 -0
  40. package/dist/tools/edit.js +62 -0
  41. package/dist/tools/memory.js +147 -0
  42. package/dist/tools/patch.js +123 -0
  43. package/dist/tools/registry.js +18 -0
  44. package/dist/tools/search.js +176 -0
  45. package/dist/tools/skill.js +30 -0
  46. package/dist/tools/web.js +73 -0
  47. package/dist/tui/App.js +165 -0
  48. package/dist/tui/InputBox.js +208 -0
  49. package/dist/tui/run.js +10 -0
  50. package/dist/tui/theme.js +11 -0
  51. package/dist/ui.js +17 -0
  52. package/dist/undo.js +40 -0
  53. package/dist/vision.js +81 -0
  54. package/package.json +33 -7
  55. package/bin/hara.mjs +0 -25
package/dist/index.js ADDED
@@ -0,0 +1,1362 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from "commander";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { emitKeypressEvents } from "node:readline";
5
+ import { runTui } from "./tui/run.js";
6
+ import { readClipboardImage } from "./images.js";
7
+ import { describeImages, classifyVision } from "./vision.js";
8
+ import { setTheme } from "./tui/theme.js";
9
+ import { memoryDigest, memoryDir } from "./memory/store.js";
10
+ import { nextMode as cycleMode } from "./tui/InputBox.js";
11
+ import { stdin, stdout } from "node:process";
12
+ import { readFileSync, existsSync } from "node:fs";
13
+ import { homedir } from "node:os";
14
+ import { fileURLToPath } from "node:url";
15
+ import { dirname, join } from "node:path";
16
+ import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
17
+ import { runAgent } from "./agent/loop.js";
18
+ import { getTools } from "./tools/registry.js";
19
+ import { createAnthropicProvider } from "./providers/anthropic.js";
20
+ import { createOpenAIProvider } from "./providers/openai.js";
21
+ import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
22
+ import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
23
+ import { getEmbedder } from "./search/embed.js";
24
+ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
25
+ import { searchHybrid } from "./search/hybrid.js";
26
+ import { expandMentions, fileCandidates } from "./context/mentions.js";
27
+ import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, } from "./session/store.js";
28
+ import { loadRoles, scaffoldRoles } from "./org/roles.js";
29
+ import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
30
+ import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
31
+ import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
32
+ import { decompose, topoOrder, savePlan, atomPrompt, verify, runCheck } from "./org/planner.js";
33
+ import { connectMcpServers, closeMcp } from "./mcp/client.js";
34
+ import { sandboxSupported } from "./sandbox.js";
35
+ import { undoLast } from "./undo.js";
36
+ import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
37
+ import { c, out, statusLine } from "./ui.js";
38
+ import * as bar from "./statusbar.js";
39
+ import { nearest } from "./fuzzy.js";
40
+ import "./tools/builtin.js"; // register read_file/write_file/bash
41
+ import "./tools/edit.js"; // register edit_file
42
+ import "./tools/search.js"; // register grep/glob/ls
43
+ import "./tools/patch.js"; // register apply_patch
44
+ import "./tools/web.js"; // register web_fetch
45
+ import "./tools/agent.js"; // register agent (subagent spawn)
46
+ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
47
+ import "./tools/skill.js"; // register the skill loader tool
48
+ import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
49
+ import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
50
+ const here = dirname(fileURLToPath(import.meta.url));
51
+ const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
52
+ const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
53
+ async function buildProvider(cfg) {
54
+ if (cfg.provider === "qwen-oauth") {
55
+ const auth = await getValidQwenAuth();
56
+ if (!auth)
57
+ return null;
58
+ return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
59
+ }
60
+ if (!cfg.apiKey)
61
+ return null;
62
+ if (cfg.provider === "anthropic") {
63
+ return createAnthropicProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL });
64
+ }
65
+ return createOpenAIProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL, label: cfg.provider });
66
+ }
67
+ function authHint(cfg) {
68
+ if (cfg.provider === "qwen-oauth")
69
+ return `Run ${c.bold("hara login qwen")} to authenticate.`;
70
+ return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara config set apiKey <key>")}.`;
71
+ }
72
+ async function runInit(provider, cwd, sandbox = "off") {
73
+ const history = [{ role: "user", content: INIT_PROMPT }];
74
+ await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
75
+ }
76
+ /** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
77
+ async function runOrg(task, o) {
78
+ const roles = loadRoles(o.cwd);
79
+ if (!roles.length) {
80
+ out(c.yellow("No roles defined — run ") + c.bold("hara roles init") + c.yellow(" to scaffold some.\n"));
81
+ return;
82
+ }
83
+ let role;
84
+ if (o.forceRole) {
85
+ role = roles.find((r) => r.id === o.forceRole);
86
+ if (!role) {
87
+ out(c.red(`No role '${o.forceRole}'. Available: ${roles.map((r) => r.id).join(", ")}\n`));
88
+ return;
89
+ }
90
+ }
91
+ else {
92
+ const kw = routeByKeywords(task, roles);
93
+ if (kw) {
94
+ role = kw.role;
95
+ }
96
+ else {
97
+ const r = await o.baseProvider.turn({
98
+ system: "You are a task dispatcher. Reply with only a role id.",
99
+ history: [{ role: "user", content: buildDispatchPrompt(task, roles) }],
100
+ tools: [],
101
+ onText: () => { },
102
+ });
103
+ role = parseRoleId(r.text, roles) ?? roles[0];
104
+ }
105
+ }
106
+ out(c.dim(`→ ${role.id} owns this task\n`));
107
+ const roleProvider = role.model && role.model !== o.cfg.model
108
+ ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider)
109
+ : o.baseProvider;
110
+ const toolFilter = role.allowTools
111
+ ? (n) => role.allowTools.includes(n)
112
+ : role.denyTools
113
+ ? (n) => !role.denyTools.includes(n)
114
+ : undefined;
115
+ const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
116
+ await runAgent(history, {
117
+ provider: roleProvider,
118
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
119
+ approval: o.approval,
120
+ confirm: o.confirm,
121
+ projectContext: o.projectContext,
122
+ memory: memoryDigest(o.cwd),
123
+ stats: o.stats,
124
+ systemOverride: role.system,
125
+ toolFilter,
126
+ });
127
+ }
128
+ function lastAssistantText(history) {
129
+ for (let i = history.length - 1; i >= 0; i--) {
130
+ const m = history[i];
131
+ if (m.role === "assistant" && typeof m.text === "string")
132
+ return m.text;
133
+ }
134
+ return "";
135
+ }
136
+ /** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate. */
137
+ async function runPlan(task, o) {
138
+ const roles = loadRoles(o.cwd);
139
+ out(c.dim("Planning…\n"));
140
+ const plan = await decompose(o.baseProvider, task, roles);
141
+ if (!plan.atoms.length) {
142
+ out(c.red("Planner returned no atoms — try rephrasing the task.\n"));
143
+ return;
144
+ }
145
+ const ord = topoOrder(plan.atoms);
146
+ if ("error" in ord) {
147
+ out(c.red(`${ord.error}\n`));
148
+ return;
149
+ }
150
+ const ordered = ord.ok;
151
+ out(c.bold(`\nPlan (${ordered.length} atoms):\n`));
152
+ for (const a of ordered) {
153
+ out(` ${c.cyan(a.id)} ${a.title}${a.deps.length ? c.dim(" ←" + a.deps.join(",")) : ""}${a.role ? c.dim(" @" + a.role) : ""}${a.check ? c.dim(" ✓" + a.check) : ""}\n`);
154
+ }
155
+ if (o.approval !== "full-auto") {
156
+ const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ordered.length}-atom plan?`);
157
+ if (!ok)
158
+ return void out(c.dim("(cancelled)\n"));
159
+ }
160
+ savePlan(o.cwd, plan);
161
+ const done = [];
162
+ for (const atom of ordered) {
163
+ atom.status = "running";
164
+ savePlan(o.cwd, plan);
165
+ out(c.cyan(`\n▶ ${atom.id} ${atom.title}\n`));
166
+ const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
167
+ const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
168
+ const toolFilter = role?.allowTools
169
+ ? (n) => role.allowTools.includes(n)
170
+ : role?.denyTools
171
+ ? (n) => !role.denyTools.includes(n)
172
+ : undefined;
173
+ const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
174
+ try {
175
+ await runAgent(history, {
176
+ provider: roleProvider,
177
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
178
+ approval: o.approval,
179
+ confirm: o.confirm,
180
+ projectContext: o.projectContext,
181
+ memory: memoryDigest(o.cwd),
182
+ stats: o.stats,
183
+ systemOverride: role?.system,
184
+ toolFilter,
185
+ });
186
+ }
187
+ catch (e) {
188
+ atom.status = "failed";
189
+ atom.note = e.message;
190
+ savePlan(o.cwd, plan);
191
+ out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
192
+ break;
193
+ }
194
+ if (atom.check)
195
+ out(c.dim(` check: ${atom.check}\n`));
196
+ const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
197
+ atom.status = v.ok ? "done" : "failed";
198
+ atom.note = v.reason;
199
+ savePlan(o.cwd, plan);
200
+ if (v.ok) {
201
+ out(c.green(` ✓ ${atom.id} verified\n`));
202
+ done.push(atom);
203
+ }
204
+ else {
205
+ out(c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`) + c.dim("Stopping — inspect .hara/org/plan.json, then refine & re-run.\n"));
206
+ break;
207
+ }
208
+ }
209
+ out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
210
+ }
211
+ const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch"]);
212
+ const PLAN_SYSTEM = "You are in PLAN MODE. Investigate read-only (read_file / grep / glob / ls / web_fetch) and think, " +
213
+ "then propose a concise step-by-step plan for the task. Do NOT edit files or run commands yet — only plan. " +
214
+ "End your message with the plan as a short numbered list.";
215
+ const DISTILL_SYSTEM = "The session is ending. Reflect and persist only durable, reusable learnings: memory_write for facts / " +
216
+ "conventions / the user's preferences, skill_create for reusable how-tos. Be selective — skip the trivial. Then reply DONE.";
217
+ const COMPACT_SYSTEM = "Summarize the conversation so far into a concise but complete brief so the assistant can " +
218
+ "continue seamlessly: the user's goal, key decisions, files changed, current state, and open next steps. " +
219
+ "Be specific. Output only the summary.";
220
+ const workingSetFromSummary = (s) => s
221
+ .split("\n")
222
+ .map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
223
+ .filter((l) => l.length > 3)
224
+ .slice(0, 12)
225
+ .map((l) => l.slice(0, 140));
226
+ /** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
227
+ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
228
+ const roles = loadRoles(cwd);
229
+ const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
230
+ const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
231
+ const toolFilter = role?.allowTools
232
+ ? (n) => role.allowTools.includes(n)
233
+ : role?.denyTools
234
+ ? (n) => !role.denyTools.includes(n)
235
+ : (n) => READONLY_TOOLS.has(n); // default sub-agent = read-only (safe to parallelize)
236
+ const subHistory = [{ role: "user", content: task }];
237
+ await runAgent(subHistory, {
238
+ provider,
239
+ ctx: { cwd, sandbox }, // no `spawn` here → sub-agents can't recurse
240
+ approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
241
+ confirm: async () => true,
242
+ projectContext,
243
+ memory: memoryDigest(cwd),
244
+ stats,
245
+ systemOverride: role?.system,
246
+ toolFilter,
247
+ quiet: true,
248
+ });
249
+ for (let i = subHistory.length - 1; i >= 0; i--) {
250
+ const m = subHistory[i];
251
+ if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
252
+ return m.text.trim();
253
+ }
254
+ return "(sub-agent produced no output)";
255
+ }
256
+ /** Check the hara setup and print a health summary (provider/auth/model/node/assets/roles). */
257
+ function runDoctor(cfg) {
258
+ const ok = (b) => (b ? c.green("✓") : c.red("✗"));
259
+ const dot = c.dim("·");
260
+ const nodeMajor = Number(process.versions.node.split(".")[0]);
261
+ const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
262
+ const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
263
+ const authed = hasKey || oauthOk;
264
+ const ad = assetsDir();
265
+ const roles = loadRoles(cfg.cwd);
266
+ const vcap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
267
+ const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
268
+ const lines = [
269
+ c.bold("hara doctor"),
270
+ `${ok(nodeMajor >= 20)} node ${process.versions.node} ${c.dim("(need ≥20)")}`,
271
+ `${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
272
+ `${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
273
+ `${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
274
+ `${dot} code-assets ${existsSync(ad) ? c.dim(ad) : c.dim("none — run: hara recall --init")}`,
275
+ `${dot} roles ${roles.length ? c.dim(roles.map((r) => r.id).join(", ")) : c.dim("none — run: hara roles init")}`,
276
+ `${dot} skills ${(() => { const n = loadSkillIndex(cfg.cwd).length; return n ? c.dim(`${n} (${loadSkillIndex(cfg.cwd).map((s) => s.id).slice(0, 6).join(", ")})`) : c.dim("none — run: hara skills init"); })()}`,
277
+ `${dot} memory ${existsSync(join(homedir(), ".hara", "memory")) ? c.dim("~/.hara/memory + project") : c.dim("none yet (created on first write)")} ${c.dim("· evolve")} ${c.bold(cfg.evolve)} ${c.dim("· capture")} ${c.bold(cfg.assetCapture)}`,
278
+ `${dot} search ${c.dim("lexical (always on)")}${cfg.embedProvider === "off" ? c.dim(" · semantic off (hara config set embedProvider ollama|qwen)") : c.dim(" · semantic ") + c.bold(cfg.embedProvider) + (() => { const idx = ["repo", "assets", "memory"].filter((n) => indexExists(n, cfg.cwd)); return c.dim(" · indexed: ") + (idx.length ? c.green(idx.join(", ")) : c.yellow("none — run: hara index --all")); })()}`,
279
+ `${dot} vision · ${c.bold(cfg.model)} ${vdesc}${cfg.visionModel ? c.dim(" · describer ") + c.bold(cfg.visionModel) : vcap === "text" ? c.yellow(" · set /vision <model>") : ""}`,
280
+ `${dot} screen ${cfg.computerUse === "off" ? c.dim("off (hara config set computerUse read|click|full)") : c.bold(cfg.computerUse) + c.dim(` · ${computerBackends()}${cfg.computerApps.length ? " · apps: " + cfg.computerApps.join(", ") : " · no app allowlist"}`)}`,
281
+ `${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
282
+ `${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
283
+ ];
284
+ return lines.join("\n");
285
+ }
286
+ function mentionCompleter(line, cwd) {
287
+ const m = /@([^\s@]*)$/.exec(line);
288
+ if (!m)
289
+ return [[], line];
290
+ return [fileCandidates(cwd, m[1]).map((f) => "@" + f), "@" + m[1]];
291
+ }
292
+ function helpText(commands) {
293
+ const lines = commands.map((cmd) => ` /${cmd.name.padEnd(13)} ${c.dim(cmd.desc)}`);
294
+ return c.bold("Commands:\n") + lines.join("\n") + "\n" + c.dim(" @path attach a file's contents (Tab to complete)\n");
295
+ }
296
+ const program = new Command();
297
+ program
298
+ .name("hara")
299
+ .description("A coding agent CLI that runs like an engineering org.")
300
+ .version(pkg.version)
301
+ .option("-p, --print <prompt>", "run a single prompt non-interactively, then exit")
302
+ .option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
303
+ .option("-m, --model <model>", "model id (overrides config)")
304
+ .option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
305
+ .option("--profile <name>", "use a named profile from ~/.hara/config.json")
306
+ .option("-c, --continue", "resume the most recent session in this directory")
307
+ .option("--resume <id>", "resume a specific session by id")
308
+ .option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
309
+ program
310
+ .command("init")
311
+ .description("analyze the project and (re)generate AGENTS.md")
312
+ .action(async () => {
313
+ const cfg = loadConfig();
314
+ const provider = await buildProvider(cfg);
315
+ if (!provider) {
316
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
317
+ process.exit(1);
318
+ }
319
+ out(c.dim("Analyzing project to generate AGENTS.md…\n"));
320
+ await runInit(provider, cfg.cwd, cfg.sandbox);
321
+ });
322
+ program
323
+ .command("sessions")
324
+ .description("list saved sessions")
325
+ .action(() => {
326
+ const metas = listSessions();
327
+ if (!metas.length) {
328
+ out(c.dim("No sessions yet.\n"));
329
+ return;
330
+ }
331
+ for (const m of metas) {
332
+ out(`${c.bold(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.provider}:${m.model} ${m.title}\n`);
333
+ }
334
+ });
335
+ program
336
+ .command("org <task...>")
337
+ .description("dispatch a task to the owning role and run it")
338
+ .option("--role <id>", "force a specific role")
339
+ .action(async (taskParts, opts2) => {
340
+ const cfg = loadConfig();
341
+ const provider = await buildProvider(cfg);
342
+ if (!provider) {
343
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
344
+ process.exit(1);
345
+ }
346
+ const stats = { input: 0, output: 0, lastInput: 0 };
347
+ await runOrg(taskParts.join(" "), {
348
+ cfg,
349
+ baseProvider: provider,
350
+ cwd: cfg.cwd,
351
+ sandbox: cfg.sandbox,
352
+ approval: "full-auto",
353
+ confirm: async () => true,
354
+ projectContext: loadAgentsMd(cfg.cwd) || undefined,
355
+ stats,
356
+ forceRole: opts2.role,
357
+ });
358
+ if (stats.input || stats.output)
359
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
360
+ });
361
+ program
362
+ .command("plan <task...>")
363
+ .description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
364
+ .action(async (taskParts) => {
365
+ const cfg = loadConfig();
366
+ const provider = await buildProvider(cfg);
367
+ if (!provider) {
368
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
369
+ process.exit(1);
370
+ }
371
+ const stats = { input: 0, output: 0, lastInput: 0 };
372
+ await runPlan(taskParts.join(" "), {
373
+ cfg,
374
+ baseProvider: provider,
375
+ cwd: cfg.cwd,
376
+ sandbox: cfg.sandbox,
377
+ approval: "full-auto",
378
+ confirm: async () => true,
379
+ projectContext: loadAgentsMd(cfg.cwd) || undefined,
380
+ stats,
381
+ });
382
+ if (stats.input || stats.output)
383
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
384
+ });
385
+ program
386
+ .command("recall [query...]")
387
+ .description("search your code-asset library (~/.hara/code-assets) for snippets/playbooks")
388
+ .option("--init", "scaffold the code-assets directory with an example")
389
+ .action(async (parts, opts2) => {
390
+ if (opts2.init) {
391
+ const w = scaffoldAssets();
392
+ out(w.length ? c.green(`Scaffolded ${assetsDir()}: ${w.join(", ")}\n`) : c.dim(`Assets already exist at ${assetsDir()}\n`));
393
+ return;
394
+ }
395
+ const q = (parts ?? []).join(" ");
396
+ if (!q)
397
+ return void out(c.dim("usage: hara recall <query> (or: hara recall --init)\n"));
398
+ const hits = await searchHybrid(q, process.cwd(), { indexName: "assets", roots: assetSearchRoots(process.cwd()) });
399
+ if (!hits.length)
400
+ return void out(c.dim(`No matches in ${assetsDir()} (add .md files, or run: hara recall --init)\n`));
401
+ for (const h of hits)
402
+ out(`${c.cyan(h.path)} ${c.dim(h.title)}\n`);
403
+ });
404
+ program
405
+ .command("index")
406
+ .description("build the semantic index (opt-in; needs an embedding provider)")
407
+ .option("--repo", "index the current project — for codebase_search (default)")
408
+ .option("--assets", "index your global code-assets, skills & memory — for recall / memory_search")
409
+ .option("--all", "index everything")
410
+ .action(async (opts) => {
411
+ const cfg = loadConfig();
412
+ const embed = getEmbedder(cfg);
413
+ if (!embed) {
414
+ out(c.yellow("Semantic search is off — search stays lexical (which still works).\n"));
415
+ out(c.dim("Turn it on with an embedding provider, then re-run `hara index`:\n"));
416
+ out(c.dim(" hara config set embedProvider ollama # local & offline (needs Ollama + an embed model)\n"));
417
+ out(c.dim(" hara config set embedProvider qwen # DashScope text-embedding-v3 (uses your key)\n"));
418
+ return;
419
+ }
420
+ const cwd = process.cwd();
421
+ const model = `${cfg.embedProvider}:${cfg.embedModel ?? "default"}`;
422
+ const doRepo = opts.all || opts.repo || (!opts.assets && !opts.all);
423
+ const doAssets = opts.all || opts.assets;
424
+ const build = async (name, chunks, blurb) => {
425
+ if (!chunks.length)
426
+ return void out(c.dim(`Nothing to index for ${name}.\n`));
427
+ out(c.dim(`Embedding ${chunks.length} ${name} chunks with ${cfg.embedProvider}…\n`));
428
+ try {
429
+ const n = await buildIndex(name, chunks, embed, cwd, model);
430
+ out(c.green(`Indexed ${n} chunks → ${indexPath(name, cwd)}`) + c.dim(` (${blurb})`) + "\n");
431
+ }
432
+ catch (e) {
433
+ out(c.red(`Indexing ${name} failed: ${e.message}\n`));
434
+ out(c.dim("Check the embedding endpoint/key; search still works lexically.\n"));
435
+ }
436
+ };
437
+ if (doRepo)
438
+ await build("repo", collectRepoChunks(findProjectRoot(cwd)), "codebase_search");
439
+ if (doAssets) {
440
+ await build("assets", [...collectDirChunks(assetsDir(), "code-assets"), ...collectDirChunks(globalSkillsDir(), "skills")], "recall");
441
+ await build("memory", collectDirChunks(memoryDir("global", cwd), "memory"), "memory_search");
442
+ }
443
+ });
444
+ program
445
+ .command("doctor")
446
+ .description("check your hara setup (provider / auth / model / node / assets / roles)")
447
+ .action(() => out(runDoctor(loadConfig()) + "\n"));
448
+ const rolesCmd = program.command("roles").description("manage org roles (.hara/roles)");
449
+ rolesCmd
450
+ .command("init")
451
+ .description("scaffold example roles")
452
+ .action(() => {
453
+ const written = scaffoldRoles(process.cwd());
454
+ out(written.length
455
+ ? c.green(`Created ${written.length} file(s) in .hara/roles/: ${written.join(", ")}\n`)
456
+ : c.dim("Roles already exist in .hara/roles/.\n"));
457
+ });
458
+ rolesCmd.action(() => {
459
+ const roles = loadRoles(process.cwd());
460
+ if (!roles.length) {
461
+ out(c.dim("No roles. Run `hara roles init`.\n"));
462
+ return;
463
+ }
464
+ for (const r of roles) {
465
+ out(`${c.bold(r.id)}${r.model ? c.dim(` (${r.model})`) : ""} ${c.dim("owns: " + r.owns.join(", "))}\n ${r.description}\n`);
466
+ }
467
+ });
468
+ const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
469
+ skillsCmd
470
+ .command("init")
471
+ .description("scaffold an example skill")
472
+ .action(() => {
473
+ const written = scaffoldSkills(process.cwd());
474
+ out(written.length
475
+ ? c.green(`Created an example skill: ${written.join(", ")}\n`)
476
+ : c.dim("Skills already exist in .hara/skills/.\n"));
477
+ });
478
+ skillsCmd.action(() => {
479
+ const skills = loadSkillIndex(process.cwd());
480
+ if (!skills.length) {
481
+ out(c.dim("No skills. Run `hara skills init`, or the agent saves them with skill_create.\n"));
482
+ return;
483
+ }
484
+ for (const s of skills) {
485
+ out(`${c.bold(s.id)}${s.context === "fork" ? c.dim(" (fork)") : ""} ${c.dim(s.source)}\n ${s.description}\n`);
486
+ }
487
+ });
488
+ const pluginCmd = program.command("plugin").description("manage plugins (bundle skills/roles/MCP servers)");
489
+ pluginCmd
490
+ .command("add <source>")
491
+ .description("install a plugin from file:<path> | github:<owner/repo> | git:<url>")
492
+ .action((source) => {
493
+ try {
494
+ const p = installPlugin(source);
495
+ setPluginEnabled(p.name, true);
496
+ const m = p.manifest;
497
+ const parts = [
498
+ m.skills?.length ? `${m.skills.length} skill dir(s)` : "",
499
+ m.agents?.length ? `${m.agents.length} role dir(s)` : "",
500
+ m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
501
+ ].filter(Boolean);
502
+ out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
503
+ }
504
+ catch (e) {
505
+ out(c.red(`Install failed: ${e.message}\n`));
506
+ }
507
+ });
508
+ pluginCmd
509
+ .command("remove <name>")
510
+ .alias("uninstall")
511
+ .description("uninstall a plugin")
512
+ .action((name) => out(uninstallPlugin(name) ? c.green(`Removed ${name}\n`) : c.dim(`(no plugin '${name}')\n`)));
513
+ pluginCmd
514
+ .command("enable <name>")
515
+ .description("enable an installed plugin")
516
+ .action((name) => (setPluginEnabled(name, true), out(c.green(`Enabled ${name}\n`))));
517
+ pluginCmd
518
+ .command("disable <name>")
519
+ .description("disable an installed plugin (keeps it installed)")
520
+ .action((name) => (setPluginEnabled(name, false), out(c.green(`Disabled ${name}\n`))));
521
+ pluginCmd.action(() => {
522
+ const installed = listInstalled();
523
+ if (!installed.length)
524
+ return void out(c.dim("No plugins. Install with `hara plugin add <source>`.\n"));
525
+ const on = new Set(enabledPlugins().map((p) => p.name));
526
+ for (const p of installed) {
527
+ out(`${on.has(p.name) ? c.green("●") : c.dim("○")} ${c.bold(p.name)}@${p.version}${p.manifest.description ? c.dim(" " + p.manifest.description) : ""}\n`);
528
+ }
529
+ });
530
+ const login = program.command("login").description("authenticate a provider");
531
+ login
532
+ .command("qwen")
533
+ .description("Qwen OAuth device login (free 'Qwen Code' tier — same as OpenClaw)")
534
+ .action(async () => {
535
+ try {
536
+ await qwenDeviceLogin((m) => out(m + "\n"));
537
+ writeConfigValue("provider", "qwen-oauth");
538
+ writeConfigValue("model", "coder-model");
539
+ out(c.green("\n✓ Qwen OAuth complete — provider set to qwen-oauth (model coder-model).\n"));
540
+ }
541
+ catch (e) {
542
+ out(c.red(`\nQwen OAuth failed: ${e.message}\n`));
543
+ process.exit(1);
544
+ }
545
+ });
546
+ const config = program.command("config").description("manage ~/.hara/config.json");
547
+ config
548
+ .command("set <key> <value>")
549
+ .description(`set a config value (keys: ${CONFIG_KEYS.join(" | ")})`)
550
+ .action((key, value) => {
551
+ if (!CONFIG_KEYS.includes(key)) {
552
+ out(c.red(`Unknown key '${key}'. Valid keys: ${CONFIG_KEYS.join(", ")}.\n`));
553
+ process.exit(1);
554
+ }
555
+ if (key === "approval" && !APPROVAL_MODES.includes(value)) {
556
+ out(c.red(`Invalid approval mode. One of: ${APPROVAL_MODES.join(", ")}.\n`));
557
+ process.exit(1);
558
+ }
559
+ if (key === "sandbox" && !SANDBOX_MODES.includes(value)) {
560
+ out(c.red(`Invalid sandbox mode. One of: ${SANDBOX_MODES.join(", ")}.\n`));
561
+ process.exit(1);
562
+ }
563
+ writeConfigValue(key, value);
564
+ out(c.green(`Set ${key} → ${configPath()}\n`));
565
+ });
566
+ config
567
+ .command("get [key]")
568
+ .description("show config (apiKey masked)")
569
+ .action((key) => {
570
+ const raw = readRawConfig();
571
+ if (key) {
572
+ out((key === "apiKey" ? maskKey(raw.apiKey) : raw[key] ?? "(unset)") + "\n");
573
+ }
574
+ else {
575
+ out(`path: ${configPath()}\n` +
576
+ `provider: ${raw.provider ?? "(default anthropic)"}\n` +
577
+ `model: ${raw.model ?? "(provider default)"}\n` +
578
+ `baseURL: ${raw.baseURL ?? "(provider default)"}\n` +
579
+ `approval: ${raw.approval ?? "(default suggest)"}\n` +
580
+ `sandbox: ${raw.sandbox ?? "(default off)"}\n` +
581
+ `apiKey: ${maskKey(raw.apiKey)}\n`);
582
+ }
583
+ });
584
+ config
585
+ .command("path")
586
+ .description("print the config file path")
587
+ .action(() => out(configPath() + "\n"));
588
+ // default action (interactive REPL / one-shot)
589
+ program.action(async (opts) => {
590
+ const cfg = loadConfig({ profile: opts.profile });
591
+ if (opts.model)
592
+ cfg.model = opts.model;
593
+ const provider0 = await buildProvider(cfg);
594
+ if (!provider0) {
595
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
596
+ process.exit(1);
597
+ }
598
+ let provider = provider0;
599
+ const cwd = cfg.cwd;
600
+ let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
601
+ let currentTurn = null; // set during a running turn so Esc can abort it
602
+ const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
603
+ let recalledContext = ""; // snippets queued by /recall, prepended to the next message
604
+ const sandbox = opts.sandbox || cfg.sandbox;
605
+ if (sandbox !== "off" && !sandboxSupported()) {
606
+ out(c.yellow(`(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`));
607
+ }
608
+ const stats = { input: 0, output: 0, lastInput: 0 };
609
+ const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
610
+ if (Object.keys(mcpAll).length) {
611
+ await connectMcpServers(mcpAll, (m) => out(c.dim(m + "\n")));
612
+ }
613
+ // one-shot
614
+ if (opts.print) {
615
+ const projectContext = loadAgentsMd(cwd) || undefined;
616
+ const history = [{ role: "user", content: expandMentions(String(opts.print), cwd) }];
617
+ await runAgent(history, {
618
+ provider,
619
+ ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role) },
620
+ approval: "full-auto",
621
+ confirm: async () => true,
622
+ projectContext,
623
+ memory: memoryDigest(cwd),
624
+ stats,
625
+ });
626
+ if (stats.input || stats.output)
627
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
628
+ await closeMcp();
629
+ return;
630
+ }
631
+ // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
632
+ const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
633
+ out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
634
+ const rl = createInterface({
635
+ input: stdin,
636
+ output: stdout,
637
+ completer: (line) => {
638
+ const sm = /^\/(\w*)$/.exec(line); // `/<partial>` → complete command names
639
+ if (sm) {
640
+ const q = sm[1].toLowerCase();
641
+ return [[...byName.keys()].filter((n) => n.startsWith(q)).sort().map((n) => "/" + n), line];
642
+ }
643
+ return mentionCompleter(line, cwd);
644
+ },
645
+ });
646
+ const confirm = async (q) => (await rl.question(`${q} ${c.dim("[y/N]")} `)).trim().toLowerCase().startsWith("y");
647
+ // shift+tab cycles the approval mode (classic REPL only; the TUI handles its own keys).
648
+ // Bare /approval is the reliable fallback everywhere.
649
+ if (stdin.isTTY && !useTui) {
650
+ try {
651
+ emitKeypressEvents(stdin);
652
+ stdin.on("keypress", (_s, key) => {
653
+ if (key && key.shift && key.name === "tab") {
654
+ approval = bar.nextMode(approval);
655
+ if (bar.isActive())
656
+ bar.update({ approval });
657
+ }
658
+ else if (key?.name === "escape" && currentTurn) {
659
+ currentTurn.abort(); // interrupt the running turn
660
+ }
661
+ });
662
+ }
663
+ catch {
664
+ /* keypress unavailable; /approval still works */
665
+ }
666
+ }
667
+ if (!hasAgentsMd(cwd)) {
668
+ const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
669
+ if (ans === "" || ans.startsWith("y")) {
670
+ out(c.dim("Analyzing project…\n"));
671
+ try {
672
+ await runInit(provider, cwd, sandbox);
673
+ }
674
+ catch (e) {
675
+ out(c.red(`[init error] ${e.message}\n`));
676
+ }
677
+ }
678
+ }
679
+ let projectContext = loadAgentsMd(cwd) || undefined;
680
+ const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
681
+ // session: --resume <id> / --continue (latest in this cwd) / new
682
+ let resumed = null;
683
+ if (opts.resume) {
684
+ const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
685
+ resumed = rid ? loadSession(rid) : null;
686
+ if (!resumed)
687
+ out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
688
+ }
689
+ else if (opts.continue) {
690
+ resumed = latestForCwd(cwd);
691
+ if (!resumed)
692
+ out(c.dim("(no prior session in this directory; starting fresh)\n"));
693
+ }
694
+ const meta = resumed?.meta ?? {
695
+ id: newSessionId(),
696
+ cwd,
697
+ provider: cfg.provider,
698
+ model: cfg.model,
699
+ title: "",
700
+ createdAt: new Date().toISOString(),
701
+ updatedAt: "",
702
+ };
703
+ const history = resumed?.history ? [...resumed.history] : [];
704
+ const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
705
+ const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
706
+ if (resumed)
707
+ out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs)\n`));
708
+ // Vision describer state — shared by the `/vision` command (both REPLs) and the TUI image pipeline.
709
+ let visionProvider;
710
+ let remindedVision = false;
711
+ /** `/vision <model>` sets the describer; `/vision main yes|no|auto` sets the current model's capability. */
712
+ const applyVision = (arg) => {
713
+ const parts = arg.trim().split(/\s+/).filter(Boolean);
714
+ if (parts.length === 0) {
715
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
716
+ return `vision — main ${cfg.model}: ${cap}${cap === "unknown" ? " (asks on first image)" : ""} · describer: ${cfg.visionModel || "(none — /vision <model>)"}`;
717
+ }
718
+ if (parts[0] === "main") {
719
+ const v = parts[1];
720
+ if (!v || !["yes", "no", "auto"].includes(v))
721
+ return "usage: /vision main yes|no|auto";
722
+ if (v === "auto") {
723
+ const m = { ...cfg.modelVision };
724
+ delete m[cfg.model];
725
+ cfg.modelVision = m;
726
+ setModelVisionOverride(cfg.model, null);
727
+ }
728
+ else {
729
+ cfg.modelVision = { ...cfg.modelVision, [cfg.model]: v };
730
+ setModelVisionOverride(cfg.model, v);
731
+ }
732
+ return `(${cfg.model} vision = ${v})`;
733
+ }
734
+ const model = parts.join(" ");
735
+ cfg.visionModel = model;
736
+ visionProvider = undefined; // rebuild the describer with the new model
737
+ writeConfigValue("visionModel", model);
738
+ const warn = classifyVision(cfg.provider, model, cfg.modelVision) !== "vision" ? ` ⚠ ${model} isn't a known vision model — if it can't read images, pick a *-vl / vision model.` : "";
739
+ return `(visionModel → ${model}; text-only main models describe pasted images with it)${warn}`;
740
+ };
741
+ const commands = [
742
+ { name: "help", desc: "show this help", run: () => void out(helpText(commands)) },
743
+ {
744
+ name: "init",
745
+ desc: "analyze project & regenerate AGENTS.md",
746
+ run: async () => {
747
+ out(c.dim("Analyzing project…\n"));
748
+ try {
749
+ await runInit(provider, cwd, sandbox);
750
+ projectContext = loadAgentsMd(cwd) || undefined;
751
+ out(c.green("AGENTS.md updated.\n"));
752
+ }
753
+ catch (e) {
754
+ out(c.red(`[init error] ${e.message}\n`));
755
+ }
756
+ },
757
+ },
758
+ {
759
+ name: "tools",
760
+ desc: "list available tools",
761
+ run: () => {
762
+ out(c.bold("Tools:\n"));
763
+ for (const t of getTools())
764
+ out(` ${t.name}${t.kind !== "read" ? c.yellow(" *") : ""} ${c.dim(t.description)}\n`);
765
+ out(c.dim(" * may prompt for confirmation (depends on approval mode)\n"));
766
+ },
767
+ },
768
+ {
769
+ name: "model",
770
+ desc: "show or switch model: /model [id]",
771
+ run: async (a) => {
772
+ if (a) {
773
+ cfg.model = a;
774
+ visionProvider = undefined;
775
+ remindedVision = false;
776
+ const p = await buildProvider(cfg);
777
+ if (p) {
778
+ provider = p;
779
+ if (bar.isActive())
780
+ bar.update({ model: a });
781
+ out(c.dim(`(model → ${cfg.provider}:${a})\n`));
782
+ }
783
+ else
784
+ out(c.red("(could not rebuild provider)\n"));
785
+ }
786
+ else
787
+ out(`${cfg.provider}:${cfg.model}\n`);
788
+ },
789
+ },
790
+ {
791
+ name: "vision",
792
+ desc: "vision describer: /vision <model> · /vision main yes|no|auto",
793
+ run: (a) => void out(applyVision(a || "") + "\n"),
794
+ },
795
+ {
796
+ name: "approval",
797
+ desc: `cycle/set approval: /approval [${APPROVAL_MODES.join("|")}]`,
798
+ run: (a) => {
799
+ if (a) {
800
+ if (APPROVAL_MODES.includes(a))
801
+ approval = a;
802
+ else
803
+ return void out(c.red(`Invalid mode. One of: ${APPROVAL_MODES.join(", ")}\n`));
804
+ }
805
+ else {
806
+ approval = bar.nextMode(approval); // bare /approval cycles
807
+ }
808
+ bar.update({ approval });
809
+ out(c.dim(`(approval → ${approval})\n`));
810
+ },
811
+ },
812
+ { name: "usage", desc: "show token usage this session", run: () => void out(statusLine(cfg.model, stats.input, stats.output) + "\n") },
813
+ { name: "doctor", desc: "check your hara setup", run: () => void out(runDoctor(cfg) + "\n") },
814
+ {
815
+ name: "roles",
816
+ desc: "list org roles",
817
+ run: () => {
818
+ const rs = loadRoles(cwd);
819
+ if (!rs.length)
820
+ return void out(c.dim("No roles. Run `hara roles init`.\n"));
821
+ for (const r of rs)
822
+ out(` ${r.id} ${c.dim("owns: " + r.owns.join(", "))}\n`);
823
+ },
824
+ },
825
+ {
826
+ name: "skills",
827
+ desc: "list available skills",
828
+ run: () => {
829
+ const ss = loadSkillIndex(cwd);
830
+ if (!ss.length)
831
+ return void out(c.dim("No skills. Run `hara skills init`.\n"));
832
+ for (const s of ss)
833
+ out(` ${s.id} ${c.dim(s.description)}\n`);
834
+ },
835
+ },
836
+ {
837
+ name: "skill",
838
+ desc: "load a skill's instructions into your next message: /skill <id>",
839
+ run: (a) => {
840
+ if (!a)
841
+ return void out(c.dim("usage: /skill <id>\n"));
842
+ const sk = loadSkillIndex(cwd).find((s) => s.id === a.trim());
843
+ if (!sk)
844
+ return void out(c.dim(`(no skill '${a.trim()}')\n`));
845
+ recalledContext += (recalledContext ? "\n\n" : "") + `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}`;
846
+ out(c.green(`↗ loaded skill ${sk.id} (added to your next message)\n`));
847
+ },
848
+ },
849
+ {
850
+ name: "org",
851
+ desc: "dispatch a task to the owning role: /org <task>",
852
+ run: async (a) => {
853
+ if (!a)
854
+ return void out(c.dim("usage: /org <task>\n"));
855
+ await runOrg(a, { cfg, baseProvider: provider, cwd, sandbox, approval, confirm, projectContext, stats });
856
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
857
+ },
858
+ },
859
+ {
860
+ name: "plan",
861
+ desc: "decompose + execute a task as atoms (DAG + verify): /plan <task>",
862
+ run: async (a) => {
863
+ if (!a)
864
+ return void out(c.dim("usage: /plan <task>\n"));
865
+ await runPlan(a, { cfg, baseProvider: provider, cwd, sandbox, approval, confirm, projectContext, stats });
866
+ if (bar.isActive())
867
+ bar.update({ input: stats.input, output: stats.output, ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0) });
868
+ else
869
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n");
870
+ },
871
+ },
872
+ {
873
+ name: "sessions",
874
+ desc: "list saved sessions",
875
+ run: () => {
876
+ const ms = listSessions();
877
+ if (!ms.length)
878
+ return void out(c.dim("No sessions yet.\n"));
879
+ for (const m of ms)
880
+ out(` ${shortId(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.title || "(untitled)"}\n`);
881
+ },
882
+ },
883
+ {
884
+ name: "undo",
885
+ desc: "revert the last file change(s) made this session",
886
+ run: async () => {
887
+ const r = await undoLast();
888
+ if ("error" in r)
889
+ return void out(c.dim(`(${r.error})\n`));
890
+ out(c.green(`↩ reverted: ${r.files.join(", ")}\n`));
891
+ },
892
+ },
893
+ {
894
+ name: "compact",
895
+ desc: "summarize the conversation so far to free up context",
896
+ run: async () => {
897
+ if (history.length < 2)
898
+ return void out(c.dim("(nothing to compact)\n"));
899
+ out(c.dim("Compacting…\n"));
900
+ const r = await provider.turn({
901
+ system: COMPACT_SYSTEM,
902
+ history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
903
+ tools: [],
904
+ onText: () => { },
905
+ });
906
+ if (r.stop === "error")
907
+ return void out(c.red(`(compact failed: ${r.errorMsg})\n`));
908
+ const summary = r.text.trim();
909
+ if (!summary)
910
+ return void out(c.dim("(compact produced nothing)\n"));
911
+ meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects next turns
912
+ history.length = 0;
913
+ history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
914
+ stats.input += r.usage?.input ?? 0;
915
+ stats.output += r.usage?.output ?? 0;
916
+ saveSession(meta, history);
917
+ out(c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`));
918
+ },
919
+ },
920
+ {
921
+ name: "recall",
922
+ desc: "pull snippets from your code-asset library into context: /recall <query>",
923
+ run: async (a) => {
924
+ if (!a)
925
+ return void out(c.dim("usage: /recall <query>\n"));
926
+ const hits = await searchHybrid(a, cwd, { indexName: "assets", roots: assetSearchRoots(cwd), limit: 3 });
927
+ if (!hits.length)
928
+ return void out(c.dim(`(no matches in ${assetsDir()})\n`));
929
+ const block = hits.map((h) => `Recalled \`${h.path}\` (${h.title}):\n${h.snippet}`).join("\n\n");
930
+ recalledContext += (recalledContext ? "\n\n" : "") + block;
931
+ out(c.green(`↗ recalled ${hits.length}: ${hits.map((h) => h.path).join(", ")} (added to your next message)\n`));
932
+ },
933
+ },
934
+ {
935
+ name: "name",
936
+ desc: "rename this session: /name <name>",
937
+ run: (a) => {
938
+ if (!a)
939
+ return void out(c.dim(`session: ${meta.title || "(untitled)"} · ${meta.id}\n`));
940
+ meta.title = a.slice(0, 32);
941
+ if (bar.isActive())
942
+ bar.update({ sessionName: meta.title });
943
+ saveSession(meta, history);
944
+ out(c.green(`(renamed → ${meta.title})\n`));
945
+ },
946
+ },
947
+ { name: "reset", aliases: ["clear"], desc: "clear conversation context", run: () => void ((history.length = 0), (recalledContext = ""), out(c.dim("(context cleared)\n"))) },
948
+ { name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
949
+ ];
950
+ const byName = new Map();
951
+ for (const cmd of commands) {
952
+ byName.set(cmd.name, cmd);
953
+ for (const a of cmd.aliases ?? [])
954
+ byName.set(a, cmd);
955
+ }
956
+ if (useTui) {
957
+ rl.close(); // hand stdin over to ink
958
+ setTheme(cfg.theme);
959
+ // Vision: a text-only main model routes pasted images through a describer (`visionModel`); a
960
+ // vision-capable main model gets them inline (describer auto-suspended). Unknown models are asked
961
+ // once and remembered per-model in cfg.modelVision. See classifyVision for the capability map.
962
+ const getVisionProvider = async () => {
963
+ if (visionProvider !== undefined)
964
+ return visionProvider;
965
+ visionProvider = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
966
+ return visionProvider;
967
+ };
968
+ // lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model)
969
+ const describeScreenshot = async (path) => {
970
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
971
+ const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
972
+ if (!vp)
973
+ return "";
974
+ try {
975
+ return await describeImages(vp, [{ path, mediaType: "image/png" }]);
976
+ }
977
+ catch {
978
+ return "";
979
+ }
980
+ };
981
+ const remindVision = (sink) => {
982
+ if (remindedVision)
983
+ return void sink.notice(`⚠ image skipped — ${cfg.model} is text-only. Add a vision model: /vision <model>`);
984
+ remindedVision = true;
985
+ sink.notice(`⚠ ${cfg.model} is text-only and can't see images, so your image was skipped.\n` +
986
+ ` Add a vision model to read images for it:\n` +
987
+ ` /vision qwen-vl-max ← sets it now (uses your current plan/key) and remembers it\n` +
988
+ ` It OCRs/describes each pasted image into text the model can act on.`);
989
+ };
990
+ const resolveImages = async (imgs, h) => {
991
+ if (!imgs?.length)
992
+ return {};
993
+ let cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
994
+ if (cap === "unknown") {
995
+ const ans = await h.select(`Can your model "${cfg.model}" understand images (vision)?`, [
996
+ { label: "Yes — send images to it directly", value: "yes" },
997
+ { label: "No — describe them with a vision model first", value: "no" },
998
+ { label: "Skip the image this time", value: "skip" },
999
+ ]);
1000
+ if (ans === "skip")
1001
+ return { skip: true };
1002
+ cap = ans === "yes" ? "vision" : "text";
1003
+ cfg.modelVision = { ...cfg.modelVision, [cfg.model]: ans };
1004
+ setModelVisionOverride(cfg.model, ans);
1005
+ h.sink.notice(`(remembered: ${cfg.model} ${ans === "yes" ? "supports images" : "is text-only"})`);
1006
+ }
1007
+ if (cap === "vision")
1008
+ return { attach: imgs }; // native vision — describer suspended
1009
+ if (!cfg.visionModel) {
1010
+ remindVision(h.sink);
1011
+ return { skip: true };
1012
+ }
1013
+ const vp = await getVisionProvider();
1014
+ if (!vp) {
1015
+ h.sink.notice(`(visionModel ${cfg.visionModel} unavailable — check visionApiKey/visionBaseURL)`);
1016
+ return { skip: true };
1017
+ }
1018
+ h.sink.notice(`✻ reading ${imgs.length} image${imgs.length === 1 ? "" : "s"} with ${cfg.visionModel}…`);
1019
+ try {
1020
+ const desc = await describeImages(vp, imgs, { signal: h.signal });
1021
+ return { extraText: `\n\n[Image description — via ${cfg.visionModel}]\n${desc}` };
1022
+ }
1023
+ catch (e) {
1024
+ const msg = h.signal?.aborted ? "image describe cancelled" : `image describe failed: ${e instanceof Error ? e.message : String(e)}`;
1025
+ h.sink.notice(`(${msg})`);
1026
+ return { skip: true };
1027
+ }
1028
+ };
1029
+ const mainCap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
1030
+ const visionLine = mainCap === "vision"
1031
+ ? `${cfg.model} reads images directly`
1032
+ : cfg.visionModel
1033
+ ? `${cfg.model} is text-only → images read by ${cfg.visionModel}`
1034
+ : mainCap === "text"
1035
+ ? `${cfg.model} is text-only — /vision <model> to read pasted images`
1036
+ : `${cfg.model} image support unknown — asked on first paste`;
1037
+ await runTui({
1038
+ initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
1039
+ model: cfg.model,
1040
+ cwd,
1041
+ header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
1042
+ cycleApproval: (m) => cycleMode(m),
1043
+ onClipboardImage: readClipboardImage,
1044
+ onSubmit: async (line, h, images) => {
1045
+ if (line.startsWith("/")) {
1046
+ const [nm, ...rest] = line.slice(1).split(/\s+/);
1047
+ const arg = rest.join(" ").trim();
1048
+ if (nm === "exit" || nm === "quit") {
1049
+ if (cfg.evolve === "proactive" && history.length >= 4) {
1050
+ h.sink.notice("✻ distilling session learnings…");
1051
+ try {
1052
+ await runAgent(history, {
1053
+ provider,
1054
+ 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 } },
1055
+ approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
1056
+ confirm: h.confirm,
1057
+ toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
1058
+ systemOverride: DISTILL_SYSTEM,
1059
+ memory: buildMemory(),
1060
+ stats,
1061
+ signal: h.signal,
1062
+ });
1063
+ saveSession(meta, history);
1064
+ }
1065
+ catch {
1066
+ /* exit anyway */
1067
+ }
1068
+ }
1069
+ return void h.exit();
1070
+ }
1071
+ if (nm === "help")
1072
+ return void h.sink.notice(commands.map((x) => `/${x.name} — ${x.desc}`).join("\n"));
1073
+ if (nm === "tools")
1074
+ return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
1075
+ if (nm === "reset" || nm === "clear") {
1076
+ history.length = 0;
1077
+ recalledContext = "";
1078
+ return void h.sink.notice("(context cleared)");
1079
+ }
1080
+ if (nm === "undo") {
1081
+ const r = await undoLast();
1082
+ return void h.sink.notice("error" in r ? `(${r.error})` : `↩ reverted: ${r.files.join(", ")}`);
1083
+ }
1084
+ if (nm === "model") {
1085
+ if (!arg)
1086
+ return void h.sink.notice(`model: ${cfg.provider}:${cfg.model}`);
1087
+ cfg.model = arg;
1088
+ visionProvider = undefined; // new model may resolve a different describer / capability
1089
+ remindedVision = false;
1090
+ const p = await buildProvider(cfg);
1091
+ if (p) {
1092
+ provider = p;
1093
+ return void h.sink.notice(`(model → ${cfg.provider}:${arg})`);
1094
+ }
1095
+ return void h.sink.notice("(could not rebuild provider)");
1096
+ }
1097
+ if (nm === "recall") {
1098
+ if (!arg)
1099
+ return void h.sink.notice("usage: /recall <query>");
1100
+ const hits = await searchHybrid(arg, cwd, { indexName: "assets", roots: assetSearchRoots(cwd), limit: 3 });
1101
+ if (!hits.length)
1102
+ return void h.sink.notice(`(no matches in ${assetsDir()})`);
1103
+ recalledContext += (recalledContext ? "\n\n" : "") + hits.map((x) => `Recalled \`${x.path}\` (${x.title}):\n${x.snippet}`).join("\n\n");
1104
+ return void h.sink.notice(`↗ recalled ${hits.length}: ${hits.map((x) => x.path).join(", ")} (added to your next message)`);
1105
+ }
1106
+ if (nm === "name") {
1107
+ if (!arg)
1108
+ return void h.sink.notice(`session: ${meta.title || "(untitled)"} · ${meta.id}`);
1109
+ meta.title = arg.slice(0, 32);
1110
+ h.sink.session(meta.title);
1111
+ saveSession(meta, history);
1112
+ return void h.sink.notice(`(renamed → ${meta.title})`);
1113
+ }
1114
+ if (nm === "compact") {
1115
+ if (history.length < 2)
1116
+ return void h.sink.notice("(nothing to compact)");
1117
+ h.sink.notice("✻ compacting…");
1118
+ const cui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1119
+ if (cfg.evolve !== "off") {
1120
+ try {
1121
+ await runAgent(history, {
1122
+ provider,
1123
+ ctx: { cwd, sandbox, spawn, ui: cui },
1124
+ approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
1125
+ confirm: h.confirm,
1126
+ toolFilter: (n) => n === "memory_write" || (cfg.assetCapture !== "off" && n === "skill_create") || READONLY_TOOLS.has(n),
1127
+ systemOverride: DISTILL_SYSTEM,
1128
+ memory: buildMemory(),
1129
+ stats,
1130
+ signal: h.signal,
1131
+ });
1132
+ }
1133
+ catch {
1134
+ /* flush is best-effort */
1135
+ }
1136
+ }
1137
+ const cr = await provider.turn({
1138
+ system: COMPACT_SYSTEM,
1139
+ history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
1140
+ tools: [],
1141
+ onText: () => { },
1142
+ });
1143
+ if (cr.stop === "error")
1144
+ return void h.sink.notice(`(compact failed: ${cr.errorMsg})`);
1145
+ const summary = cr.text.trim();
1146
+ if (!summary)
1147
+ return void h.sink.notice("(compact produced nothing)");
1148
+ meta.workingSet = workingSetFromSummary(summary);
1149
+ history.length = 0;
1150
+ history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
1151
+ stats.input += cr.usage?.input ?? 0;
1152
+ stats.output += cr.usage?.output ?? 0;
1153
+ h.sink.usage(cr.usage?.input ?? 0, cr.usage?.output ?? 0);
1154
+ saveSession(meta, history);
1155
+ return void h.sink.notice(`(compacted — kept ${meta.workingSet.length} working-memory notes)`);
1156
+ }
1157
+ if (nm === "sessions") {
1158
+ const ms = listSessions();
1159
+ return void h.sink.notice(ms.length ? ms.slice(0, 12).map((m) => ` ${shortId(m.id)} ${m.updatedAt.slice(0, 16).replace("T", " ")} ${m.title || "(untitled)"}`).join("\n") : "No sessions yet.");
1160
+ }
1161
+ if (nm === "usage")
1162
+ return void h.sink.notice(`tokens — ↑${stats.input} ↓${stats.output}`);
1163
+ if (nm === "doctor")
1164
+ return void h.sink.notice(runDoctor(cfg).replace(/\[[0-9;]*m/g, ""));
1165
+ if (nm === "vision")
1166
+ return void h.sink.notice(applyVision(arg));
1167
+ if (nm === "roles") {
1168
+ const rs = loadRoles(cwd);
1169
+ return void h.sink.notice(rs.length ? rs.map((r) => ` ${r.id} — owns: ${r.owns.join(", ")}`).join("\n") : "No roles. Run `hara roles init`.");
1170
+ }
1171
+ if (nm === "skills") {
1172
+ const ss = loadSkillIndex(cwd);
1173
+ return void h.sink.notice(ss.length ? ss.map((s) => ` ${s.id} — ${s.description}`).join("\n") : "No skills. Run `hara skills init`.");
1174
+ }
1175
+ if (nm === "skill") {
1176
+ if (!arg)
1177
+ return void h.sink.notice("usage: /skill <id>");
1178
+ const sk = loadSkillIndex(cwd).find((s) => s.id === arg.trim());
1179
+ if (!sk)
1180
+ return void h.sink.notice(`(no skill '${arg.trim()}')`);
1181
+ recalledContext += (recalledContext ? "\n\n" : "") + `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}`;
1182
+ return void h.sink.notice(`↗ loaded skill ${sk.id} (added to your next message)`);
1183
+ }
1184
+ if (nm === "approval") {
1185
+ const all = ["suggest", "auto-edit", "full-auto", "plan"];
1186
+ if (arg && !all.includes(arg))
1187
+ return void h.sink.notice(`Invalid mode. One of: ${all.join(", ")}`);
1188
+ const m = (arg || cycleMode(h.approval));
1189
+ h.setApproval(m);
1190
+ return void h.sink.notice(`(approval → ${m})`);
1191
+ }
1192
+ if (byName.has(nm))
1193
+ return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
1194
+ const near = nearest(nm, [...byName.keys()]);
1195
+ return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
1196
+ }
1197
+ const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
1198
+ const appr = h.approval;
1199
+ if (appr === "plan") {
1200
+ // PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
1201
+ const planImg = await resolveImages(images, h);
1202
+ if (planImg.skip)
1203
+ return;
1204
+ history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
1205
+ recalledContext = "";
1206
+ const pin = stats.input;
1207
+ const pout = stats.output;
1208
+ await runAgent(history, {
1209
+ provider,
1210
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1211
+ approval: "suggest",
1212
+ confirm: h.confirm,
1213
+ toolFilter: (n) => READONLY_TOOLS.has(n),
1214
+ systemOverride: PLAN_SYSTEM,
1215
+ memory: buildMemory(),
1216
+ projectContext,
1217
+ stats,
1218
+ signal: h.signal,
1219
+ });
1220
+ if (!meta.title) {
1221
+ meta.title = titleFrom(history);
1222
+ h.sink.session(meta.title);
1223
+ }
1224
+ h.sink.usage(stats.input - pin, stats.output - pout);
1225
+ saveSession(meta, history);
1226
+ const choice = await h.select("hara has a plan — proceed?", [
1227
+ { label: "Yes, and auto-apply edits", value: "auto-edit" },
1228
+ { label: "Yes, approve each edit", value: "suggest" },
1229
+ { label: "No, keep planning (esc)", value: "no" },
1230
+ ]);
1231
+ if (choice !== "no") {
1232
+ h.setApproval(choice);
1233
+ history.push({ role: "user", content: "Proceed: execute the plan above." });
1234
+ const xin = stats.input;
1235
+ const xout = stats.output;
1236
+ await runAgent(history, {
1237
+ provider,
1238
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1239
+ approval: choice,
1240
+ memory: buildMemory(),
1241
+ confirm: h.confirm,
1242
+ autoApprove,
1243
+ projectContext,
1244
+ stats,
1245
+ signal: h.signal,
1246
+ });
1247
+ h.sink.usage(stats.input - xin, stats.output - xout);
1248
+ saveSession(meta, history);
1249
+ }
1250
+ return;
1251
+ }
1252
+ const ri = await resolveImages(images, h);
1253
+ if (ri.skip)
1254
+ return;
1255
+ const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
1256
+ recalledContext = "";
1257
+ history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
1258
+ const beforeIn = stats.input;
1259
+ const beforeOut = stats.output;
1260
+ await runAgent(history, {
1261
+ provider,
1262
+ ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot },
1263
+ approval: appr,
1264
+ memory: buildMemory(),
1265
+ confirm: h.confirm,
1266
+ autoApprove,
1267
+ projectContext,
1268
+ stats,
1269
+ signal: h.signal,
1270
+ });
1271
+ if (!meta.title) {
1272
+ meta.title = titleFrom(history);
1273
+ h.sink.session(meta.title);
1274
+ }
1275
+ h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
1276
+ saveSession(meta, history);
1277
+ },
1278
+ });
1279
+ await closeMcp();
1280
+ process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
1281
+ }
1282
+ out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${projectContext ? " (AGENTS.md loaded)" : ""}\n\n`));
1283
+ bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output });
1284
+ process.on("exit", () => {
1285
+ try {
1286
+ bar.uninstall();
1287
+ }
1288
+ catch {
1289
+ /* best-effort terminal reset */
1290
+ }
1291
+ });
1292
+ for (;;) {
1293
+ bar.renderTop(); // top border + session name
1294
+ let line;
1295
+ try {
1296
+ line = (await rl.question(c.cyan("› "))).trim();
1297
+ }
1298
+ catch {
1299
+ break;
1300
+ }
1301
+ bar.renderBottom(); // bottom border + modes/usage
1302
+ if (!line)
1303
+ continue;
1304
+ if (line.startsWith("/")) {
1305
+ const [name, ...rest] = line.slice(1).split(/\s+/);
1306
+ const cmd = byName.get(name);
1307
+ if (!cmd) {
1308
+ const near = nearest(name, [...byName.keys()]);
1309
+ const hint = near.length ? c.dim(` Did you mean ${near.map((n) => "/" + n).join(", ")}?`) : "";
1310
+ out(c.red(`Unknown command /${name}.`) + hint + c.dim(" — /help for the list.\n"));
1311
+ continue;
1312
+ }
1313
+ const res = await cmd.run(rest.join(" "));
1314
+ if (res === "exit")
1315
+ break;
1316
+ continue;
1317
+ }
1318
+ const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
1319
+ recalledContext = "";
1320
+ history.push({ role: "user", content: userContent });
1321
+ currentTurn = new AbortController();
1322
+ try {
1323
+ await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
1324
+ }
1325
+ catch (e) {
1326
+ out(c.red(`\n[error] ${e.message}\n`));
1327
+ }
1328
+ finally {
1329
+ currentTurn = null;
1330
+ }
1331
+ if (!meta.title)
1332
+ meta.title = titleFrom(history);
1333
+ if (bar.isActive()) {
1334
+ bar.update({
1335
+ sessionName: meta.title,
1336
+ input: stats.input,
1337
+ output: stats.output,
1338
+ ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0),
1339
+ });
1340
+ }
1341
+ else {
1342
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
1343
+ }
1344
+ saveSession(meta, history);
1345
+ const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
1346
+ if (ctxPct >= 80)
1347
+ out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /reset to clear\n`));
1348
+ }
1349
+ bar.uninstall();
1350
+ rl.close();
1351
+ await closeMcp();
1352
+ });
1353
+ program.parseAsync().catch((e) => {
1354
+ try {
1355
+ bar.uninstall();
1356
+ }
1357
+ catch {
1358
+ /* ignore */
1359
+ }
1360
+ out(c.red(`\n[fatal] ${e?.message ?? e}\n`));
1361
+ process.exit(1);
1362
+ });