@nanhara/hara 0.120.0 → 0.121.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 CHANGED
@@ -5,6 +5,26 @@ 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.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
9
+
10
+ - **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
11
+ claim/complete/ack/cancel tasks, and persist the desk token with owner-only file permissions. The
12
+ client identifies itself as `hara-cli`, so mixed Codex/Claude Code/hara fleets remain legible.
13
+ - **Coding edits are transaction-safe.** `write_file` uses same-directory atomic replacement;
14
+ multi-file patches validate every destination before writing and roll back already-committed
15
+ files if a later commit fails. Undo snapshots are captured before mutation, failed writes do not
16
+ create false undo history, and symlink/non-regular destinations are rejected consistently.
17
+ - **Large files stay useful without flooding memory or context.** Slice reads stream only the
18
+ requested line window, `@file` mentions have explicit byte/line caps, and tool results are
19
+ bounded centrally (including plugin/MCP results) with clear truncation metadata.
20
+ - **Interactive input is friendlier and bounded.** The TUI composer has shell-style up/down history
21
+ with draft restoration, duplicate suppression, navigation reset after edits, and a fixed maximum
22
+ history size. The ask-mode sentinel is also source-safe on every supported Node runtime.
23
+ - **Faster cold start.** Unicode-width data and the write/edit tool graph are loaded only when the
24
+ active command needs them, reducing startup work for lightweight commands such as `--version`.
25
+ - **Production dependency hardening.** The Lark SDK's Axios transport is pinned to a patched 1.x
26
+ release instead of its vulnerable `~1.13.3` range; the production npm audit is clean at release.
27
+
8
28
  ## 0.120.0 — `hara feedback`: one door for humans and agents
9
29
 
10
30
  - **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
@@ -1,10 +1,11 @@
1
1
  // @file mentions — expand `@path` references in user input into appended file contents,
2
2
  // and provide fuzzy file candidates for REPL tab-completion.
3
- import { readFileSync, existsSync, statSync } from "node:fs";
3
+ import { existsSync, statSync } from "node:fs";
4
4
  import { isAbsolute, resolve } from "node:path";
5
5
  import { listProjectFiles, dirPrefixes, walkFiles } from "../fs-walk.js";
6
6
  import { fuzzyRank } from "../fuzzy.js";
7
7
  import { mediaTypeFor } from "../images.js";
8
+ import { readTextPrefixSync } from "../fs-read.js";
8
9
  const MAX_FILE = 50_000;
9
10
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
10
11
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
@@ -53,9 +54,10 @@ function expandRef(ref, cwd) {
53
54
  // don't inline binary image bytes as text — paste with Ctrl+V (or drag the file in) to attach visually
54
55
  if (mediaTypeFor(abs))
55
56
  return `Referenced \`${ref}\` is an image — paste it with Ctrl+V to attach it visually.`;
56
- let txt = readFileSync(abs, "utf8");
57
- if (txt.length > MAX_FILE)
58
- txt = txt.slice(0, MAX_FILE) + "\n…[truncated]";
57
+ const prefix = readTextPrefixSync(abs, MAX_FILE);
58
+ if (prefix.binary)
59
+ return `Referenced \`${ref}\` appears to be binary — it was not inserted into the model context.`;
60
+ const txt = prefix.text + (prefix.truncated ? "\n…[truncated]" : "");
59
61
  return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
60
62
  }
