@nanhara/hara 0.120.0 → 0.121.1

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,45 @@ 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.1 — field-feedback reliability & credential safety
9
+
10
+ - **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
11
+ width change; height-only window drags keep the input box visible and width changes repaint immediately.
12
+ - **Credentials stay out of durable transcripts and generated code.** Session writes deeply redact likely
13
+ keys/tokens/passwords (including tool inputs/results); legacy content is redacted when loaded and migrated
14
+ on its next atomic, private save. Interactive pastes get a warning without echoing the value. The agent must use environment references,
15
+ never literal secrets in code/commands, and may only populate a real-secret `.env` when explicitly asked.
16
+ - **`web_search` has a verified mainland path.** A configured Tavily request races the keyless Bing China
17
+ HTML path; Baidu, Google, and DuckDuckGo remain concurrent secondary fallbacks. Provider timeouts are
18
+ isolated, and JavaScript-only `web_fetch` pages now explain that a browser/API/connector is required
19
+ instead of returning a misleading empty body.
20
+ - **Long setup commands fail usefully.** Package installs automatically become background jobs unless the
21
+ caller explicitly sets timeout/background, while ngrok tunnels preflight local authentication once and
22
+ stop with a focused fix instead of cycling through unrelated tunnel tools.
23
+ - **Persistent desktop sessions re-read configuration.** New/resumed `hara serve` sessions pick up rotated
24
+ `apiKey`/model/baseURL values without a restart; empty env/project values no longer mask valid global
25
+ config, and a 401 says the configured credential expired rather than asking users to paste it into chat.
26
+
27
+ ## 0.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
28
+
29
+ - **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
30
+ claim/complete/ack/cancel tasks, and persist the desk token with owner-only file permissions. The
31
+ client identifies itself as `hara-cli`, so mixed Codex/Claude Code/hara fleets remain legible.
32
+ - **Coding edits are transaction-safe.** `write_file` uses same-directory atomic replacement;
33
+ multi-file patches validate every destination before writing and roll back already-committed
34
+ files if a later commit fails. Undo snapshots are captured before mutation, failed writes do not
35
+ create false undo history, and symlink/non-regular destinations are rejected consistently.
36
+ - **Large files stay useful without flooding memory or context.** Slice reads stream only the
37
+ requested line window, `@file` mentions have explicit byte/line caps, and tool results are
38
+ bounded centrally (including plugin/MCP results) with clear truncation metadata.
39
+ - **Interactive input is friendlier and bounded.** The TUI composer has shell-style up/down history
40
+ with draft restoration, duplicate suppression, navigation reset after edits, and a fixed maximum
41
+ history size. The ask-mode sentinel is also source-safe on every supported Node runtime.
42
+ - **Faster cold start.** Unicode-width data and the write/edit tool graph are loaded only when the
43
+ active command needs them, reducing startup work for lightweight commands such as `--version`.
44
+ - **Production dependency hardening.** The Lark SDK's Axios transport is pinned to a patched 1.x
45
+ release instead of its vulnerable `~1.13.3` range; the production npm audit is clean at release.
46
+
8
47
  ## 0.120.0 — `hara feedback`: one door for humans and agents
9
48
 
