@nanhara/hara 0.109.2 → 0.109.4

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 CHANGED
@@ -5,6 +5,29 @@ 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.109.4 — a dropped file path is read, not "Unknown command"
9
+
10
+ - **Dragging/pasting a file into the prompt no longer errors.** A dropped file pastes as an absolute path
11
+ (`/Users/…/spec.md`, often with trailing text or `[Image #N]` tokens). Because it starts with `/`, hara
12
+ treated it as a slash command and replied `Unknown command /Users/…`. Now the command parser only fires
13
+ when the first token has **no embedded slash** (real commands — `/help`, `/design` — never contain one),
14
+ so a path falls through to the normal turn. And when a message *begins* with an existing absolute path,
15
+ that path is rewritten to an `@`-mention so its content is **read into the turn** — i.e. "interpret this
16
+ file" just works. Fixed in both the TUI and the readline REPL.
17
+
18
+ ## 0.109.3 — an empty model response no longer looks like a hang
19
+
20
+ - **A turn that comes back with no text and no tool calls is no longer silently dropped.** The agent
21
+ loop treated an empty completion as "done" and returned with zero feedback — so after e.g. a "继续"
22
+ the box just sat there, idle, looking frozen for hours (CPU 0%, waiting at the prompt) when really
23
+ the turn had vanished. It now **retries once** with a nudge (an empty response is usually a transient
24
+ hiccup), and if it's still empty says so plainly (`✻ empty response — nothing to do. Rephrase…`)
25
+ instead of disappearing. Matches how Claude Code / codex refuse to end on a blank turn.
26
+ - **Closed a matching spin:** a `tool_use` stop reason carrying an *empty* tool list used to push an
27
+ empty tool round and re-request in a loop; it's now bounded by the same single-retry guard. (The
28
+ 120s stall-watchdog already covered a genuinely dead/silent socket — this covers the case where the
29
+ request *succeeds* but returns nothing.)
30
+
8
31
  ## 0.109.2 — long paste no longer freezes the input box
9
32
 
10
33
  - **The input box now draws a bottom-anchored viewport, not every wrapped row.** A long multi-line
@@ -97,6 +97,7 @@ export async function runAgent(history, opts) {
97
97
  const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
98
98
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
99
99
  let triedFallback = false;
100
+ let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
100
101
  // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
101
102
  // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
102
103
  // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
@@ -281,7 +282,37 @@ export async function runAgent(history, opts) {
281
282
  }
282
283
  return;
283
284
  }
284
- if (r.stop !== "tool_use")
285
+ // Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
286
+ // completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
287
+ // user at a dead prompt with ZERO feedback: it reads as a 15-hour hang when really the turn just
288
+ // vanished. Retry ONCE with a nudge (usually a transient hiccup), then, if still empty, say so
289
+ // plainly and end — never loop forever, never disappear. (Claude Code / codex both guard this.)
290
+ if (!r.text.trim() && r.toolUses.length === 0) {
291
+ if (!emptyRetried) {
292
+ emptyRetried = true;
293
+ history.pop(); // drop the empty assistant turn before re-asking
294
+ history.push({ role: "user", content: "(Your previous response was empty. Continue the task now — take the next concrete step with a tool, or reply with text. Do not return an empty response.)" });
295
+ if (!opts.quiet) {
296
+ const note = "✻ empty response — retrying once…";
297
+ if (sink)
298
+ sink.notice(note);
299
+ else
300
+ out(c.dim(`${note}\n`));
301
+ }
302
+ continue;
303
+ }
304
+ const note = "✻ the model returned an empty response — nothing to do. Rephrase your request, or press Enter to try again.";
305
+ if (!opts.quiet) {
306
+ if (sink)
307
+ sink.notice(note);
308
+ else
309
+ out(c.dim(`${note}\n`));
310
+ }
311
+ return;
312
+ }
313
+ // A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
314
+ // rather than pushing an empty tool round and re-requesting in a loop.
315
+ if (r.stop !== "tool_use" || r.toolUses.length === 0)
285
316
  return;
286
317
  const plans = [];