61
63
  if (st.isDirectory()) {
@@ -73,16 +75,24 @@ function expandRef(ref, cwd) {
73
75
  const cache = new Map();
74
76
  const CACHE_MS = 5000;
75
77
  function projectEntries(cwd) {
76
- const hit = cache.get(cwd);
78
+ const key = resolve(cwd);
79
+ const hit = cache.get(key);
77
80
  const now = Date.now();
78
81
  if (hit && now - hit.at < CACHE_MS)
79
82
  return hit.entries;
80
- const files = listProjectFiles(cwd);
83
+ const files = listProjectFiles(key);
81
84
  // files + their directory prefixes (so `@src/` drills into the subtree)
82
85
  const entries = [...dirPrefixes(files), ...files];
83
- cache.set(cwd, { at: now, entries });
86
+ cache.set(key, { at: now, entries });
84
87
  return entries;
85
88
  }
89
+ /** Invalidate the derived @path inventory after a coding tool creates/deletes a file. */
90
+ export function invalidateFileCandidates(cwd) {
91
+ if (cwd)
92
+ cache.delete(resolve(cwd));
93
+ else
94
+ cache.clear();
95
+ }
86
96
  /**
87
97
  * File/dir candidates whose path matches `query`, for @ autocomplete.
88
98
  * Recurses subdirectories (git-tracked + untracked, or a filesystem walk outside git),
package/dist/desk.js ADDED
@@ -0,0 +1,64 @@
1
+ // `hara desk` client — how a hara agent connects to the hara-desk coordination server (the closed-
2
+ // source identity registry + task board). This is the OPEN-SOURCE side of the wire: a thin HTTP
3
+ // client + local credential storage. The desk itself (auth, board, L2 ack) lives in hara-desk.
4
+ //
5
+ // Credentials live in ~/.hara/desk.json (0600): the desk URL + this agent's token, written once at
6
+ // `hara desk register`. Every later call reads them back. The token is a bearer secret — never logged.
7
+ import { readFileSync, writeFileSync, mkdirSync, chmodSync } from "node:fs";
8
+ import { homedir } from "node:os";
9
+ import { join } from "node:path";
10
+ const credsPath = () => join(homedir(), ".hara", "desk.json");
11
+ export function loadCreds() {
12
+ try {
13
+ const c = JSON.parse(readFileSync(credsPath(), "utf8"));
14
+ return c.url && c.token ? c : null;
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ export function saveCreds(c) {
21
+ const dir = join(homedir(), ".hara");
22
+ mkdirSync(dir, { recursive: true });
23
+ const p = credsPath();
24
+ writeFileSync(p, JSON.stringify(c, null, 2));
25
+ try {
26
+ chmodSync(p, 0o600);
27
+ }
28
+ catch {
29
+ /* best-effort on non-posix */
30
+ }
31
+ }
32
+ /** One HTTP call to the desk. Auth via bearer token when creds are present. Throws on non-2xx with
33
+ * the server's error message (so the CLI can surface a real reason, not "request failed"). */
34
+ export async function deskCall(url, method, path, opts = {}) {
35
+ const r = await fetch(url.replace(/\/$/, "") + path, {
36
+ method,
37
+ headers: {
38
+ "content-type": "application/json",
39
+ ...(opts.token ? { authorization: `Bearer ${opts.token}` } : {}),
40
+ },
41
+ ...(opts.body !== undefined ? { body: JSON.stringify(opts.body) } : {}),
42
+ signal: AbortSignal.timeout(opts.timeoutMs ?? 10000),
43
+ });
44
+ const text = await r.text();
45
+ let data = {};
46
+ try {
47
+ data = text ? JSON.parse(text) : {};
48
+ }
49
+ catch {
50
+ data = { raw: text };
51
+ }
52
+ if (!r.ok)
53
+ throw new Error(data?.error || `desk ${r.status}: ${text.slice(0, 200)}`);
54
+ return data;
55
+ }
56
+ /** Register this machine's agent with a desk and persist the returned credentials. `client`
57
+ * identifies the kind of client (defaults "hara-cli"); the desk is client-agnostic, so any agent
58
+ * may pass its own label. */
59
+ export async function registerAgent(url, enrollKey, name, owner, client = "hara-cli") {
60
+ const r = await deskCall(url, "POST", "/register", { body: { enrollKey, name, owner, client } });
61
+ const creds = { url, agentId: r.agentId, owner: r.owner, token: r.token };
62
+ saveCreds(creds);
63
+ return creds;
64
+ }
@@ -0,0 +1,157 @@
1
+ // Bounded streaming line reader for files too large to load as one string. It stores only the requested
2
+ // window and a capped prefix of each line, so huge logs/JSONL and minified one-line files stay safe.
3
+ import { closeSync, createReadStream, fstatSync, openSync, readSync } from "node:fs";
4
+ export class BinaryFileError extends Error {
5
+ constructor(path) {
6
+ super(`${path} appears to be binary (NUL byte detected)`);
7
+ this.name = "BinaryFileError";
8
+ }
9
+ }
10
+ const DEFAULT_LINE_CAP = 2000;
11
+ const DEFAULT_MAX_SCAN = 64 * 1024 * 1024;
12
+ /** Read only enough bytes to produce a bounded UTF-8 prefix (used by synchronous @file expansion). */
13
+ export function readTextPrefixSync(path, maxChars) {
14
+ const chars = Math.max(0, Math.floor(maxChars));
15
+ const fd = openSync(path, "r");
16
+ try {
17
+ const size = fstatSync(fd).size;
18
+ // Four bytes per Unicode scalar plus a small boundary cushion guarantees enough decoded input for
19
+ // `chars` without allocating the entire file. readSync can short-read, so fill in a loop.
20
+ const byteLimit = Math.min(size, chars * 4 + 4);
21
+ const buffer = Buffer.allocUnsafe(byteLimit);
22
+ let read = 0;
23
+ while (read < byteLimit) {
24
+ const n = readSync(fd, buffer, read, byteLimit - read, read);
25
+ if (n === 0)
26
+ break;
27
+ read += n;
28
+ }
29
+ const bytes = buffer.subarray(0, read);
30
+ const binary = bytes.subarray(0, Math.min(bytes.length, 4096)).includes(0);
31
+ const decoded = binary ? "" : bytes.toString("utf8");
32
+ return {
33
+ text: decoded.slice(0, chars),
34
+ truncated: size > read || decoded.length > chars,
35
+ binary,
36
+ };
37
+ }
38
+ finally {
39
+ closeSync(fd);
40
+ }
41
+ }
42
+ /** Render a line slice without retaining the entire file. Total line count is shown only when EOF is reached. */
43
+ export async function streamFileSlice(path, offset = 1, limit = 300, options = {}) {
44
+ const start = Math.max(1, Math.floor(offset));
45
+ const want = Math.max(1, Math.floor(limit));
46
+ const requestedEnd = start + want - 1;
47
+ const lineCap = Math.max(1, Math.floor(options.lineCap ?? DEFAULT_LINE_CAP));
48
+ const maxScan = Math.max(lineCap, Math.floor(options.maxScanChars ?? DEFAULT_MAX_SCAN));
49
+ const rendered = [];
50
+ let lineNo = 1;
51
+ let linePrefix = "";
52
+ let lineChars = 0;
53
+ let lineEndsWithCr = false;
54
+ let sawData = false;
55
+ let endedWithNewline = false;
56
+ let scanned = 0;
57
+ let hasMore = false;
58
+ let scanLimited = false;
59
+ let stoppedEarly = false;
60
+ const append = (part) => {
61
+ if (!part.length)
62
+ return;
63
+ sawData = true;
64
+ endedWithNewline = false;
65
+ if (lineNo > requestedEnd) {
66
+ hasMore = true;
67
+ return;
68
+ }
69
+ if (lineNo >= start) {
70
+ lineChars += part.length;
71
+ if (linePrefix.length < lineCap)
72
+ linePrefix += part.slice(0, lineCap - linePrefix.length);
73
+ lineEndsWithCr = part.endsWith("\r");
74
+ }
75
+ };
76
+ const finishLine = (partial = false) => {
77
+ const current = lineNo;
78
+ if (current >= start && current <= requestedEnd) {
79
+ let chars = lineChars;
80
+ let prefix = linePrefix;
81
+ if (!partial && lineEndsWithCr) {
82
+ chars = Math.max(0, chars - 1);
83
+ if (prefix.endsWith("\r"))
84
+ prefix = prefix.slice(0, -1);
85
+ }
86
+ const omitted = Math.max(0, chars - prefix.length);
87
+ const tail = partial
88
+ ? `…[line continues; scan stopped after ${scanned} chars]`
89
+ : omitted
90
+ ? `…[+${omitted} chars]`
91
+ : "";
92
+ rendered.push(`${String(current).padStart(6)}\t${prefix}${tail}`);
93
+ }
94
+ lineNo++;
95
+ linePrefix = "";
96
+ lineChars = 0;
97
+ lineEndsWithCr = false;
98
+ if (current > requestedEnd)
99
+ hasMore = true;
100
+ };
101
+ const stream = createReadStream(path, { encoding: "utf8", highWaterMark: 64 * 1024 });
102
+ try {
103
+ for await (const raw of stream) {
104
+ const chunk = String(raw);
105
+ if (chunk.length)
106
+ sawData = true;
107
+ if (scanned < 4096 && chunk.slice(0, 4096 - scanned).includes("\0"))
108
+ throw new BinaryFileError(path);
109
+ scanned += chunk.length;
110
+ let at = 0;
111
+ while (at < chunk.length) {
112
+ const newline = chunk.indexOf("\n", at);
113
+ if (newline < 0) {
114
+ append(chunk.slice(at));
115
+ break;
116
+ }
117
+ append(chunk.slice(at, newline));
118
+ finishLine();
119
+ endedWithNewline = true;
120
+ at = newline + 1;
121
+ if (hasMore)
122
+ break;
123
+ }
124
+ if (hasMore) {
125
+ stoppedEarly = true;
126
+ break;
127
+ }
128
+ if (scanned >= maxScan) {
129
+ scanLimited = true;
130
+ stoppedEarly = true;
131
+ if (lineNo >= start && lineNo <= requestedEnd)
132
+ finishLine(true);
133
+ break;
134
+ }
135
+ }
136
+ }
137
+ finally {
138
+ stream.destroy();
139
+ }
140
+ if (!stoppedEarly) {
141
+ // A trailing newline creates no phantom line, matching String#split + trailing-empty removal.
142
+ if (!endedWithNewline || !sawData)
143
+ finishLine();
144
+ const total = lineNo - 1;
145
+ if (start > total)
146
+ return `(file has ${total} lines — offset ${start} is past the end)`;
147
+ const end = start + rendered.length - 1;
148
+ const sliced = start > 1 || end < total;
149
+ const head = sliced ? `(lines ${start}–${end} of ${total}${end < total ? ` — continue with offset:${end + 1}` : ""})\n` : "";
150
+ return head + rendered.join("\n");
151
+ }
152
+ const end = start + Math.max(0, rendered.length - 1);
153
+ if (scanLimited) {
154
+ return `(large file scan stopped after ${scanned} chars — use grep or bash byte-range tools for a narrower target)\n${rendered.join("\n")}`;
155
+ }
156
+ return `(lines ${start}–${end}; more lines follow — continue with offset:${end + 1})\n${rendered.join("\n")}`;
157
+ }
@@ -0,0 +1,105 @@
1
+ // Crash-safe UTF-8 writes for coding tools. Content is staged beside the destination, fsynced, then
2
+ // renamed into place so a killed process never leaves a half-written source file.
3
+ import { dirname, join } from "node:path";
4
+ import { link, lstat, mkdir, open, readFile, realpath, rename, stat, unlink } from "node:fs/promises";
5
+ export class FileChangedError extends Error {
6
+ code = "HARA_FILE_CHANGED";
7
+ constructor(path) {
8
+ super(`File changed while the edit was being prepared: ${path}. Re-read it and retry the edit.`);
9
+ this.name = "FileChangedError";
10
+ }
11
+ }
12
+ let tempSequence = 0;
13
+ async function writeTarget(path) {
14
+ try {
15
+ const info = await lstat(path);
16
+ // Replacing a symlink would silently break it. Stage beside the real target instead so edits retain
17
+ // the link (common in dotfile repos and generated workspace layouts).
18
+ if (info.isSymbolicLink())
19
+ return await realpath(path);
20
+ }
21
+ catch (error) {
22
+ if (error?.code !== "ENOENT")
23
+ throw error;
24
+ }
25
+ return path;
26
+ }
27
+ async function syncDirectory(path) {
28
+ // Directory fsync makes the rename durable across a power loss on POSIX. Some filesystems/platforms
29
+ // reject opening directories, so durability degrades gracefully after the file itself was synced.
30
+ try {
31
+ const handle = await open(path, "r");
32
+ try {
33
+ await handle.sync();
34
+ }
35
+ finally {
36
+ await handle.close();
37
+ }
38
+ }
39
+ catch {
40
+ /* best effort */
41
+ }
42
+ }
43
+ /** Atomically replace/create a UTF-8 file, optionally refusing to overwrite a newer disk version. */
44
+ export async function atomicWriteText(path, content, options = {}) {
45
+ const target = await writeTarget(path);
46
+ const dir = dirname(target);
47
+ await mkdir(dir, { recursive: true });
48
+ let mode = 0o666;
49
+ try {
50
+ mode = (await stat(target)).mode & 0o777;
51
+ }
52
+ catch (error) {
53
+ if (error?.code !== "ENOENT")
54
+ throw error;
55
+ }
56
+ // Keep the staging basename fixed-size: prefixing the destination's full basename would make a
57
+ // perfectly valid near-NAME_MAX file impossible to edit because the temporary name becomes longer.
58
+ const temp = join(dir, `.hara-${process.pid}-${Date.now().toString(36)}-${tempSequence++}.tmp`);
59
+ let staged = false;
60
+ try {
61
+ const handle = await open(temp, "wx", mode);
62
+ staged = true;
63
+ try {
64
+ await handle.writeFile(content, "utf8");
65
+ await handle.sync();
66
+ }
67
+ finally {
68
+ await handle.close();
69
+ }
70
+ if (options.expected === null) {
71
+ // link(2) is an atomic create-if-absent operation. A plain rename would overwrite a file that
72
+ // appeared after validation, defeating create's no-clobber contract.
73
+ try {
74
+ await link(temp, target);
75
+ }
76
+ catch (error) {
77
+ if (error?.code === "EEXIST")
78
+ throw new FileChangedError(path);
79
+ throw error;
80
+ }
81
+ await unlink(temp);
82
+ staged = false;
83
+ }
84
+ else {
85
+ if (typeof options.expected === "string") {
86
+ let current;
87
+ try {
88
+ current = await readFile(target, "utf8");
89
+ }
90
+ catch {
91
+ throw new FileChangedError(path);
92
+ }
93
+ if (current !== options.expected)
94
+ throw new FileChangedError(path);
95
+ }
96
+ await rename(temp, target);
97
+ staged = false;
98
+ }
99
+ await syncDirectory(dir);
100
+ }
101
+ finally {
102
+ if (staged)
103
+ await unlink(temp).catch(() => { });
104
+ }
105
+ }
package/dist/index.js CHANGED
@@ -2036,6 +2036,94 @@ program
2036
2036
  out(`\n(gh CLI unavailable or filing failed — copy everything below into ${NEW_ISSUE_URL})\n\n# ${issueTitle(desc)}\n\n${body}\n`);