10
49
  - **`hara feedback "what happened"`** files a structured GitHub issue (repo hara-cli/hara,
@@ -38,7 +38,7 @@ export function failoverAction(kind, s) {
38
38
  export function errorHint(kind) {
39
39
  switch (kind) {
40
40
  case "auth":
41
- return " — check your API key / auth (try `hara setup`)";
41
+ return " — the configured credential was rejected or expired; update ~/.hara/config.json, the active profile, or its environment variable, then retry. Do not paste the key into chat";
42
42
  case "rate_limit":
43
43
  return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
44
44
  case "overloaded":
@@ -16,6 +16,7 @@ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_ST
16
16
  import { setTurnPhase } from "./phase.js";
17
17
  import { recordTouch } from "./touched.js";
18
18
  import { resolve as resolvePath } from "node:path";
19
+ import { redactSensitiveText } from "../security/secrets.js";
19
20
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
20
21
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
21
22
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -65,7 +66,14 @@ re-reading a big file after every edit is the slowest habit an agent can have.
65
66
  When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
66
67
  change something (arguments / approach / tool) before trying again. After two failed variants of the same
67
68
  approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
68
- the errors said. Repeating a failed action hoping for a different result is how sessions die. For broad,
69
+ the errors said. Repeating a failed action hoping for a different result is how sessions die.
70
+ Never put a literal password, API key, token, App Secret, Authorization header, or other credential in a
71
+ source file or shell command. Reference an environment variable instead (for example process.env.API_KEY or
72
+ $API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
73
+ .env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
74
+ echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
75
+ not make embedding credentials acceptable.
76
+ For broad,
69
77
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
70
78
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
71
79
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
@@ -88,7 +96,10 @@ site, build output), check they are newer than their sources (compare mtimes or
88
96
  the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
89
97
  When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
90
98
  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.`;
99
+ two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
100
+ poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
101
+ verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
102
+ of unrelated tunnel tools. After completing a task, give a one-line summary.`;
92
103
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
93
104
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
94
105
  function gatewayNote() {
@@ -118,6 +129,17 @@ export async function runAgent(history, opts) {
118
129
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
119
130
  let triedFallback = false;
120
131
  let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
132
+ // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
133
+ // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
134
+ const latestUser = [...history].reverse().find((m) => m.role === "user");
135
+ const sensitive = latestUser?.role === "user" ? redactSensitiveText(latestUser.content).redactions : [];
136
+ if (sensitive.length && !opts.quiet) {
137
+ const note = "⚠ possible credential detected — the saved session copy will be redacted; prefer passing secrets through environment variables.";
138
+ if (ctx.ui)
139
+ ctx.ui.notice(note);
140
+ else if (stdout.isTTY)
141
+ out(c.yellow(note + "\n"));
142
+ }
121
143
  // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
122
144
  // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
123
145
  // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
package/dist/config.js CHANGED
@@ -49,6 +49,28 @@ export function readRawConfig() {
49
49
  return {};
50
50
  }
51
51
  }
52
+ const ROUTING_CONFIG_KEYS = new Set(["provider", "apiKey", "model", "baseURL"]);
53
+ /** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
54
+ * an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
55
+ function withoutBlankRoutingValues(input) {
56
+ const out = {};
57
+ for (const [key, value] of Object.entries(input)) {
58
+ if (ROUTING_CONFIG_KEYS.has(key) && typeof value === "string") {
59
+ const trimmed = value.trim();
60
+ if (!trimmed)
61
+ continue;
62
+ out[key] = trimmed;
63
+ }
64
+ else {
65
+ out[key] = value;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ function nonBlankEnv(value) {
71
+ const trimmed = value?.trim();
72
+ return trimmed || undefined;
73
+ }
52
74
  /** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
53
75
  function readProjectConfig(cwd) {
54
76
  let dir = resolve(cwd);
@@ -121,12 +143,16 @@ export function loadConfig(opts = {}) {
121
143
  const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
122
144
  const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
123
145
  const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
124
- const merged = { ...globalBase, ...project, ...overlay };
125
- const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
146
+ const merged = {
147
+ ...withoutBlankRoutingValues(globalBase),
148
+ ...withoutBlankRoutingValues(project),
149
+ ...withoutBlankRoutingValues(overlay),
150
+ };
151
+ const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
126
152
  const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
127
- const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
128
- const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
129
- const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
153
+ const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
154
+ const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
155
+ const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
130
156
  const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
131
157
  const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
132
158
  const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
@@ -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
+ }
package/dist/feedback.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
4
4
  // The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
5
5
  import { platform, release, arch } from "node:os";
6
+ import { redactSensitiveText } from "./security/secrets.js";
6
7
  export function collectEnv(version, model) {
7
8
  return {
8
9
  version,
@@ -14,13 +15,7 @@ export function collectEnv(version, model) {
14
15
  /** Strip credential-looking material from text that is about to become a PUBLIC issue.
15
16
  * Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
16
17
  export function redact(text) {
17
- return text
18
- .replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
19
- .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
20
- .replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
21
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
22
- .replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
23
- .replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
18
+ return redactSensitiveText(text).text;
24
19
  }
25
20
  /** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
26
21
  * and command-filed issues read identically to the triage side. */
@@ -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
+ }