@nanhara/hara 0.108.0 → 0.109.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 +25 -0
- package/dist/sandbox.js +38 -2
- package/dist/tui/InputBox.js +31 -13
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,31 @@ 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.0 — real multi-line input + Windows shell
|
|
9
|
+
|
|
10
|
+
- **Pasted multi-line text is now real, editable text in the box** — not a `[Paste]` token, not an
|
|
11
|
+
auto-submit. The input box renders `\n` as actual line breaks (deterministic wrap now treats a
|
|
12
|
+
newline as a hard row break), so a pasted paragraph / code / stack trace shows in full, you edit it,
|
|
13
|
+
and only a real **Enter** sends it. (0.108.1 over-corrected by folding every paste to a token; this
|
|
14
|
+
is codex's behavior — the composer is a multi-line textarea, a pasted newline is content.) A truly
|
|
15
|
+
enormous dump (>8000 chars) still folds to a token so it can't wall off the screen.
|
|
16
|
+
- **Windows: the shell no longer hard-fails.** The bash tool hardcoded `/bin/sh`, which doesn't exist
|
|
17
|
+
on Windows — so every command errored. Now on Windows hara prefers a real **bash** (Git Bash / WSL,
|
|
18
|
+
which it probes on PATH) so the POSIX commands the model writes keep working, and falls back to
|
|
19
|
+
`cmd.exe` with a one-time notice pointing at the fix. (Still no auto-`cron install` or sandbox on
|
|
20
|
+
Windows — those stay Unix-only, as before.)
|
|
21
|
+
|
|
22
|
+
## 0.108.1 — pasting no longer sends the message
|
|
23
|
+
|
|
24
|
+
- **A pasted newline is content, not "send".** Pasting multi-line text used to fire the message at
|
|
25
|
+
the first newline (any 1–2 line paste under 600 chars auto-submitted) — the classic "I pasted and
|
|
26
|
+
it sent before I could edit" bug. Now ANY paste containing a newline folds to a `[Paste #N +L lines]`
|
|
27
|
+
token and waits; only a real **Enter** sends it (the token expands to the full text on submit). This
|
|
28
|
+
is codex's paste-burst rule — Enter inside a paste is a newline, not a submit. A lone newline typed
|
|
29
|
+
at the prompt still sends, as before.
|
|
30
|
+
- (Slow paste *recognition* over a remote/SSH terminal is chunk-delivery latency — network-bound, not
|
|
31
|
+
something the client amplifies; local pastes arrive as one chunk and are instant.)
|
|
32
|
+
|
|
8
33
|
## 0.108.0 — cron grows up: chat-native scheduling, delivery, a deterministic lane
|
|
9
34
|
|
|
10
35
|
Distilled from a three-way study of openclaw (the production-grade scheduler running our company),
|
package/dist/sandbox.js
CHANGED
|
@@ -5,10 +5,33 @@
|
|
|
5
5
|
// project, NOT a determined exfiltration. Other platforms run UNSANDBOXED (a one-time warning is
|
|
6
6
|
// emitted from runShell so every entry point — REPL, -p, org, cron — surfaces it). Only the `bash`
|
|
7
7
|
// shell is sandboxed; hara's own file tools are in-process, explicit, and gated by the approval flow.
|
|
8
|
-
import { spawn } from "node:child_process";
|
|
8
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
9
9
|
import { writeFileSync, mkdtempSync } from "node:fs";
|
|
10
10
|
import { tmpdir, platform } from "node:os";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
// Windows shell resolution. hara (and the model) speak POSIX shell — the agent writes `ls`, `grep`,
|
|
13
|
+
// `cat`, pipes, `&&`. So on Windows we PREFER a real bash (Git Bash or WSL, which most Windows devs
|
|
14
|
+
// have) and only fall back to cmd.exe when none is found. Memoized: the PATH probe runs at most once.
|
|
15
|
+
let _winBash;
|
|
16
|
+
function findWindowsBash() {
|
|
17
|
+
if (_winBash !== undefined)
|
|
18
|
+
return _winBash;
|
|
19
|
+
// `where bash` finds Git Bash / WSL bash on PATH; also probe the default Git-for-Windows location.
|
|
20
|
+
const onPath = spawnSync("where", ["bash"], { encoding: "utf8" });
|
|
21
|
+
const hit = onPath.status === 0 ? String(onPath.stdout).split(/\r?\n/).find((l) => l.trim()) : "";
|
|
22
|
+
_winBash = (hit && hit.trim()) || null;
|
|
23
|
+
return _winBash;
|
|
24
|
+
}
|
|
25
|
+
/** Pure shell-argv resolution — split out so the platform branching is unit-testable without spawning.
|
|
26
|
+
* `plat` and `bash` are injected; production passes the real platform() + findWindowsBash(). */
|
|
27
|
+
export function resolveShellArgv(command, plat, bash) {
|
|
28
|
+
if (plat === "win32") {
|
|
29
|
+
// A real bash keeps POSIX commands working; cmd.exe is the last resort (most `ls/grep` will fail
|
|
30
|
+
// there — the model should be told, see maybeWarnWindowsShell).
|
|
31
|
+
return bash ? { cmd: bash, args: ["-c", command] } : { cmd: "cmd.exe", args: ["/d", "/s", "/c", command] };
|
|
32
|
+
}
|
|
33
|
+
return { cmd: "/bin/sh", args: ["-c", command] };
|
|
34
|
+
}
|
|
12
35
|
const sbQuote = (s) => '"' + s.replace(/\\/g, "\\\\").replace(/"/g, '\\"') + '"';
|
|
13
36
|
function seatbeltProfile(cwd, mode) {
|
|
14
37
|
const writable = [
|
|
@@ -43,7 +66,20 @@ export function shellCommand(command, cwd, mode) {
|
|
|
43
66
|
return { cmd: "sandbox-exec", args: ["-f", profileFile, "/bin/bash", "-lc", command] };
|
|
44
67
|
}
|
|
45
68
|
maybeWarnUnsandboxed(mode);
|
|
46
|
-
|
|
69
|
+
const plat = platform();
|
|
70
|
+
if (plat === "win32")
|
|
71
|
+
maybeWarnWindowsShell();
|
|
72
|
+
return resolveShellArgv(command, plat, plat === "win32" ? findWindowsBash() : null);
|
|
73
|
+
}
|
|
74
|
+
/** One-time notice on Windows when no bash is found — the POSIX commands the model writes won't run
|
|
75
|
+
* under cmd.exe. Points the user at the fix (install Git for Windows or run under WSL). */
|
|
76
|
+
let warnedWinShell = false;
|
|
77
|
+
function maybeWarnWindowsShell() {
|
|
78
|
+
if (warnedWinShell || findWindowsBash())
|
|
79
|
+
return;
|
|
80
|
+
warnedWinShell = true;
|
|
81
|
+
process.stderr.write("hara: no bash found on PATH — shell commands run under cmd.exe, where most Unix commands (ls, grep, cat) fail.\n" +
|
|
82
|
+
" Install Git for Windows (bundles bash) or run hara inside WSL for full command support.\n");
|
|
47
83
|
}
|
|
48
84
|
/** One-time-per-process notice that --sandbox is a no-op off macOS (covers every entry point). */
|
|
49
85
|
export function maybeWarnUnsandboxed(mode) {
|
package/dist/tui/InputBox.js
CHANGED
|
@@ -126,12 +126,13 @@ export function wrapRows(value, cols) {
|
|
|
126
126
|
pos += p.text.length;
|
|
127
127
|
}
|
|
128
128
|
else {
|
|
129
|
-
// Break the text run into
|
|
130
|
-
|
|
129
|
+
// Break the text run into: a hard newline (its own atom → forces a row break, so a pasted/typed
|
|
130
|
+
// `\n` renders as an actual line), a word + trailing NON-newline whitespace, or a run of spaces.
|
|
131
|
+
const re = /\n|\S+[^\S\n]*|[^\S\n]+/g;
|
|
131
132
|
let m;
|
|
132
|
-
|
|
133
|
+
const base = pos;
|
|
133
134
|
while ((m = re.exec(p.text))) {
|
|
134
|
-
atoms.push({ text: m[0], start: base + m.index, atomic: false });
|
|
135
|
+
atoms.push({ text: m[0], start: base + m.index, atomic: false, br: m[0] === "\n" });
|
|
135
136
|
}
|
|
136
137
|
pos += p.text.length;
|
|
137
138
|
}
|
|
@@ -147,6 +148,12 @@ export function wrapRows(value, cols) {
|
|
|
147
148
|
};
|
|
148
149
|
for (const a of atoms) {
|
|
149
150
|
const aEnd = a.start + a.text.length;
|
|
151
|
+
if (a.br) {
|
|
152
|
+
// A newline ends the current row: the `\n` sits at the row's end (renderRow strips it from the
|
|
153
|
+
// displayed text). The next row starts right after it — rows stay contiguous over the value.
|
|
154
|
+
flush(aEnd);
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
150
157
|
if (a.atomic || a.text.length <= width) {
|
|
151
158
|
// A whole unit (image token OR a word chunk that fits within a full row): if it doesn't fit in
|
|
152
159
|
// the remaining room and the row already has content, wrap to a fresh row FIRST — never split it.
|
|
@@ -188,7 +195,9 @@ export function wrapRows(value, cols) {
|
|
|
188
195
|
* cursor (inverse cell) when it falls on this row. Splitting happens on precomputed rows so the
|
|
189
196
|
* whole prompt never reflows as a single <Text> — stable under wrapping and quick to diff. */
|
|
190
197
|
function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
191
|
-
|
|
198
|
+
// `\n` chars are row SEPARATORS (wrapRows already broke the row on them) — never render them, or ink
|
|
199
|
+
// would insert a second line break and desync the deterministic layout.
|
|
200
|
+
const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text.replace(/\n/g, "") }, k)) : (_jsx(Text, { children: text.replace(/\n/g, "") }, k));
|
|
192
201
|
// Segments intersected with this row's [start,end) range.
|
|
193
202
|
const parts = segmentize(value);
|
|
194
203
|
const nodes = [];
|
|
@@ -208,7 +217,7 @@ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
|
|
|
208
217
|
const rel = cursor - from;
|
|
209
218
|
if (rel > 0)
|
|
210
219
|
nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
|
|
211
|
-
nodes.push(_jsx(Text, { inverse: true, children: slice[rel] }, `${keyPrefix}c${ki++}`));
|
|
220
|
+
nodes.push(_jsx(Text, { inverse: true, children: slice[rel] === "\n" ? " " : slice[rel] }, `${keyPrefix}c${ki++}`));
|
|
212
221
|
if (rel + 1 < slice.length)
|
|
213
222
|
nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
|
|
214
223
|
}
|
|
@@ -404,15 +413,24 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
|
|
|
404
413
|
return;
|
|
405
414
|
}
|
|
406
415
|
if (input && !key.ctrl && !key.meta) {
|
|
407
|
-
// A
|
|
408
|
-
//
|
|
409
|
-
if (
|
|
410
|
-
|
|
416
|
+
// A lone newline delivered through `input` (some terminals send Enter this way instead of
|
|
417
|
+
// setting key.return) = the user pressed Enter → submit.
|
|
418
|
+
if (/^[\r\n]+$/.test(input)) {
|
|
419
|
+
submit(value);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
// A PASTE containing newlines inserts as REAL MULTI-LINE TEXT (normalize CRLF/CR → LF) — the
|
|
423
|
+
// user sees and can edit it, and a pasted newline is content, NOT "send" (only a real Enter,
|
|
424
|
+
// above / key.return, submits). Only a truly ENORMOUS dump folds to a `[Paste #N]` token, so a
|
|
425
|
+
// 500-line paste doesn't turn the box into a wall — normal multi-line pastes stay visible.
|
|
426
|
+
const hasNL = /[\r\n]/.test(input);
|
|
427
|
+
if (input.length >= 8000) {
|
|
428
|
+
addPaste(input.replace(/\r\n?/g, "\n"));
|
|
411
429
|
return;
|
|
412
430
|
}
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
431
|
+
if (hasNL) {
|
|
432
|
+
const text = input.replace(/\r\n?/g, "\n");
|
|
433
|
+
set(value.slice(0, cursor) + text + value.slice(cursor), cursor + text.length);
|
|
416
434
|
return;
|
|
417
435
|
}
|
|
418
436
|
// a dragged-in / pasted image file path attaches instead of inserting literal text
|