2037
2037
  }
2038
2038
  });
2039
+ // `hara desk` — connect to a hara-desk coordination server (identity registry + task board).
2040
+ // The desk is the closed-source enterprise piece; this is the open-source client side.
2041
+ const deskCmd = program.command("desk").description("coordinate with a hara-desk server (register · post · board · claim · complete)");
2042
+ deskCmd
2043
+ .command("register")
2044
+ .description("register this agent with a desk and save credentials to ~/.hara/desk.json")
2045
+ .requiredOption("--url <url>", "desk base URL (e.g. http://127.0.0.1:4200)")
2046
+ .requiredOption("--key <enrollKey>", "the desk's enroll key")
2047
+ .option("--name <name>", "agent name shown in the registry", "hara-cli")
2048
+ .option("--owner <owner>", "the human this agent belongs to", "me")
2049
+ .action(async (o) => {
2050
+ const { registerAgent } = await import("./desk.js");
2051
+ try {
2052
+ const creds = await registerAgent(o.url, o.key, o.name, o.owner);
2053
+ out(c.green("✓ registered ") + `${creds.agentId} (owner ${creds.owner}) → ${creds.url}\n`);
2054
+ }
2055
+ catch (e) {
2056
+ out(c.red(`register failed: ${e.message}\n`));
2057
+ }
2058
+ });
2059
+ deskCmd
2060
+ .command("post [title...]")
2061
+ .description("post a task or feedback report to the board")
2062
+ .option("--dispatch", "a dispatch task (someone does work) instead of a feedback report")
2063
+ .option("--high", "high-risk dispatch (needs an owner ack before it can complete)")
2064
+ .option("--body <text>", "task body / details", "")
2065
+ .action(async (parts, o) => {
2066
+ const { loadCreds, deskCall } = await import("./desk.js");
2067
+ const creds = loadCreds();
2068
+ if (!creds)
2069
+ return void out(c.red("not registered — run `hara desk register --url … --key …` first\n"));
2070
+ const title = (parts ?? []).join(" ").trim();
2071
+ if (!title)
2072
+ return void out("Usage: hara desk post <title> [--dispatch] [--high] [--body …]\n");
2073
+ try {
2074
+ const r = await deskCall(creds.url, "POST", "/tasks", { token: creds.token, body: { kind: o.dispatch ? "dispatch" : "feedback", risk: o.high ? "high" : "low", title, body: o.body } });
2075
+ out(c.green("✓ posted ") + `${r.task.id} (${r.task.kind}/${r.task.state})\n`);
2076
+ }
2077
+ catch (e) {
2078
+ out(c.red(`post failed: ${e.message}\n`));
2079
+ }
2080
+ });
2081
+ deskCmd
2082
+ .command("board")
2083
+ .description("list the board (default: open tasks)")
2084
+ .option("--state <state>", "open | claimed | done | cancelled", "open")
2085
+ .option("--kind <kind>", "feedback | dispatch")
2086
+ .action(async (o) => {
2087
+ const { loadCreds, deskCall } = await import("./desk.js");
2088
+ const creds = loadCreds();
2089
+ if (!creds)
2090
+ return void out(c.red("not registered — run `hara desk register` first\n"));
2091
+ try {
2092
+ const q = `/tasks?state=${encodeURIComponent(o.state)}${o.kind ? `&kind=${encodeURIComponent(o.kind)}` : ""}`;
2093
+ const r = await deskCall(creds.url, "GET", q, { token: creds.token });
2094
+ if (!r.tasks.length)
2095
+ return void out(c.dim("(board empty)\n"));
2096
+ for (const t of r.tasks)
2097
+ out(`${t.id} ${c.bold(t.kind)}${t.risk === "high" ? c.red("!") : " "} ${t.state.padEnd(8)} ${t.title}\n`);
2098
+ }
2099
+ catch (e) {
2100
+ out(c.red(`board failed: ${e.message}\n`));
2101
+ }
2102
+ });
2103
+ for (const [verb, path, ok] of [
2104
+ ["claim", "claim", "claimed"],
2105
+ ["complete", "complete", "completed"],
2106
+ ["ack", "ack", "acked"],
2107
+ ["cancel", "cancel", "cancelled"],
2108
+ ]) {
2109
+ deskCmd
2110
+ .command(`${verb} <taskId>`)
2111
+ .description(`${verb} a task`)
2112
+ .option("--detail <text>", "note (complete)", "")
2113
+ .action(async (taskId, o) => {
2114
+ const { loadCreds, deskCall } = await import("./desk.js");
2115
+ const creds = loadCreds();
2116
+ if (!creds)
2117
+ return void out(c.red("not registered — run `hara desk register` first\n"));
2118
+ try {
2119
+ const r = await deskCall(creds.url, "POST", `/tasks/${taskId}/${path}`, { token: creds.token, body: verb === "complete" ? { detail: o.detail } : {} });
2120
+ out(c.green(`✓ ${ok} `) + `${r.task?.id ?? taskId}${r.task ? ` (${r.task.state})` : ""}\n`);
2121
+ }
2122
+ catch (e) {
2123
+ out(c.red(`${verb} failed: ${e.message}\n`));
2124
+ }
2125
+ });
2126
+ }
2039
2127
  const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