287
318
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
@@ -8,6 +8,23 @@ import { mediaTypeFor } from "../images.js";
8
8
  const MAX_FILE = 50_000;
9
9
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
10
10
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
11
+ /** A submitted line is a slash COMMAND only if it starts with '/' AND its first whitespace-delimited
12
+ * token has no *embedded* '/'. A dropped/pasted absolute file path (`/Users/…/doc.md`, possibly followed
13
+ * by text or `[Image #N]` tokens) has an embedded slash → it is NOT a command. Without this, dragging a
14
+ * file into the prompt errored with "Unknown command /Users/…". Exported pure for tests. */
15
+ export function isSlashCommand(line) {
16
+ return line.startsWith("/") && !line.slice(1).split(/\s+/)[0].includes("/");
17
+ }
18
+ /** If a message BEGINS with an absolute file path (a dragged/pasted file), rewrite that leading path into
19
+ * an `@`-mention so expandMentions inlines the file's content into the turn — the "read/interpret this
20
+ * file" the user meant. Space-free leading paths only (a mention can't contain spaces); a non-existent or
21
+ * spaced path is left untouched. `exists` is injected so this stays pure/testable. */
22
+ export function inlineLeadingPath(line, exists) {
23
+ if (!line.startsWith("/"))
24
+ return line;
25
+ const first = line.split(/\s+/)[0];
26
+ return exists(first) ? "@" + line : line;
27
+ }
11
28
  /** Expand `@path` references **in place** — the file/dir content lands where it's referenced, not
12
29
  * dumped at the bottom (so "compare @a.ts with @b.ts" reads in context). A repeated mention keeps
13
30
  * the bare `@path` the second time (no double-inlining), and a non-readable ref is left untouched. */
package/dist/index.js CHANGED
@@ -47,7 +47,7 @@ import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./conte
47
47
  import { getEmbedder } from "./search/embed.js";
48
48
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
49
49
  import { searchHybrid } from "./search/hybrid.js";
50
- import { expandMentions, fileCandidates } from "./context/mentions.js";
50
+ import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
51
51
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
52
52
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
53
53
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
@@ -2814,7 +2814,9 @@ program.action(async (opts) => {
2814
2814
  onClipboardImage: readClipboardImage,
2815
2815
  vim: cfg.vimMode,
2816
2816
  onSubmit: async (line, h, images) => {
2817
- if (line.startsWith("/")) {
2817
+ // A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
2818
+ // is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
2819
+ if (isSlashCommand(line)) {
2818
2820
  const [nm, ...rest] = line.slice(1).split(/\s+/);
2819
2821
  const arg = rest.join(" ").trim();
2820
2822
  if (nm === "exit" || nm === "quit") {
@@ -3074,6 +3076,9 @@ program.action(async (opts) => {
3074
3076
  const near = nearest(nm, [...byName.keys()]);
3075
3077
  return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
3076
3078
  }
3079
+ // A message that begins with an absolute file path (the case skipped above) → inline it as an
3080
+ // @-mention so its content is read into the turn (drag-a-file-in → interpret it).
3081
+ line = inlineLeadingPath(line, existsSync);
3077
3082
  const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3078
3083
  const appr = h.approval;
3079
3084
  // Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
@@ -3237,7 +3242,8 @@ program.action(async (opts) => {
3237
3242
  bar.renderBottom(); // bottom border + modes/usage
3238
3243
  if (!line)
3239
3244
  continue;
3240
- if (line.startsWith("/")) {
3245
+ // A dropped/pasted file path starts with '/' but isn't a command — read it, don't error (see TUI path).
3246
+ if (isSlashCommand(line)) {
3241
3247
  const [name, ...rest] = line.slice(1).split(/\s+/);
3242
3248
  const cmd = byName.get(name);
3243
3249
  if (!cmd) {
@@ -3275,6 +3281,7 @@ program.action(async (opts) => {
3275
3281
  break;
3276
3282
  continue;
3277
3283
  }
3284
+ line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
3278
3285
  const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
3279
3286
  recalledContext = "";
3280
3287
  history.push({ role: "user", content: userContent });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.109.2",
3
+ "version": "0.109.4",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"