@nanhara/hara 0.48.0 → 0.53.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +64 -0
- package/README.md +3 -0
- package/dist/agent/loop.js +16 -1
- package/dist/config.js +4 -2
- package/dist/hooks.js +64 -0
- package/dist/index.js +29 -2
- package/dist/notify.js +42 -0
- package/dist/plugins/plugins.js +14 -0
- package/dist/providers/anthropic.js +21 -11
- package/dist/tools/todo.js +51 -0
- package/dist/tools/web.js +97 -0
- package/dist/tui/App.js +15 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,70 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.53.0 — unreleased (task-done notifications + steering in plan mode)
|
|
9
|
+
|
|
10
|
+
- **Notifications** — get pinged when a turn finishes so you can walk away during a long run
|
|
11
|
+
(codex/Claude-Code parity). `hara config set notify bell` rings the terminal BEL; `notify system` fires
|
|
12
|
+
an OS notification (macOS `osascript` / Linux `notify-send`) plus the bell; default `off`. Gated on
|
|
13
|
+
elapsed time (≥8s) so quick turns you were watching stay silent. Wired into the TUI turn, plan-mode
|
|
14
|
+
execute, and the plain REPL; `hara doctor` shows the setting. New `src/notify.ts` (`notifyDone`).
|
|
15
|
+
- **Type-ahead steering now covers plan mode too.** v0.52 wired steering into the regular turn only;
|
|
16
|
+
the `pendingInput` builder is now hoisted so plan-mode *investigation* and *execution* also fold in
|
|
17
|
+
messages you type mid-turn (previously they fell back to the old wait-for-turn-end behavior — an
|
|
18
|
+
inconsistency). All three turn paths now steer.
|
|
19
|
+
|
|
20
|
+
## 0.52.0 — unreleased (type-ahead steering — mid-turn messages course-correct the live task)
|
|
21
|
+
|
|
22
|
+
- **Type-ahead now *steers* the running turn** instead of waiting for it to finish. Previously a message
|
|
23
|
+
typed while hara worked was held and replayed as a brand-new turn once the turn ended — so a
|
|
24
|
+
supplement ("also handle the error case", "use TS not JS") arrived *after* the task had already
|
|
25
|
+
finished on the old understanding, becoming rework. Now, studying how **codex** does it (its
|
|
26
|
+
`pending_input` drains at the next model-call boundary *inside* the same turn) vs **cc-haha/Claude
|
|
27
|
+
Code** (waits for full completion), hara adopts the codex model: queued messages are **folded into the
|
|
28
|
+
next model call** (drained after each tool round), so the model course-corrects mid-task. Each shows
|
|
29
|
+
inline in the transcript at the point it's folded in. Messages typed during the *final* step (no more
|
|
30
|
+
tool rounds) still start a fresh turn; **Esc** drops the queue and stops.
|
|
31
|
+
- New `RunOpts.pendingInput` (the loop drains it before each model call; unused outside the TUI = zero
|
|
32
|
+
change for `-p`/sub-agents/plain REPL). The TUI hands the queue through `Helpers.drainQueue`.
|
|
33
|
+
- **`toAnthropic` now coalesces consecutive `user` messages** — required since a steered message lands
|
|
34
|
+
right after tool-results (which map to a `user` message) and Anthropic rejects two `user` turns in a
|
|
35
|
+
row. Dormant in normal alternating histories. Unit-tested.
|
|
36
|
+
|
|
37
|
+
## 0.51.0 — unreleased (lifecycle hooks — PreToolUse / PostToolUse)
|
|
38
|
+
|
|
39
|
+
- **Hooks dispatch** — run your own shell commands around every tool call (codex / Claude-Code parity, which
|
|
40
|
+
hara lacked). A **`PreToolUse`** hook runs *before* a tool and can **veto** it (non-zero exit blocks the
|
|
41
|
+
call; its stdout/stderr becomes the denial the model sees) — e.g. forbid `bash rm -rf`, gate edits to a
|
|
42
|
+
path, require a clean tree. A **`PostToolUse`** hook runs *after* (observe-only) — e.g. `prettier` a file
|
|
43
|
+
the agent just wrote, log/notify. The command gets `{tool, payload}` as JSON on stdin + `HARA_TOOL_NAME`
|
|
44
|
+
in its env; each is matched by a `matcher` (regex/literal on the tool name, `*`/omitted = all) with a 30s
|
|
45
|
+
timeout. Configure in `config.json` `"hooks"`; **plugins can contribute hooks** too. `hara doctor` shows
|
|
46
|
+
the active count. No hooks configured = zero overhead (fast no-op).
|
|
47
|
+
|
|
48
|
+
```jsonc
|
|
49
|
+
// ~/.hara/config.json
|
|
50
|
+
"hooks": {
|
|
51
|
+
"PreToolUse": [{ "matcher": "bash", "command": "grep -q 'rm -rf' && { echo 'no rm -rf'; exit 1; } || exit 0" }],
|
|
52
|
+
"PostToolUse": [{ "matcher": "edit_file|write_file", "command": "prettier --write \"$(jq -r .payload.input.path)\" 2>/dev/null; exit 0" }]
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 0.50.0 — unreleased (web_search — find pages, not just fetch)
|
|
57
|
+
|
|
58
|
+
- New **`web_search`** tool — search the web (title/URL/snippet), then `web_fetch` a result to read it. Closes
|
|
59
|
+
the other codex/cc-haha gap (hara could previously only fetch a *known* URL). **Reliable with a Tavily key**
|
|
60
|
+
(`HARA_SEARCH_API_KEY` / `TAVILY_API_KEY`, free tier); a **keyless DuckDuckGo** fallback works best-effort
|
|
61
|
+
(POST endpoint; may rate-limit). Read-kind, available to sub-agents. Verified live (keyless: "anthropic
|
|
62
|
+
claude" → real results); parser unit-tested (incl. the DDG `uddg` redirect decode).
|
|
63
|
+
|
|
64
|
+
## 0.49.0 — unreleased (inline todo tool — `todo_write`)
|
|
65
|
+
|
|
66
|
+
- New **`todo_write`** tool — the agent maintains a live task checklist during multi-step work (codex's
|
|
67
|
+
`update_plan` / Claude Code's `TodoWrite`, which hara lacked). Plan up front, keep one item `in_progress`,
|
|
68
|
+
flip to `done` as you go; pass the full list each call. Read-kind (never prompts); the system prompt nudges
|
|
69
|
+
its use for multi-step tasks; sub-agents can use it too. Renders a `☐/▶/☑` checklist with a done count.
|
|
70
|
+
*(Gap analysis vs codex + cc-haha: this was the top missing capability.)*
|
|
71
|
+
|
|
8
72
|
## 0.48.0 — unreleased (chrome plugin: drive your real logged-in Chrome)
|
|
9
73
|
|
|
10
74
|
- New first-party **`chrome` plugin** — web automation via **`chrome-devtools-mcp`** against a **real Chrome with
|
package/README.md
CHANGED
|
@@ -179,6 +179,8 @@ by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-
|
|
|
179
179
|
vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
|
|
180
180
|
**Sessions**: conversations are saved automatically — `-c` / `--resume <id>` to continue, `hara sessions` to list.
|
|
181
181
|
**MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`.
|
|
182
|
+
**Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
|
|
183
|
+
**Hooks**: run your own shell commands around tool calls via a `"hooks"` map in config. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
|
|
182
184
|
**Profiles**: add a `profiles` map to `~/.hara/config.json` (`--profile <name>`), or drop a project-level `.hara/config.json` that overrides the global config.
|
|
183
185
|
|
|
184
186
|
### The org — what makes hara different
|
|
@@ -208,6 +210,7 @@ read-only **`grep`** / **`glob`** / **`ls`** / **`web_fetch`** — behind a huma
|
|
|
208
210
|
dangerous ones unless `-y`. Read-only tools run in parallel within a turn, and edits print a
|
|
209
211
|
**colored diff** of what changed. Shell output streams live; press **Esc** to interrupt a running
|
|
210
212
|
turn, or **`/undo`** to revert the last edit.
|
|
213
|
+
- **Type-ahead steering**: keep typing while hara works — your message is held, then **folded into the next model call** (not deferred to a new turn), so a clarification or "also do X" course-corrects the task already in flight (codex-style). Messages typed after the final step start a fresh turn; **Esc** drops the queue and stops.
|
|
211
214
|
- **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
|
|
212
215
|
- **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
|
|
213
216
|
- **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
|
package/dist/agent/loop.js
CHANGED
|
@@ -4,6 +4,7 @@ import { c, out } from "../ui.js";
|
|
|
4
4
|
import { activity } from "../activity.js";
|
|
5
5
|
import { makeRenderer } from "../md.js";
|
|
6
6
|
import { skillsDigest } from "../skills/skills.js";
|
|
7
|
+
import { runHooks } from "../hooks.js";
|
|
7
8
|
/** Whether a tool call needs user confirmation under the given approval mode. */
|
|
8
9
|
export function needsConfirm(kind, mode) {
|
|
9
10
|
if (kind === "read")
|
|
@@ -20,7 +21,9 @@ const HARA_SYSTEM = (cwd) => `You are hara, a coding agent running in the user's
|
|
|
20
21
|
Working directory: ${cwd}
|
|
21
22
|
Be concise and direct. Use the provided tools to read files, edit/write files, and run shell
|
|
22
23
|
commands. Prefer small, verifiable steps; edit existing files with edit_file rather than rewriting
|
|
23
|
-
them whole.
|
|
24
|
+
them whole. For a multi-step task, call \`todo_write\` to plan a short checklist and keep it updated as
|
|
25
|
+
you go (one item in_progress at a time) — skip it for trivial one-step tasks. You have a persistent
|
|
26
|
+
memory: use memory_search before answering about prior decisions,
|
|
24
27
|
conventions, or the user's preferences, and memory_write to proactively save durable facts you learn.
|
|
25
28
|
When a task matches one of the Skills listed below, call the \`skill\` tool to load its full instructions
|
|
26
29
|
before acting; save a reusable how-to as a new skill with skill_create. If you discover a durable project
|
|
@@ -38,6 +41,12 @@ function composeSystem(cwd, projectContext, override, memory) {
|
|
|
38
41
|
export async function runAgent(history, opts) {
|
|
39
42
|
const { provider, ctx } = opts;
|
|
40
43
|
for (;;) {
|
|
44
|
+
// Type-ahead steering: fold in anything the user submitted while the previous step ran, so it
|
|
45
|
+
// reaches the model on this next call (drained after the last tool round; empty on the 1st pass).
|
|
46
|
+
if (opts.pendingInput) {
|
|
47
|
+
for (const m of await opts.pendingInput())
|
|
48
|
+
history.push(m);
|
|
49
|
+
}
|
|
41
50
|
const specs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
|
|
42
51
|
const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
|
|
43
52
|
const tty = stdout.isTTY && !opts.quiet && !sink;
|
|
@@ -154,8 +163,14 @@ export async function runAgent(history, opts) {
|
|
|
154
163
|
}
|
|
155
164
|
activity.inc();
|
|
156
165
|
try {
|
|
166
|
+
const pre = runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
|
|
167
|
+
if (pre.block) {
|
|
168
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
157
171
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
158
172
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: res };
|
|
173
|
+
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
159
174
|
}
|
|
160
175
|
catch (e) {
|
|
161
176
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: `Error: ${e.message}`, isError: true };
|
package/dist/config.js
CHANGED
|
@@ -11,7 +11,7 @@ const PROVIDER_DEFAULTS = {
|
|
|
11
11
|
"qwen-oauth": { model: "coder-model", envKey: "QWEN_OAUTH_TOKEN" },
|
|
12
12
|
openai: { model: "gpt-4o-mini", envKey: "OPENAI_API_KEY" },
|
|
13
13
|
};
|
|
14
|
-
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey"];
|
|
14
|
+
export const CONFIG_KEYS = ["provider", "apiKey", "model", "baseURL", "approval", "sandbox", "theme", "evolve", "assetCapture", "computerUse", "computerApps", "visionModel", "visionBaseURL", "visionApiKey", "embedProvider", "embedModel", "embedBaseURL", "embedApiKey", "notify"];
|
|
15
15
|
export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
|
|
16
16
|
export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
|
|
17
17
|
const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
|
|
@@ -107,7 +107,9 @@ export function loadConfig(opts = {}) {
|
|
|
107
107
|
...(project.mcpServers ?? {}),
|
|
108
108
|
...(profile.mcpServers ?? {}),
|
|
109
109
|
};
|
|
110
|
-
|
|
110
|
+
const hooks = (merged.hooks && typeof merged.hooks === "object" ? merged.hooks : {});
|
|
111
|
+
const notify = (process.env.HARA_NOTIFY ?? merged.notify ?? "off");
|
|
112
|
+
return { provider, apiKey, model, baseURL, approval, sandbox, theme, evolve, assetCapture, computerUse, computerApps, visionModel, visionBaseURL, visionApiKey, modelVision, embedProvider, embedModel, embedBaseURL, embedApiKey, hooks, notify, mcpServers, cwd: process.cwd() };
|
|
111
113
|
}
|
|
112
114
|
export function providerEnvKey(provider) {
|
|
113
115
|
return (PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic).envKey;
|
package/dist/hooks.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// Lifecycle hooks — run user/plugin shell commands around tool calls (codex/Claude-Code parity).
|
|
2
|
+
// PreToolUse runs BEFORE a tool: a non-zero exit BLOCKS the call (its output becomes the denial message).
|
|
3
|
+
// PostToolUse runs AFTER: observe-only (format, log, notify). Configured in config.json `hooks` + contributed
|
|
4
|
+
// by plugins. The command receives {tool, payload} as JSON on stdin + HARA_TOOL_NAME in the env.
|
|
5
|
+
import { spawnSync } from "node:child_process";
|
|
6
|
+
import { loadConfig } from "./config.js";
|
|
7
|
+
import { pluginHooks } from "./plugins/plugins.js";
|
|
8
|
+
let cache = null;
|
|
9
|
+
export function resetHooksCache() {
|
|
10
|
+
cache = null;
|
|
11
|
+
}
|
|
12
|
+
function merged() {
|
|
13
|
+
if (cache)
|
|
14
|
+
return cache;
|
|
15
|
+
const cfg = loadConfig().hooks ?? {};
|
|
16
|
+
const plg = pluginHooks();
|
|
17
|
+
cache = {
|
|
18
|
+
PreToolUse: [...(cfg.PreToolUse ?? []), ...(plg.PreToolUse ?? [])],
|
|
19
|
+
PostToolUse: [...(cfg.PostToolUse ?? []), ...(plg.PostToolUse ?? [])],
|
|
20
|
+
};
|
|
21
|
+
return cache;
|
|
22
|
+
}
|
|
23
|
+
const matches = (m, name) => {
|
|
24
|
+
if (!m || m === "*")
|
|
25
|
+
return true;
|
|
26
|
+
try {
|
|
27
|
+
return new RegExp(m).test(name);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return m === name;
|
|
31
|
+
}
|
|
32
|
+
};
|
|
33
|
+
/** True if any hook is configured (lets the loop skip the work entirely in the common case). */
|
|
34
|
+
export function hasHooks() {
|
|
35
|
+
const h = merged();
|
|
36
|
+
return !!(h.PreToolUse?.length || h.PostToolUse?.length);
|
|
37
|
+
}
|
|
38
|
+
/** Run hooks for an event matching `toolName`. PreToolUse: a non-zero exit BLOCKS (returns the message);
|
|
39
|
+
* PostToolUse: observe-only, never blocks. Sync (hooks are short, opt-in); 30s timeout each. */
|
|
40
|
+
export function runHooks(event, toolName, payload, cwd) {
|
|
41
|
+
for (const h of merged()[event] ?? []) {
|
|
42
|
+
if (!matches(h.matcher, toolName))
|
|
43
|
+
continue;
|
|
44
|
+
let r;
|
|
45
|
+
try {
|
|
46
|
+
r = spawnSync(h.command, {
|
|
47
|
+
shell: true,
|
|
48
|
+
cwd,
|
|
49
|
+
input: JSON.stringify({ tool: toolName, payload }),
|
|
50
|
+
encoding: "utf8",
|
|
51
|
+
timeout: 30_000,
|
|
52
|
+
env: { ...process.env, HARA_TOOL_NAME: toolName },
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (event === "PreToolUse" && r.status !== 0 && r.status !== null) {
|
|
59
|
+
const msg = (String(r.stdout ?? "") + String(r.stderr ?? "")).trim();
|
|
60
|
+
return { block: true, message: `⛔ blocked by a PreToolUse hook${msg ? `: ${msg}` : ` (exit ${r.status})`}` };
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { block: false, message: "" };
|
|
64
|
+
}
|
package/dist/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { fileURLToPath } from "node:url";
|
|
|
15
15
|
import { dirname, join } from "node:path";
|
|
16
16
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
17
17
|
import { runAgent } from "./agent/loop.js";
|
|
18
|
+
import { notifyDone } from "./notify.js";
|
|
18
19
|
import { getTools } from "./tools/registry.js";
|
|
19
20
|
import { createAnthropicProvider } from "./providers/anthropic.js";
|
|
20
21
|
import { createOpenAIProvider } from "./providers/openai.js";
|
|
@@ -27,7 +28,7 @@ import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
|
27
28
|
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
28
29
|
import { loadRoles, scaffoldRoles } from "./org/roles.js";
|
|
29
30
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
30
|
-
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers } from "./plugins/plugins.js";
|
|
31
|
+
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
31
32
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
32
33
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
33
34
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
@@ -46,6 +47,7 @@ import "./tools/agent.js"; // register agent (subagent spawn)
|
|
|
46
47
|
import "./tools/memory.js"; // register memory_search/get/write/forget/skill_create
|
|
47
48
|
import "./tools/skill.js"; // register the skill loader tool
|
|
48
49
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
50
|
+
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
49
51
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
50
52
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
51
53
|
const pkg = JSON.parse(readFileSync(join(here, "..", "package.json"), "utf8"));
|
|
@@ -271,7 +273,7 @@ async function runResume(o) {
|
|
|
271
273
|
savePlan(o.cwd, plan);
|
|
272
274
|
await executePlan(plan, roles, o);
|
|
273
275
|
}
|
|
274
|
-
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "codebase_search"]);
|
|
276
|
+
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
|
|
275
277
|
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
|
|
276
278
|
"issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
|
|
277
279
|
"for context. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
|
|
@@ -376,6 +378,8 @@ function runDoctor(cfg) {
|
|
|
376
378
|
`${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"}`)}`,
|
|
377
379
|
`${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>"); })()}`,
|
|
378
380
|
`${dot} mcp servers ${c.dim(String(Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length))}`,
|
|
381
|
+
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
382
|
+
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
379
383
|
];
|
|
380
384
|
return lines.join("\n");
|
|
381
385
|
}
|
|
@@ -1423,6 +1427,22 @@ program.action(async (opts) => {
|
|
|
1423
1427
|
}
|
|
1424
1428
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
1425
1429
|
const appr = h.approval;
|
|
1430
|
+
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
1431
|
+
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
1432
|
+
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
1433
|
+
const pendingInput = async () => {
|
|
1434
|
+
const out = [];
|
|
1435
|
+
for (const it of h.drainQueue()) {
|
|
1436
|
+
const r2 = await resolveImages(it.images, h);
|
|
1437
|
+
const body = expandMentions(it.line, cwd) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
1438
|
+
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
1439
|
+
if (!body.trim() && !attach)
|
|
1440
|
+
continue; // image-only message whose image was skipped → nothing to add
|
|
1441
|
+
out.push({ role: "user", content: `[I sent this while you were working on the above]\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
1442
|
+
}
|
|
1443
|
+
return out;
|
|
1444
|
+
};
|
|
1445
|
+
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
1426
1446
|
if (appr === "plan") {
|
|
1427
1447
|
// PLAN MODE: read-only investigate → propose a plan → selectable proceed → execute.
|
|
1428
1448
|
const planImg = await resolveImages(images, h);
|
|
@@ -1443,6 +1463,7 @@ program.action(async (opts) => {
|
|
|
1443
1463
|
projectContext,
|
|
1444
1464
|
stats,
|
|
1445
1465
|
signal: h.signal,
|
|
1466
|
+
pendingInput,
|
|
1446
1467
|
});
|
|
1447
1468
|
if (!meta.title) {
|
|
1448
1469
|
meta.title = await nameSession(provider, history);
|
|
@@ -1470,10 +1491,12 @@ program.action(async (opts) => {
|
|
|
1470
1491
|
projectContext,
|
|
1471
1492
|
stats,
|
|
1472
1493
|
signal: h.signal,
|
|
1494
|
+
pendingInput,
|
|
1473
1495
|
});
|
|
1474
1496
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
1475
1497
|
saveSession(meta, history);
|
|
1476
1498
|
}
|
|
1499
|
+
notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
|
|
1477
1500
|
return;
|
|
1478
1501
|
}
|
|
1479
1502
|
const ri = await resolveImages(images, h);
|
|
@@ -1494,12 +1517,14 @@ program.action(async (opts) => {
|
|
|
1494
1517
|
projectContext,
|
|
1495
1518
|
stats,
|
|
1496
1519
|
signal: h.signal,
|
|
1520
|
+
pendingInput,
|
|
1497
1521
|
});
|
|
1498
1522
|
if (!meta.title) {
|
|
1499
1523
|
meta.title = await nameSession(provider, history);
|
|
1500
1524
|
h.sink.session(meta.title);
|
|
1501
1525
|
}
|
|
1502
1526
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
1527
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
|
|
1503
1528
|
saveSession(meta, history);
|
|
1504
1529
|
},
|
|
1505
1530
|
});
|
|
@@ -1546,6 +1571,7 @@ program.action(async (opts) => {
|
|
|
1546
1571
|
recalledContext = "";
|
|
1547
1572
|
history.push({ role: "user", content: userContent });
|
|
1548
1573
|
currentTurn = new AbortController();
|
|
1574
|
+
const t0 = Date.now();
|
|
1549
1575
|
try {
|
|
1550
1576
|
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
1551
1577
|
}
|
|
@@ -1555,6 +1581,7 @@ program.action(async (opts) => {
|
|
|
1555
1581
|
finally {
|
|
1556
1582
|
currentTurn = null;
|
|
1557
1583
|
}
|
|
1584
|
+
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
|
|
1558
1585
|
if (!meta.title)
|
|
1559
1586
|
meta.title = await nameSession(provider, history);
|
|
1560
1587
|
if (bar.isActive()) {
|
package/dist/notify.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Task-done notifications — ping the user when a turn finishes (or needs them) so they can walk away
|
|
2
|
+
// during a long run (codex/Claude-Code parity). off = nothing; bell = terminal BEL; system = an OS
|
|
3
|
+
// notification (best-effort, fire-and-forget) + bell. Gated on elapsed so quick turns you watched stay quiet.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { platform } from "node:os";
|
|
6
|
+
export const NOTIFY_MODES = ["off", "bell", "system"];
|
|
7
|
+
/** AppleScript double-quoted string (escape " and \). */
|
|
8
|
+
const osaStr = (s) => '"' + s.replace(/[\\"]/g, "\\$&") + '"';
|
|
9
|
+
/** Fire a notification for a finished/awaiting turn. No-op under `off` or when the turn was quicker than
|
|
10
|
+
* `minMs` (default 8s) — you were watching those. `system` shells out without blocking and also rings the bell. */
|
|
11
|
+
export function notifyDone(mode, opts) {
|
|
12
|
+
if (mode === "off")
|
|
13
|
+
return;
|
|
14
|
+
if (opts.elapsedMs < (opts.minMs ?? 8000))
|
|
15
|
+
return;
|
|
16
|
+
const bell = () => {
|
|
17
|
+
try {
|
|
18
|
+
process.stderr.write("\x07");
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
/* no tty */
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
if (mode === "bell")
|
|
25
|
+
return bell();
|
|
26
|
+
const title = (opts.title ?? "hara").slice(0, 80);
|
|
27
|
+
const msg = opts.message.slice(0, 200).replace(/\s*\n+\s*/g, " ").trim() || "done";
|
|
28
|
+
try {
|
|
29
|
+
const os = platform();
|
|
30
|
+
if (os === "darwin") {
|
|
31
|
+
spawn("osascript", ["-e", `display notification ${osaStr(msg)} with title ${osaStr(title)}`], { stdio: "ignore", detached: true }).unref();
|
|
32
|
+
}
|
|
33
|
+
else if (os === "linux") {
|
|
34
|
+
spawn("notify-send", ["-a", "hara", title, msg], { stdio: "ignore", detached: true }).unref();
|
|
35
|
+
}
|
|
36
|
+
// Windows (and any platform): the bell is the reliable cross-terminal signal; toast needs extra modules.
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
/* best-effort — a notification must never break the turn */
|
|
40
|
+
}
|
|
41
|
+
bell();
|
|
42
|
+
}
|
package/dist/plugins/plugins.js
CHANGED
|
@@ -66,6 +66,20 @@ export function pluginMcpServers() {
|
|
|
66
66
|
Object.assign(out, p.manifest.mcpServers ?? {});
|
|
67
67
|
return out;
|
|
68
68
|
}
|
|
69
|
+
/** Lifecycle hooks contributed by enabled plugins (appended after user-config hooks). */
|
|
70
|
+
export function pluginHooks() {
|
|
71
|
+
const out = { PreToolUse: [], PostToolUse: [] };
|
|
72
|
+
for (const p of enabledPlugins()) {
|
|
73
|
+
const h = p.manifest.hooks;
|
|
74
|
+
if (!h || typeof h !== "object")
|
|
75
|
+
continue;
|
|
76
|
+
if (Array.isArray(h.PreToolUse))
|
|
77
|
+
out.PreToolUse.push(...h.PreToolUse);
|
|
78
|
+
if (Array.isArray(h.PostToolUse))
|
|
79
|
+
out.PostToolUse.push(...h.PostToolUse);
|
|
80
|
+
}
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
69
83
|
/** Install a plugin from `file:<path>`, `github:<owner/repo>`, or `git:<url>` into ~/.hara/plugins/<name>. */
|
|
70
84
|
export function installPlugin(source) {
|
|
71
85
|
mkdirSync(pluginsDir(), { recursive: true });
|
|
@@ -2,6 +2,19 @@ import Anthropic from "@anthropic-ai/sdk";
|
|
|
2
2
|
import { imageToBase64 } from "../images.js";
|
|
3
3
|
export function toAnthropic(history) {
|
|
4
4
|
const msgs = [];
|
|
5
|
+
// Append a user message, merging into the previous one if it's also `user` — Anthropic requires
|
|
6
|
+
// alternating roles, and tool-results map to a user message, so a mid-turn-injected user message
|
|
7
|
+
// (type-ahead steering) lands right after one. Merging keeps the request valid; dormant otherwise.
|
|
8
|
+
const pushUser = (content) => {
|
|
9
|
+
const last = msgs[msgs.length - 1];
|
|
10
|
+
if (last && last.role === "user") {
|
|
11
|
+
const toBlocks = (c) => typeof c === "string" ? [{ type: "text", text: c }] : c;
|
|
12
|
+
last.content = [...toBlocks(last.content), ...toBlocks(content)];
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
msgs.push({ role: "user", content });
|
|
16
|
+
}
|
|
17
|
+
};
|
|
5
18
|
for (const m of history) {
|
|
6
19
|
if (m.role === "user") {
|
|
7
20
|
if (m.images?.length) {
|
|
@@ -13,10 +26,10 @@ export function toAnthropic(history) {
|
|
|
13
26
|
if (data)
|
|
14
27
|
blocks.push({ type: "image", source: { type: "base64", media_type: img.mediaType, data } });
|
|
15
28
|
}
|
|
16
|
-
|
|
29
|
+
pushUser(blocks.length ? blocks : m.content);
|
|
17
30
|
}
|
|
18
31
|
else {
|
|
19
|
-
|
|
32
|
+
pushUser(m.content);
|
|
20
33
|
}
|
|
21
34
|
}
|
|
22
35
|
else if (m.role === "assistant") {
|
|
@@ -28,15 +41,12 @@ export function toAnthropic(history) {
|
|
|
28
41
|
msgs.push({ role: "assistant", content: content.length ? content : [{ type: "text", text: "(no output)" }] });
|
|
29
42
|
}
|
|
30
43
|
else {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
is_error: r.isError,
|
|
38
|
-
})),
|
|
39
|
-
});
|
|
44
|
+
pushUser(m.results.map((r) => ({
|
|
45
|
+
type: "tool_result",
|
|
46
|
+
tool_use_id: r.id,
|
|
47
|
+
content: r.content,
|
|
48
|
+
is_error: r.isError,
|
|
49
|
+
})));
|
|
40
50
|
}
|
|
41
51
|
}
|
|
42
52
|
return msgs;
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// todo_write — an inline task checklist the agent maintains during a turn (like codex's update_plan /
|
|
2
|
+
// Claude Code's TodoWrite). Keeps the model organized on multi-step work and shows the user live progress.
|
|
3
|
+
// In-memory, replace-whole-list semantics; kind:"read" so it never prompts and is safe to call freely.
|
|
4
|
+
import { registerTool } from "./registry.js";
|
|
5
|
+
let todos = [];
|
|
6
|
+
/** The current checklist (latest todo_write wins) — for a TUI/statusline to render. */
|
|
7
|
+
export function currentTodos() {
|
|
8
|
+
return todos;
|
|
9
|
+
}
|
|
10
|
+
const MARK = { pending: "☐", in_progress: "▶", done: "☑" };
|
|
11
|
+
export function renderTodos(list) {
|
|
12
|
+
if (!list.length)
|
|
13
|
+
return "(todo list cleared)";
|
|
14
|
+
const done = list.filter((t) => t.status === "done").length;
|
|
15
|
+
return `Todos (${done}/${list.length} done):\n` + list.map((t) => ` ${MARK[t.status]} ${t.text}`).join("\n");
|
|
16
|
+
}
|
|
17
|
+
registerTool({
|
|
18
|
+
name: "todo_write",
|
|
19
|
+
description: "Maintain a short task checklist for the CURRENT work. Use it to plan a multi-step task up front, then " +
|
|
20
|
+
"update it as you go: keep exactly one item 'in_progress', flip items to 'done' as you finish, add items " +
|
|
21
|
+
"you discover. Pass the FULL list each call (it replaces the previous). Skip it for trivial one-step tasks.",
|
|
22
|
+
input_schema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
todos: {
|
|
26
|
+
type: "array",
|
|
27
|
+
description: "the full checklist, in order",
|
|
28
|
+
items: {
|
|
29
|
+
type: "object",
|
|
30
|
+
properties: {
|
|
31
|
+
text: { type: "string", description: "the task, a short imperative phrase" },
|
|
32
|
+
status: { type: "string", enum: ["pending", "in_progress", "done"] },
|
|
33
|
+
},
|
|
34
|
+
required: ["text", "status"],
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["todos"],
|
|
39
|
+
},
|
|
40
|
+
kind: "read", // pure state + display: never prompts, parallel-safe
|
|
41
|
+
async run(input) {
|
|
42
|
+
const raw = Array.isArray(input.todos) ? input.todos : [];
|
|
43
|
+
todos = raw
|
|
44
|
+
.map((t) => ({
|
|
45
|
+
text: String(t?.text ?? "").trim(),
|
|
46
|
+
status: (["pending", "in_progress", "done"].includes(t?.status) ? t.status : "pending"),
|
|
47
|
+
}))
|
|
48
|
+
.filter((t) => t.text);
|
|
49
|
+
return renderTodos(todos);
|
|
50
|
+
},
|
|
51
|
+
});
|
package/dist/tools/web.js
CHANGED
|
@@ -24,6 +24,103 @@ export function htmlToText(html) {
|
|
|
24
24
|
.replace(/\n{3,}/g, "\n\n")
|
|
25
25
|
.trim();
|
|
26
26
|
}
|
|
27
|
+
/** Parse DuckDuckGo HTML results → [{title, url, snippet}]. Best-effort HTML scrape (no key, no dependency). */
|
|
28
|
+
export function parseSearchResults(html, limit) {
|
|
29
|
+
const strip = (s) => s
|
|
30
|
+
.replace(/<[^>]+>/g, "")
|
|
31
|
+
.replace(/&/g, "&")
|
|
32
|
+
.replace(/</g, "<")
|
|
33
|
+
.replace(/>/g, ">")
|
|
34
|
+
.replace(/"/g, '"')
|
|
35
|
+
.replace(/'|'/g, "'")
|
|
36
|
+
.replace(/\s+/g, " ")
|
|
37
|
+
.trim();
|
|
38
|
+
const snippets = [];
|
|
39
|
+
const snipRe = /class="result__snippet"[^>]*>([\s\S]*?)<\/a>/g;
|
|
40
|
+
let m;
|
|
41
|
+
while ((m = snipRe.exec(html)))
|
|
42
|
+
snippets.push(strip(m[1]));
|
|
43
|
+
const out = [];
|
|
44
|
+
const linkRe = /class="result__a"[^>]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/g;
|
|
45
|
+
let i = 0;
|
|
46
|
+
while ((m = linkRe.exec(html)) && out.length < limit) {
|
|
47
|
+
let href = m[1].replace(/&/g, "&");
|
|
48
|
+
const uddg = /[?&]uddg=([^&]+)/.exec(href); // DuckDuckGo wraps results in a /l/?uddg=<real-url> redirect
|
|
49
|
+
if (uddg)
|
|
50
|
+
href = decodeURIComponent(uddg[1]);
|
|
51
|
+
else if (href.startsWith("//"))
|
|
52
|
+
href = "https:" + href;
|
|
53
|
+
out.push({ title: strip(m[2]), url: href, snippet: snippets[i++] ?? "" });
|
|
54
|
+
}
|
|
55
|
+
return out;
|
|
56
|
+
}
|
|
57
|
+
registerTool({
|
|
58
|
+
name: "web_search",
|
|
59
|
+
description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
|
|
60
|
+
"don't already have a URL for, then `web_fetch` a result to read it. Read-only. Reliable with a Tavily key " +
|
|
61
|
+
"(env HARA_SEARCH_API_KEY); otherwise a best-effort keyless fallback that may be rate-limited.",
|
|
62
|
+
input_schema: {
|
|
63
|
+
type: "object",
|
|
64
|
+
properties: {
|
|
65
|
+
query: { type: "string" },
|
|
66
|
+
limit: { type: "number", description: "max results (default 6, max 10)" },
|
|
67
|
+
},
|
|
68
|
+
required: ["query"],
|
|
69
|
+
},
|
|
70
|
+
kind: "read",
|
|
71
|
+
async run(input) {
|
|
72
|
+
const q = String(input.query ?? "").trim();
|
|
73
|
+
if (!q)
|
|
74
|
+
return "(empty query)";
|
|
75
|
+
const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
|
|
76
|
+
const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
|
|
77
|
+
const ctrl = new AbortController();
|
|
78
|
+
const timer = setTimeout(() => ctrl.abort(), 20_000);
|
|
79
|
+
try {
|
|
80
|
+
// Reliable path: Tavily (designed for agents, free tier) when a key is configured.
|
|
81
|
+
const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
|
|
82
|
+
if (key) {
|
|
83
|
+
const res = await fetch("https://api.tavily.com/search", {
|
|
84
|
+
method: "POST",
|
|
85
|
+
signal: ctrl.signal,
|
|
86
|
+
headers: { "content-type": "application/json" },
|
|
87
|
+
body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
|
|
88
|
+
});
|
|
89
|
+
if (res.ok) {
|
|
90
|
+
const j = (await res.json());
|
|
91
|
+
const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
|
|
92
|
+
if (rs.length)
|
|
93
|
+
return fmt(rs);
|
|
94
|
+
}
|
|
95
|
+
// Tavily failed → fall through to the keyless best-effort path.
|
|
96
|
+
}
|
|
97
|
+
// Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
|
|
98
|
+
const res = await fetch("https://html.duckduckgo.com/html/", {
|
|
99
|
+
method: "POST",
|
|
100
|
+
signal: ctrl.signal,
|
|
101
|
+
redirect: "follow",
|
|
102
|
+
headers: {
|
|
103
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
104
|
+
"content-type": "application/x-www-form-urlencoded",
|
|
105
|
+
accept: "text/html",
|
|
106
|
+
},
|
|
107
|
+
body: `q=${encodeURIComponent(q)}`,
|
|
108
|
+
});
|
|
109
|
+
if (!res.ok)
|
|
110
|
+
return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
|
|
111
|
+
const results = parseSearchResults(await res.text(), limit);
|
|
112
|
+
if (!results.length)
|
|
113
|
+
return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
|
|
114
|
+
return fmt(results);
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
|
|
118
|
+
}
|
|
119
|
+
finally {
|
|
120
|
+
clearTimeout(timer);
|
|
121
|
+
}
|
|
122
|
+
},
|
|
123
|
+
});
|
|
27
124
|
registerTool({
|
|
28
125
|
name: "web_fetch",
|
|
29
126
|
description: "Fetch an http(s) URL and return its text content (HTML is reduced to readable text). Read-only. " +
|
package/dist/tui/App.js
CHANGED
|
@@ -92,6 +92,19 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
92
92
|
return [...cur, { id: nid(), kind, text }];
|
|
93
93
|
});
|
|
94
94
|
}, []);
|
|
95
|
+
// Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
|
|
96
|
+
// inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
|
|
97
|
+
// addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
|
|
98
|
+
const drainQueue = useCallback(() => {
|
|
99
|
+
if (!queueRef.current.length)
|
|
100
|
+
return [];
|
|
101
|
+
const batch = queueRef.current;
|
|
102
|
+
queueRef.current = [];
|
|
103
|
+
setPool([]);
|
|
104
|
+
for (const b of batch)
|
|
105
|
+
pushCurrent("user", b.line.trim() || "🖼 (image)");
|
|
106
|
+
return batch;
|
|
107
|
+
}, [pushCurrent]);
|
|
95
108
|
const handleSubmit = useCallback(async (line, images) => {
|
|
96
109
|
const t = line.trim();
|
|
97
110
|
if ((!t && !images?.length) || prompt)
|
|
@@ -127,7 +140,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
127
140
|
const selectFn = (title, options) => openPrompt(title, options);
|
|
128
141
|
const setApprovalFn = (m) => setStatus((s) => ({ ...s, approval: m }));
|
|
129
142
|
try {
|
|
130
|
-
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval }, images);
|
|
143
|
+
await onSubmit(t, { sink, confirm: confirmFn, select: selectFn, setApproval: setApprovalFn, signal: ctrl.signal, exit, approval: statusRef.current.approval, drainQueue }, images);
|
|
131
144
|
}
|
|
132
145
|
catch (e) {
|
|
133
146
|
pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
|
|
@@ -139,7 +152,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
|
|
|
139
152
|
setCurrent([]);
|
|
140
153
|
setWorking(false);
|
|
141
154
|
ctrlRef.current = null;
|
|
142
|
-
}, [working, prompt, onSubmit, pushCurrent, model, exit]);
|
|
155
|
+
}, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
|
|
143
156
|
// Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
|
|
144
157
|
// every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
|
|
145
158
|
useEffect(() => {
|