2040
2128
  skillsCmd
2041
2129
  .command("init")
@@ -1,11 +1,14 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { dirname, resolve, isAbsolute } from "node:path";
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { resolve, isAbsolute } from "node:path";
3
3
  import { stdout as procOut } from "node:process";
4
4
  import { registerTool } from "./registry.js";
5
5
  import { runShell } from "../sandbox.js";
6
- import { nearestPaths } from "../fs-walk.js";
6
+ import { isProbablyBinary, nearestPaths } from "../fs-walk.js";
7
7
  import { emitDiff } from "../diff.js";
8
8
  import { recordEdit } from "../undo.js";
9
+ import { atomicWriteText } from "../fs-write.js";
10
+ import { invalidateFileCandidates } from "../context/mentions.js";
11
+ import { BinaryFileError, streamFileSlice } from "../fs-read.js";
9
12
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
10
13
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
11
14
  const MAX = 100_000;
@@ -43,8 +46,9 @@ export function capHeadTail(s, max = MAX) {
43
46
  const head = Math.floor(max * 0.6);
44
47
  return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
45
48
  }
46
- const READ_LINES = 2000; // default lines per read_file call (a long file is read in slices, not dumped whole)
49
+ const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
47
50
  const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
51
+ const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
48
52
  /** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
49
53
  * tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
50
54
  * re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
@@ -71,22 +75,31 @@ export function renderFileSlice(text, offset, limit) {
71
75
  }
72
76
  registerTool({
73
77
  name: "read_file",
74
- description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 2000 lines by default — for a longer file pass offset/limit to read the next slice (the output header tells you the total and where to continue). Prefer grep to locate, then read just that region.",
78
+ description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 300 lines by default — for a longer file pass offset/limit to read the next slice (the header tells you where to continue). Large files are streamed instead of loaded whole. Prefer grep to locate, then read just that region.",
75
79
  input_schema: {
76
80
  type: "object",
77
81
  properties: {
78
82
  path: { type: "string", description: "File path, relative to cwd or absolute" },
79
83
  offset: { type: "number", description: "1-based line number to start from (for long files)" },
80
- limit: { type: "number", description: "max lines to return (default 2000)" },
84
+ limit: { type: "number", description: "max lines to return (default 300)" },
81
85
  },
82
86
  required: ["path"],
83
87
  },
84
88
  kind: "read",
85
89
  async run(input, ctx) {
90
+ const p = abs(input.path, ctx.cwd);
86
91
  try {
87
- return cap(renderFileSlice(await readFile(abs(input.path, ctx.cwd), "utf8"), input.offset, input.limit));
92
+ const info = await stat(p);
93
+ if (info.size > BUFFERED_READ_BYTES)
94
+ return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
95
+ const buf = await readFile(p);
96
+ if (isProbablyBinary(buf))
97
+ throw new BinaryFileError(p);
98
+ return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
88
99
  }
89
100
  catch (e) {
101
+ if (e instanceof BinaryFileError)
102
+ return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
90
103
  const near = nearestPaths(ctx.cwd, input.path);
91
104
  return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
92
105
  }
@@ -106,17 +119,27 @@ registerTool({
106
119
  kind: "edit",
107
120
  async run(input, ctx) {
108
121
  const p = abs(input.path, ctx.cwd);
122
+ if (typeof input.content !== "string")
123
+ return "Error: write_file `content` must be a string. No changes written.";
109
124
  let prev = null;
110
125
  try {
111
126
  prev = await readFile(p, "utf8");
112
127
  }
113
- catch {
114
- /* new file */
128
+ catch (error) {
129
+ if (error?.code !== "ENOENT")
130
+ return `Error: cannot inspect ${input.path}: ${error?.code ?? error?.message}. No changes written.`;
131
+ }
132
+ if (prev === input.content)
133
+ return `Unchanged ${p} (${input.content.length} chars already match).`;
134
+ try {
135
+ await atomicWriteText(p, input.content, { expected: prev });
136
+ }
137
+ catch (error) {
138
+ return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
115
139
  }
116
- await mkdir(dirname(p), { recursive: true });
117
- await writeFile(p, input.content, "utf8");
118
140
  emitDiff(input.path, prev ?? "", input.content, ctx.ui);
119
141
  recordEdit([{ path: input.path, absPath: p, before: prev }]);
142
+ invalidateFileCandidates(ctx.cwd);
120
143
  return `Wrote ${String(input.content).length} chars to ${p}`;
121
144
  },
122
145
  });
@@ -1,10 +1,12 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { registerTool } from "./registry.js";
4
4
  import { nearestPaths } from "../fs-walk.js";
5
5
  import { emitDiff } from "../diff.js";
6
6
  import { applyEdits } from "./apply-core.js";
7
7
  import { recordEdit } from "../undo.js";
