@nanhara/hara 0.119.0 → 0.119.2

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,32 @@ 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.119.2 — TUI: update notices actually visible · CJK-correct input wrapping
9
+
10
+ - **Update notices now render INSIDE the TUI** (yellow line under the header card). They used to
11
+ print to stdout before ink mounted and vanished when the TUI took the screen — which is why TUI
12
+ users never saw them and versions silently went stale (field report: stuck on 0.112.5).
13
+ - **Input wrapping measures terminal CELLS, not characters.** CJK/emoji render 2 cells wide;
14
+ mixed 中文+ASCII prompts used to overflow the real width and get soft-wrapped a second time
15
+ mid-word ("output" torn into "ou/tput"). Long words hard-break per code point so a double-width
16
+ char never straddles the terminal edge.
17
+
18
+ ## 0.119.1 — field-feedback robustness: param gate · no-hang git · actionable timeouts · stale-artifact rule
19
+
20
+ - **Required-parameter gate.** A tool call arriving WITHOUT its required parameters (observed:
21
+ qwen3.7-plus dropping write_file's path/content, then retrying the same broken call forever) is
22
+ now rejected before execution with an error naming exactly what's missing; repeat-guard
23
+ escalates if the model loops anyway. Empty strings stay legal.
24
+ - **git can no longer hang to the timeout.** Shell commands run with `GIT_TERMINAL_PROMPT=0` +
25
+ `GCM_INTERACTIVE=never` (user env overrides win): an https op that wants credentials fails in
26
+ seconds with a real auth error instead of sitting silently for 5 minutes.
27
+ - **Timeouts tell you what to do next**: larger `timeout_ms` for long builds, `background:true`
28
+ for servers, and for network ops — diagnose or skip, never blind-retry.
29
+ - **Stale-artifact rule** (prompt): before previewing generated artifacts, verify they're newer
30
+ than their sources; AGENTS.md/README command sequences are authoritative — middle steps (render/
31
+ build) are never skipped. Pairs with hara-design 0.3.6's deterministic staleness tripwire +
32
+ preview idle auto-exit.
33
+
8
34
  ## 0.119.0 — project panels: the chat ↔ live-preview split
9
35
 
10
36
  - **Plugin panels become project-aware.** A plugin panel can declare `detect` markers (e.g.
@@ -1,4 +1,4 @@
1
- import { getTool, toolSpecs } from "../tools/registry.js";
1
+ import { getTool, toolSpecs, missingRequired } from "../tools/registry.js";
2
2
  import { stdout } from "node:process";
3
3
  import { c, out } from "../ui.js";
4
4
  import { activity } from "../activity.js";
@@ -83,7 +83,12 @@ can't serve private repos), don't switch protocols — hara already fast-fails r
83
83
  diagnose instead. git ignores the macOS system / Clash proxy unless configured (git config --global
84
84
  http.proxy), so a browser that reaches a site doesn't mean the terminal does — verify connectivity yourself
85
85
  rather than trusting "the network is fine". If a step's output artifact already exists and is newer than its
86
- inputs, skip re-running it. After completing a task, give a one-line summary.`;
86
+ inputs, skip re-running it and the INVERSE: before serving or previewing GENERATED artifacts (a gallery,
87
+ site, build output), check they are newer than their sources (compare mtimes or the latest commit time); if
88
+ the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
89
+ When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
90
+ that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
91
+ two-day-old work. After completing a task, give a one-line summary.`;
87
92
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
88
93
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
89
94
  function gatewayNote() {
@@ -433,6 +438,16 @@ export async function runAgent(history, opts) {
433
438
  }
434
439
  activity.inc();
435
440
  try {
441
+ // Defensive parameter gate — some models drop required tool parameters outright (observed:
442
+ // qwen3.7-plus sending write_file without path/content, then retrying the same broken call
443
+ // forever). Reject precisely and name what's missing; repeat-guard escalates if it loops.
444
+ const missing = missingRequired(p.tool, p.tu.input);
445
+ if (missing.length) {
446
+ const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
447
+ `${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
448
+ results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
449
+ return;
450
+ }
436
451
  const pre = runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
437
452
  if (pre.block) {
438
453
  results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
package/dist/index.js CHANGED
@@ -2937,6 +2937,9 @@ program.action(async (opts) => {
2937
2937
  routeHost: __routeForHeader?.host,
2938
2938
  modelSource: __modelSource,
2939
2939
  visionModel: cfg.visionModel,
2940
+ // the pre-mount stdout notice (line ~2497) doesn't survive ink taking the screen — TUI users
2941
+ // never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
2942
+ updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
2940
2943
  },
2941
2944
  visionNotice: __visionNotice,
2942
2945
  cycleApproval: (m) => cycleMode(m),
package/dist/sandbox.js CHANGED
@@ -93,7 +93,16 @@ export function maybeWarnUnsandboxed(mode) {
93
93
  export function runShell(command, cwd, mode, opts) {
94
94
  const { cmd, args } = shellCommand(command, cwd, mode);
95
95
  return new Promise((resolve, reject) => {
96
- const child = spawn(cmd, args, { cwd });
96
+ // Non-interactive by contract: there is no terminal to answer a credential prompt, so a git
97
+ // https op against a private repo would otherwise sit silently until the timeout (observed as
98
+ // "git hangs 5 minutes"). With prompts disabled it fails in seconds with a real auth error.
99
+ // Users' credential helpers (keychain/GCM store) still work — only interactive PROMPTS are off.
100
+ const env = {
101
+ ...process.env,
102
+ GIT_TERMINAL_PROMPT: process.env.GIT_TERMINAL_PROMPT ?? "0",
103
+ GCM_INTERACTIVE: process.env.GCM_INTERACTIVE ?? "never",
104
+ };
105
+ const child = spawn(cmd, args, { cwd, env });
97
106
  let stdout = "";
98
107
  let stderr = "";
99
108
  let timedOut = false;
@@ -179,7 +179,15 @@ registerTool({
179
179
  return capHeadTail(combined.trim() || "(no output)");
180
180
  }
181
181
  catch (e) {
182
- const base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
182
+ let base = `Command failed: ${e.message}\n${e.stdout || ""}${e.stderr || ""}`;
183
+ // Timeout gets an ACTIONABLE next step, not just a corpse — the model (and user) should pick a
184
+ // lane instead of blind-retrying into the same wall.
185
+ if (/timed out after \d+ms/.test(String(e.message))) {
186
+ base +=
187
+ `\n⏱ hara: the command hit its ${input.timeout_ms ?? 300_000}ms cap and was killed. Pick ONE: ` +
188
+ `a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
189
+ `a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
190
+ }
183
191
  // Network fault tolerance — if this was a genuine host-unreachability (connect timeout / DNS, NOT
184
192
  // auth / 404 / connection-refused), remember the host so we fast-fail future ops to it this session.
185
193
  if (isConnectFailure(base)) {
@@ -1,3 +1,13 @@
1
+ /** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
2
+ * against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
3
+ * path/content mid-stream) — the loop rejects the call with a precise error instead of executing
4
+ * garbage, and repeat-guard escalates if the model loops on the same broken shape. Empty strings
5
+ * are NOT flagged (writing an empty file is legitimate). */
6
+ export function missingRequired(tool, input) {
7
+ const req = tool.input_schema.required ?? [];
8
+ const obj = (input && typeof input === "object" ? input : {});
9
+ return req.filter((k) => obj[k] === undefined || obj[k] === null);
10
+ }
1
11
  const registry = new Map();
2
12
  export function registerTool(t) {
3
13
  registry.set(t.name, t);
package/dist/tui/App.js CHANGED
@@ -231,7 +231,7 @@ function HeaderCard(props) {
231
231
  : row("profile", _jsx(Text, { children: props.profileId ? `personal:${props.profileId}` : "personal" }));
232
232
  return (_jsxs(Box, { flexDirection: "column", children: [_jsxs(Box, { flexDirection: "column", borderStyle: "round", borderColor: "gray", borderDimColor: true, paddingX: 1, alignSelf: "flex-start", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), bold: true, children: "◆ hara" }), _jsx(Text, { dimColor: true, children: ` v${version} · the agent that runs like an org` })] }), _jsx(Text, { children: " " }), identityRow, row("model", (_jsxs(Text, { children: [_jsx(Text, { children: modelLabel }), isOrg
233
233
  ? props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null
234
- : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" })] }));
234
+ : props.visionModel ? _jsx(Text, { dimColor: true, children: modelLineSuffix(props.visionModel) }) : null, _jsx(Text, { children: " " }), _jsx(Text, { color: "green", children: "/model ↹" })] }))), row("cwd", (_jsxs(Text, { children: [_jsx(Text, { children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }))), sessionShort ? row("session", _jsx(Text, { children: sessionShort })) : null] }), _jsx(Text, { dimColor: true, children: " Tip: @ attach file · ctrl+t transcript · ctrl+r reasoning · shift+tab approval · esc interrupt" }), props.updateNotice ? _jsx(Text, { color: "yellow", children: ` ⬆ ${props.updateNotice}` }) : null] }));
235
235
  }
236
236
  // Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
237
237
  // so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
@@ -110,6 +110,35 @@ function segmentize(value) {
110
110
  parts.push({ text: value.slice(last), token: false });
111
111
  return parts;
112
112
  }
113
+ /** Terminal cell width of one code point: CJK/fullwidth/emoji render 2 cells, combining marks and
114
+ * joiners 0, everything else 1. Wrapping used to count `.length` (1 per char) — mixed CJK+ASCII
115
+ * input then overflowed the real terminal width and ink soft-wrapped a second time mid-word
116
+ * (field report: "output" torn into "ou/tput" while typing a URL + 中文 prompt). */
117
+ export function charCells(ch) {
118
+ const cp = ch.codePointAt(0);
119
+ if ((cp >= 0x300 && cp <= 0x36f) || cp === 0x200d || (cp >= 0xfe00 && cp <= 0xfe0f))
120
+ return 0; // combining/ZWJ/VS
121
+ if ((cp >= 0x1100 && cp <= 0x115f) || // Hangul Jamo
122
+ (cp >= 0x2e80 && cp <= 0xa4cf) || // CJK radicals … Yi
123
+ (cp >= 0xa960 && cp <= 0xa97f) ||
124
+ (cp >= 0xac00 && cp <= 0xd7a3) || // Hangul syllables
125
+ (cp >= 0xf900 && cp <= 0xfaff) || // CJK compatibility ideographs
126
+ (cp >= 0xfe30 && cp <= 0xfe4f) || // CJK compatibility forms
127
+ (cp >= 0xff00 && cp <= 0xff60) || // fullwidth forms
128
+ (cp >= 0xffe0 && cp <= 0xffe6) ||
129
+ (cp >= 0x1f300 && cp <= 0x1faff) || // emoji
130
+ (cp >= 0x20000 && cp <= 0x3fffd) // CJK extension planes
131
+ )
132
+ return 2;
133
+ return 1;
134
+ }
135
+ /** Display width of a string in terminal cells (sum of charCells over code points). */
136
+ export function cells(s) {
137
+ let n = 0;
138
+ for (const ch of s)
139
+ n += charCells(ch);
140
+ return n;
141
+ }
113
142
  /** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
114
143
  * never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
115
144
  * align under a stable gutter and the cursor position is exact. Always returns at least one row. */
@@ -154,25 +183,28 @@ export function wrapRows(value, cols) {
154
183
  flush(aEnd);
155
184
  continue;
156
185
  }
157
- if (a.atomic || a.text.length <= width) {
186
+ const aCells = cells(a.text);
187
+ if (a.atomic || aCells <= width) {
158
188
  // A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
159
189
  // the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
160
- if (a.text.length > width - col && col > 0)
190
+ if (aCells > width - col && col > 0)
161
191
  flush(a.start);
162
- col += a.text.length; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
192
+ col += aCells; // an oversized atomic token may exceed width — acceptable (rare, kept whole)
163
193
  if (col >= width)
164
194
  flush(aEnd);
165
195
  }
166
196
  else {
167
- // A single word longer than the whole width: hard-break it across rows.
197
+ // A single word longer than the whole width: hard-break it across rows — walking CODE POINTS
198
+ // and accumulating CELLS, so a double-width char never straddles the terminal edge.
168
199
  let s = a.start;
169
200
  if (col > 0)
170
201
  flush(s); // start the long word on a fresh row
171
- while (s < aEnd) {
172
- const room = width - col;
173
- const take = Math.min(room, aEnd - s);
174
- col += take;
175
- s += take;
202
+ for (const ch of a.text) {
203
+ const w = charCells(ch);
204
+ if (col + w > width && col > 0)
205
+ flush(s);
206
+ col += w;
207
+ s += ch.length;
176
208
  if (col >= width)
177
209
  flush(s);
178
210
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.119.0",
3
+ "version": "0.119.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"