8
+ import { atomicWriteText } from "../fs-write.js";
9
+ import { invalidateFileCandidates } from "../context/mentions.js";
8
10
  registerTool({
9
11
  name: "edit_file",
10
12
  description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
@@ -53,9 +55,15 @@ registerTool({
53
55
  const res = applyEdits(text, edits);
54
56
  if ("error" in res)
55
57
  return `Error: ${res.error} in ${input.path}. No changes written.`;
56
- await writeFile(p, res.text, "utf8");
58
+ try {
59
+ await atomicWriteText(p, res.text, { expected: text });
60
+ }
61
+ catch (error) {
62
+ return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
63
+ }
57
64
  emitDiff(input.path, text, res.text, ctx.ui);
58
65
  recordEdit([{ path: input.path, absPath: p, before: text }]);
66
+ invalidateFileCandidates(ctx.cwd);
59
67
  const note = res.fuzzy ? " (quote-normalized)" : "";
60
68
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
61
69
  return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;
@@ -1,11 +1,13 @@
1
1
  // apply_patch — change MULTIPLE files atomically (all-or-nothing). Everything is validated and
2
2
  // computed in memory first; nothing is written unless every change applies cleanly.
3
- import { readFile, writeFile, mkdir, unlink } from "node:fs/promises";
4
- import { isAbsolute, resolve, dirname } from "node:path";
3
+ import { lstat, readFile, unlink } from "node:fs/promises";
4
+ import { isAbsolute, resolve } from "node:path";
5
5
  import { registerTool } from "./registry.js";
6
6
  import { applyEdits } from "./apply-core.js";
7
7
  import { emitDiff } from "../diff.js";
8
8
  import { recordEdit } from "../undo.js";
9
+ import { atomicWriteText, FileChangedError } from "../fs-write.js";
10
+ import { invalidateFileCandidates } from "../context/mentions.js";
9
11
  registerTool({
10
12
  name: "apply_patch",
11
13
  description: "Change SEVERAL files in one atomic step (all-or-nothing). `changes` is an array of " +
@@ -51,13 +53,29 @@ registerTool({
51
53
  const abs = (pth) => (isAbsolute(pth) ? pth : resolve(ctx.cwd, pth));
52
54
  // PHASE 1 — validate + compute every change in memory; bail before writing anything.
53
55
  const plans = [];
56
+ const plannedPaths = new Set();
54
57
  for (let i = 0; i < changes.length; i++) {
55
58
  const ch = changes[i];
56
59
  const tag = `change ${i + 1}/${changes.length}`;
57
60
  if (typeof ch.path !== "string" || !ch.path)
58
61
  return `Error: ${tag} is missing a path. Nothing written.`;
59
62
  const p = abs(ch.path);
60
- const type = ch.type ?? (ch.edits ? "update" : "create");
63
+ if (plannedPaths.has(p))
64
+ return `Error: ${tag} repeats path ${ch.path}. Combine edits for one file into a single change. Nothing written.`;
65
+ plannedPaths.add(p);
66
+ let type = ch.type ?? (ch.edits ? "update" : "create");
67
+ // Backward-compatible shorthand: {path, content} updates an existing file and creates a missing
68
+ // one. An EXPLICIT type:create is stricter and never clobbers an existing path.
69
+ if (!ch.type && !ch.edits) {
70
+ try {
71
+ await lstat(p);
72
+ type = "update";
73
+ }
74
+ catch (error) {
75
+ if (error?.code !== "ENOENT")
76
+ return `Error: ${tag} cannot inspect ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
77
+ }
78
+ }
61
79
  if (type === "delete") {
62
80
  let before;
63
81
  try {
@@ -71,16 +89,15 @@ registerTool({
71
89
  else if (type === "create") {
72
90
  if (typeof ch.content !== "string")
73
91
  return `Error: ${tag} create ${ch.path} needs \`content\`. Nothing written.`;
74
- let before = "";
75
- let existed = false;
76
92
  try {
77
- before = await readFile(p, "utf8");
78
- existed = true;
93
+ await lstat(p);
94
+ return `Error: ${tag} create ${ch.path}: path already exists (use type:update to replace it). Nothing written.`;
79
95
  }
80
- catch {
81
- /* new file */
96
+ catch (error) {
97
+ if (error?.code !== "ENOENT")
98
+ return `Error: ${tag} create ${ch.path}: ${error?.message ?? String(error)}. Nothing written.`;
82
99
  }
83
- plans.push({ path: ch.path, abs: p, type, before, after: ch.content, existed });
100
+ plans.push({ path: ch.path, abs: p, type, before: "", after: ch.content, existed: false });
84
101
  }
85
102
  else {
86
103
  // update
@@ -108,27 +125,39 @@ registerTool({
108
125
  try {
109
126
  for (const pl of plans) {
110
127
  if (pl.type === "delete") {
128
+ let current;
129
+ try {
130
+ current = await readFile(pl.abs, "utf8");
131
+ }
132
+ catch {
133
+ throw new FileChangedError(pl.path);
134
+ }
135
+ if (current !== pl.before)
136
+ throw new FileChangedError(pl.path);
111
137
  await unlink(pl.abs);
112
138
  }
113
139
  else {
114
- await mkdir(dirname(pl.abs), { recursive: true });
115
- await writeFile(pl.abs, pl.after, "utf8");
140
+ await atomicWriteText(pl.abs, pl.after, { expected: pl.existed ? pl.before : null });
116
141
  }
117
142
  applied.push(pl);
118
143
  }
119
144
  }
120
145
  catch (e) {
146
+ const rollbackFailures = [];
121
147
  for (const pl of applied.reverse()) {
122
148
  try {
123
149
  if (pl.type === "create" && !pl.existed)
124
150
  await unlink(pl.abs); // remove a file we created
125
151
  else
126
- await writeFile(pl.abs, pl.before, "utf8"); // restore an updated/deleted file's prior content
152
+ await atomicWriteText(pl.abs, pl.before); // restore an updated/deleted file's prior content
127
153
  }
128
- catch {
129
- /* best-effort rollback */
154
+ catch (rollbackError) {
155
+ rollbackFailures.push(`${pl.path}: ${rollbackError?.message ?? String(rollbackError)}`);
130
156
  }
131
157
  }
158
+ if (rollbackFailures.length) {
159
+ return `Error: apply_patch failed (${e instanceof Error ? e.message : String(e)}); rollback was INCOMPLETE: ${rollbackFailures.join("; ")}. Inspect these files before continuing.`;
160
+ }
132
161
  return `Error: apply_patch failed writing a file (${e instanceof Error ? e.message : String(e)}) — rolled back, nothing left changed.`;
133
162
  }
134
163
  // All writes succeeded → now show diffs + record the undo snapshot.
@@ -137,6 +166,7 @@ registerTool({
137
166
  return pl.type === "delete" ? `deleted ${pl.path}` : `${pl.type === "create" ? "created" : "updated"} ${pl.path}`;
138
167
  });
139
168
  recordEdit(plans.map((pl) => ({ path: pl.path, absPath: pl.abs, before: pl.existed ? pl.before : null })));
169
+ invalidateFileCandidates(ctx.cwd);
140
170
  return `apply_patch: ${plans.length} file(s) — ${summary.join("; ")}.`;
141
171
  },
142
172
  });
@@ -1,3 +1,4 @@
1
+ import { limitToolResult } from "./result-limit.js";
1
2
  /** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
2
3
  * against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
3
4
  * path/content mid-stream) — the loop rejects the call with a precise error instead of executing
@@ -9,8 +10,16 @@ export function missingRequired(tool, input) {
9
10
  return req.filter((k) => obj[k] === undefined || obj[k] === null);
10
11
  }
11
12
  const registry = new Map();
13
+ let specsCache = null;
12
14
  export function registerTool(t) {
13
- registry.set(t.name, t);
15
+ const run = t.run;
16
+ registry.set(t.name, {
17
+ ...t,
18
+ // Apply the context boundary at registration so every caller (main loop, tests, embedders) gets
19
+ // identical behavior instead of relying on one orchestration path to remember the cap.
20
+ run: async (input, ctx) => limitToolResult(await run(input, ctx)),
21
+ });
22
+ specsCache = null;
14
23
  }
15
24
  export function getTool(name) {
16
25
  return registry.get(name);
@@ -20,9 +29,14 @@ export function getTools() {
20
29
  }
21
30
  /** Provider-neutral tool specs derived from the registry. */
22
31
  export function toolSpecs() {
23
- return getTools().map((t) => ({
24
- name: t.name,
25
- description: t.description,
26
- input_schema: t.input_schema,
27
- }));
32
+ if (!specsCache) {
33
+ specsCache = getTools().map((t) => ({
34
+ name: t.name,
35
+ description: t.description,
36
+ input_schema: t.input_schema,
37
+ }));
38
+ }
39
+ // Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
40
+ // stable cached snapshot shared by subsequent agent rounds.
41
+ return specsCache.slice();
28
42
  }
@@ -0,0 +1,37 @@
1
+ // One invariant for every registered tool: no single result may monopolize the model context. Individual
2
+ // tools can use tighter domain-specific limits, but this final boundary also covers plugins/new tools.
3
+ export const MAX_TOOL_RESULT_CHARS = 24_000;
4
+ function safeHead(value, end) {
5
+ let at = Math.max(0, Math.min(value.length, end));
6
+ if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
7
+ at--;
8
+ return value.slice(0, at);
9
+ }
10
+ function safeTail(value, start) {
11
+ let at = Math.max(0, Math.min(value.length, start));
12
+ if (at < value.length && /[\uDC00-\uDFFF]/.test(value[at] ?? ""))
13
+ at++;
14
+ return value.slice(at);
15
+ }
16
+ /** Keep actionable beginnings and endings while bounding the exact string persisted in history. */
17
+ export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
18
+ const text = typeof value === "string" ? value : String(value ?? "");
19
+ const cap = Math.max(0, Math.floor(max));
20
+ if (text.length <= cap)
21
+ return text;
22
+ if (cap === 0)
23
+ return "";
24
+ let omitted = text.length - cap;
25
+ let fullMarker = "";
26
+ // The marker consumes part of the budget too. Iterate twice so its count reflects the actual payload
27
+ // removed rather than understating it by roughly the marker's own length.
28
+ for (let i = 0; i < 2; i++) {
29
+ fullMarker = `\n…[hara: ${omitted} chars omitted; narrow the query or continue read_file with offset/limit]…\n`;
30
+ omitted = text.length - Math.max(0, cap - fullMarker.length);
31
+ }
32
+ const marker = fullMarker.length < cap ? fullMarker : "…[truncated]…".slice(0, cap);
33
+ const room = cap - marker.length;
34
+ const headChars = Math.floor(room * 0.6);
35
+ const tailChars = room - headChars;
36
+ return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
37
+ }
package/dist/tui/App.js CHANGED
@@ -516,7 +516,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
516
516
  const askTextFn = (title) => new Promise((resolve) => setAskText({ title, resolve }));
517
517
  // ask_user: when options are given, offer them as a select + a "type my own" escape hatch; otherwise (or
518
518
  // when the user chooses to type their own) capture a free-text line. Returns the chosen/typed answer.
519
- const OTHER = "__ask_other__"; // sentinel value for the "type my own" option
519
+ const OTHER = "\\0__ask_other__"; // escaped sentinel keeps this TypeScript file text-only
520
520
  const askFn = async (question, options) => {
521
521
  if (options && options.length) {
522
522
  const choice = await openPrompt(question, [
@@ -14,10 +14,11 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
14
14
  // of leaning on ink's soft-wrap of a single <Text>, which mis-aligns wrapped rows against the
15
15
  // "› " prompt gutter and reflows unpredictably as you type.
16
16
  import { Box, Text, useInput, useStdout } from "ink";
17
- import { memo, useMemo, useState } from "react";
17
+ import { memo, useMemo, useRef, useState } from "react";
18
18
  import { fileCandidates } from "../context/mentions.js";
19
19
  import { imagePathFromPaste } from "../images.js";
20
20
  import { vimNormal } from "./vim.js";
21
+ import { ComposerHistory, moveCursorLine, nextGraphemeIndex, previousGraphemeIndex, previousWordIndex, } from "./input-history.js";
21
22
  export const MODES = ["suggest", "auto-edit", "full-auto", "plan"];
22
23
  export const nextMode = (m) => MODES[(MODES.indexOf(m) + 1) % MODES.length];
23
24
  const tok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
@@ -312,15 +313,30 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
312
313
  const [mode, setMode] = useState("insert"); // vim only
313
314
  const [pending, setPending] = useState(""); // vim operator-pending (d/c/g)
314
315
  const [register, setRegister] = useState(""); // vim yank/delete register
316
+ const historyRef = useRef(null);
317
+ if (!historyRef.current)
318
+ historyRef.current = new ComposerHistory();
319
+ const inputHistory = historyRef.current;
315
320
  const set = (v, c) => {
321
+ inputHistory.abandonNavigation();
316
322
  setValue(v);
317
323
  setCursor(c);
318
324
  setSel(0);
319
325
  setDismissed(false);
320
326
  };
327
+ const snapshot = () => ({ value, attachments: images, pastes });
328
+ const restore = (draft) => {
329
+ setValue(draft.value);
330
+ setCursor(draft.value.length);
331
+ setImages(draft.attachments);
332
+ setPastes(draft.pastes);
333
+ setSel(0);
334
+ setDismissed(false);
335
+ };
321
336
  // Attach an image: drop a highlighted `[Image #N]` token inline at the cursor and track the file
322
337
  // (codex / Claude-Code style). Backspace over the token removes both.
323
338
  const addImage = (img) => {
339
+ inputHistory.abandonNavigation();
324
340
  const tok = `[Image #${images.length + 1}]`;
325
341
  const before = value.slice(0, cursor);
326
342
  const ins = (before && !/\s$/.test(before) ? " " : "") + tok + " ";
@@ -334,6 +350,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
334
350
  // the box: typing stays smooth (the VALUE stays short), the box stays small, and a multi-line paste
335
351
  // can no longer fire the newline-submit path mid-paste. Expanded back to the full text on submit.
336
352
  const addPaste = (text) => {
353
+ inputHistory.abandonNavigation();
337
354
  const lines = text.split("\n").length;
338
355
  const tok = `[Paste #${pastes.length + 1} +${lines} lines]`;
339
356
  const before = value.slice(0, cursor);
@@ -348,6 +365,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
348
365
  const submit = (text) => {
349
366
  if (!text.trim() && images.length === 0)
350
367
  return; // nothing to send
368
+ inputHistory.record(snapshot());
351
369
  onSubmit?.(expandPastes(text), images.length ? images : undefined);
352
370
  set("", 0);
353
371
  setImages([]);
@@ -365,6 +383,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
365
383
  const complete = (cand) => {
366
384
  if (!mention)
367
385
  return;
386
+ inputHistory.abandonNavigation();
368
387
  const before = value.slice(0, mention.start); // includes the leading '@'
369
388
  const after = value.slice(cursor);
370
389
  const insert = cand.endsWith("/") ? cand : cand + " "; // dirs keep drilling; files end the mention
@@ -406,6 +425,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
406
425
  return setCursor((c) => Math.max(0, c - 1));
407
426
  if (input && !key.ctrl && !key.meta) {
408
427
  const st = vimNormal({ value, cursor, mode, pending, register }, input);
428
+ if (st.value !== value)
429
+ inputHistory.abandonNavigation();
409
430
  setValue(st.value);
410
431
  setCursor(st.cursor);
411
432
  setMode(st.mode);
@@ -416,20 +437,54 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
416
437
  }
417
438
  return;
418
439
  }
440
+ if (key.upArrow) {
441
+ const onFirstLine = cursor === 0 || value.lastIndexOf("\n", cursor - 1) < 0;
442
+ if (onFirstLine) {
443
+ const draft = inputHistory.older(snapshot());
444
+ if (draft)
445
+ restore(draft);
446
+ }
447
+ else {
448
+ setCursor(moveCursorLine(value, cursor, -1));
449
+ }
450
+ return;
451
+ }
452
+ if (key.downArrow) {
453
+ const onLastLine = value.indexOf("\n", cursor) < 0;
454
+ if (onLastLine) {
455
+ const draft = inputHistory.newer();
456
+ if (draft)
457
+ restore(draft);
458
+ }
459
+ else {
460
+ setCursor(moveCursorLine(value, cursor, 1));
461
+ }
462
+ return;
463
+ }
419
464
  if (key.return) {
465
+ if (key.shift || key.meta) {
466
+ set(value.slice(0, cursor) + "\n" + value.slice(cursor), cursor + 1);
467
+ return;
468
+ }
420
469
  submit(value);
421
470
  return;
422
471
  }
423
472
  if (key.leftArrow)
424
- return setCursor((c) => Math.max(0, c - 1));
473
+ return setCursor((c) => previousGraphemeIndex(value, c));
425
474
  if (key.rightArrow)
426
- return setCursor((c) => Math.min(value.length, c + 1));
475
+ return setCursor((c) => nextGraphemeIndex(value, c));
427
476
  if (key.ctrl && input === "a")
428
477
  return setCursor(0);
429
478
  if (key.ctrl && input === "e")
430
479
  return setCursor(value.length);
431
480
  if (key.ctrl && input === "u")
432
481
  return set(value.slice(cursor), 0);
482
+ if (key.ctrl && input === "w") {
483
+ const start = previousWordIndex(value, cursor);
484
+ return set(value.slice(0, start) + value.slice(cursor), start);
485
+ }
486
+ if (key.ctrl && input === "k")
487
+ return set(value.slice(0, cursor), cursor);
433
488
  if (key.ctrl && input === "v") {
434
489
  // paste a screenshot / image from the OS clipboard
435
490
  const img = onClipboardImage?.();
@@ -442,6 +497,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
442
497
  const head = value.slice(0, cursor);
443
498
  const pm = /\[Paste #(\d+) \+\d+ lines\]\s?$/.exec(head); // paste token deletes whole + renumbers
444
499
  if (pm) {
500
+ inputHistory.abandonNavigation();
445
501
  const n = Number(pm[1]);
446
502
  const kept = head.slice(0, pm.index) + value.slice(cursor);
447
503
  const renumbered = kept.replace(/\[Paste #(\d+)( \+\d+ lines\])/g, (m2, d, tail) => (Number(d) > n ? `[Paste #${Number(d) - 1}${tail}` : m2));
@@ -454,6 +510,7 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
454
510
  }
455
511
  const tm = /\[Image #(\d+)\]\s?$/.exec(head); // backspacing over an attachment token removes it whole
456
512
  if (tm) {
513
+ inputHistory.abandonNavigation();
457
514
  const n = Number(tm[1]);
458
515
  const kept = head.slice(0, tm.index) + value.slice(cursor);
459
516
  const renumbered = kept.replace(/\[Image #(\d+)\]/g, (m2, d) => (Number(d) > n ? `[Image #${Number(d) - 1}]` : m2));
@@ -464,7 +521,8 @@ export function InputBox({ status, cwd, model, route, width, onSubmit, onClipboa
464
521
  setDismissed(false);
465
522
  return;
466
523
  }
467
- set(value.slice(0, cursor - 1) + value.slice(cursor), cursor - 1);
524
+ const previous = previousGraphemeIndex(value, cursor);
525
+ set(value.slice(0, previous) + value.slice(cursor), previous);
468
526
  }
469
527
  return;
470
528
  }
@@ -0,0 +1,167 @@
1
+ // Pure composer state: bounded shell-style history plus Unicode-safe cursor helpers. Keeping this out
2
+ // of the Ink component makes navigation deterministic and avoids copying a growing history on each key.
3
+ const cloneDraft = (draft) => ({
4
+ value: draft.value,
5
+ attachments: [...draft.attachments],
6
+ pastes: [...draft.pastes],
7
+ });
8
+ const draftChars = (draft) => draft.value.length + draft.pastes.reduce((sum, paste) => sum + paste.length, 0) + draft.attachments.length * 128;
9
+ /** Bounded in-process history. The draft that existed before Up is restored after navigating Down. */
10
+ export class ComposerHistory {
11
+ maxEntries;
12
+ maxChars;
13
+ entries = [];
14
+ cursor = null;
15
+ scratch = null;
16
+ totalChars = 0;
17
+ constructor(maxEntries = 100, maxChars = 2_000_000) {
18
+ this.maxEntries = maxEntries;
19
+ this.maxChars = maxChars;
20
+ }
21
+ get browsing() {
22
+ return this.cursor !== null;
23
+ }
24
+ get length() {
25
+ return this.entries.length;
26
+ }
27
+ record(draft) {
28
+ this.abandonNavigation();
29
+ const chars = draftChars(draft);
30
+ // A giant paste is already retained by the conversation history. Do not duplicate an entry larger
31
+ // than the entire composer-history budget just to support Up-arrow recall.
32
+ if (chars > this.maxChars || this.maxEntries <= 0)
33
+ return;
34
+ this.entries.push({ draft: cloneDraft(draft), chars });
35
+ this.totalChars += chars;
36
+ while (this.entries.length > this.maxEntries || this.totalChars > this.maxChars) {
37
+ this.totalChars -= this.entries.shift().chars;
38
+ }
39
+ }
40
+ older(current) {
41
+ if (!this.entries.length)
42
+ return null;
43
+ if (this.cursor === null) {
44
+ this.scratch = cloneDraft(current);
45
+ this.cursor = this.entries.length - 1;
46
+ }
47
+ else if (this.cursor > 0) {
48
+ this.cursor--;
49
+ }
50
+ return cloneDraft(this.entries[this.cursor].draft);
51
+ }
52
+ newer() {
53
+ if (this.cursor === null)
54
+ return null;
55
+ if (this.cursor < this.entries.length - 1) {
56
+ this.cursor++;
57
+ return cloneDraft(this.entries[this.cursor].draft);
58
+ }
59
+ const draft = this.scratch ?? { value: "", attachments: [], pastes: [] };
60
+ this.cursor = null;
61
+ this.scratch = null;
62
+ return cloneDraft(draft);
63
+ }
64
+ abandonNavigation() {
65
+ this.cursor = null;
66
+ this.scratch = null;
67
+ }
68
+ }
69
+ let segmenter;
70
+ function graphemeSegmenter() {
71
+ // Constructing Intl.Segmenter loads ICU data. Defer that cost until the first actual cursor edit so
72
+ // lightweight commands such as `hara --version` do not pay for TUI Unicode support.
73
+ if (segmenter === undefined)
74
+ segmenter = typeof Intl.Segmenter === "function" ? new Intl.Segmenter(undefined, { granularity: "grapheme" }) : null;
75
+ return segmenter;
76
+ }
77
+ /** Previous user-perceived character boundary (keeps emoji ZWJ sequences/combining marks intact). */
78
+ export function previousGraphemeIndex(value, cursor) {
79
+ const at = Math.max(0, Math.min(value.length, cursor));
80
+ if (at === 0)
81
+ return 0;
82
+ const segments = graphemeSegmenter();
83
+ if (!segments) {
84
+ const low = value.charCodeAt(at - 1);
85
+ const paired = low >= 0xdc00 && low <= 0xdfff && at > 1 && value.charCodeAt(at - 2) >= 0xd800 && value.charCodeAt(at - 2) <= 0xdbff;
86
+ return Math.max(0, at - (paired ? 2 : 1));
87
+ }
88
+ let previous = 0;
89
+ for (const part of segments.segment(value)) {
90
+ if (part.index >= at)
91
+ break;
92
+ previous = part.index;
93
+ }
94
+ return previous;
95
+ }
96
+ /** Next user-perceived character boundary. */
97
+ export function nextGraphemeIndex(value, cursor) {
98
+ const at = Math.max(0, Math.min(value.length, cursor));
99
+ if (at >= value.length)
100
+ return value.length;
101
+ const segments = graphemeSegmenter();
102
+ if (!segments) {
103
+ const cp = value.codePointAt(at);
104
+ return Math.min(value.length, at + (cp !== undefined && cp > 0xffff ? 2 : 1));
105
+ }
106
+ for (const part of segments.segment(value)) {
107
+ const end = part.index + part.segment.length;
108
+ if (end > at)
109
+ return end;
110
+ }
111
+ return value.length;
112
+ }
113
+ /** Start of the previous shell-style word, used by Ctrl+W. */
114
+ export function previousWordIndex(value, cursor) {
115
+ let at = Math.max(0, Math.min(value.length, cursor));
116
+ while (at > 0) {
117
+ const previous = previousGraphemeIndex(value, at);
118
+ if (!/\s/u.test(value.slice(previous, at)))
119
+ break;
120
+ at = previous;
121
+ }
122
+ while (at > 0) {
123
+ const previous = previousGraphemeIndex(value, at);
124
+ if (/\s/u.test(value.slice(previous, at)))
125
+ break;
126
+ at = previous;
127
+ }
128
+ return at;
129
+ }
130
+ function graphemeFloor(value, cursor) {
131
+ const at = Math.max(0, Math.min(value.length, cursor));
132
+ if (at === value.length)
133
+ return at;
134
+ const segments = graphemeSegmenter();
135
+ if (!segments)
136
+ return at > 0 && /[\uDC00-\uDFFF]/.test(value[at]) ? at - 1 : at;
137
+ let floor = 0;
138
+ for (const part of segments.segment(value)) {
139
+ if (part.index > at)
140
+ break;
141
+ floor = part.index;
142
+ if (part.index + part.segment.length === at)
143
+ return at;
144
+ }
145
+ return floor;
146
+ }
147
+ /** Move between logical pasted/typed lines while retaining the closest possible column. */
148
+ export function moveCursorLine(value, cursor, direction) {
149
+ const at = Math.max(0, Math.min(value.length, cursor));
150
+ const lineStart = (at === 0 ? -1 : value.lastIndexOf("\n", at - 1)) + 1;
151
+ const lineEndAt = value.indexOf("\n", at);
152
+ const lineEnd = lineEndAt < 0 ? value.length : lineEndAt;
153
+ const column = Math.min(at - lineStart, lineEnd - lineStart);
154
+ if (direction < 0) {
155
+ if (lineStart === 0)
156
+ return at;
157
+ const previousEnd = lineStart - 1;
158
+ const previousStart = value.lastIndexOf("\n", Math.max(0, previousEnd - 1)) + 1;
159
+ return graphemeFloor(value, previousStart + Math.min(column, previousEnd - previousStart));
160
+ }
161
+ if (lineEnd === value.length)
162
+ return at;
163
+ const nextStart = lineEnd + 1;
164
+ const nextEndAt = value.indexOf("\n", nextStart);
165
+ const nextEnd = nextEndAt < 0 ? value.length : nextEndAt;
166
+ return graphemeFloor(value, nextStart + Math.min(column, nextEnd - nextStart));
167
+ }
package/dist/undo.js CHANGED
@@ -1,7 +1,8 @@
1
1
  // In-session undo stack for file changes. Each edit tool records the prior state of the files it
2
2
  // touched; `/undo` pops the last group and restores it. Process-scoped (one REPL session).
3
- import { writeFile, unlink, mkdir } from "node:fs/promises";
4
- import { dirname } from "node:path";
3
+ import { unlink } from "node:fs/promises";
4
+ import { atomicWriteText } from "./fs-write.js";
5
+ import { invalidateFileCandidates } from "./context/mentions.js";
5
6
  const stack = [];
6
7
  const MAX = 50;
7
8
  /** Record a group of file changes (one tool call = one undo step). */
@@ -27,8 +28,7 @@ export async function undoLast() {
27
28
  await unlink(s.absPath).catch(() => { }); // was newly created → remove
28
29
  }
29
30
  else {
30
- await mkdir(dirname(s.absPath), { recursive: true });
31
- await writeFile(s.absPath, s.before, "utf8");
31
+ await atomicWriteText(s.absPath, s.before);
32
32
  }
33
33
  files.push(s.path);
34
34
  }
@@ -36,5 +36,7 @@ export async function undoLast() {
36
36
  /* skip a file we can't restore */
37
37
  }
38
38
  }
39
+ if (files.length)
40
+ invalidateFileCandidates();
39
41
  return { files };
40
42
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.120.0",
3
+ "version": "0.121.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -70,5 +70,10 @@
70
70
  "optionalDependencies": {
71
71
  "@zvec/zvec": "^0.5.0",
72
72
  "qrcode-terminal": "^0.12.0"
73
+ },
74
+ "overrides": {
75
+ "@larksuiteoapi/node-sdk": {
76
+ "axios": "1.18.1"
77
+ }
73
78
  }
74
79
  }