@nanhara/hara 0.122.7 → 0.123.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,34 @@ 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.123.0 — 2026-07-15 — task-aware interaction and input/security hardening
9
+
10
+ - **Conversation and execution are separate state.** Sessions now persist a bounded, redacted task record
11
+ beside the transcript: stable task/turn IDs, the original objective, running/paused/completed/blocked
12
+ status, outcomes, and steering audit. A crashed running task recovers as paused/interrupted; the first
13
+ input after resume continues an unfinished objective instead of silently replacing it. Legacy sessions
14
+ without task state still load normally, and `/task` shows or clears only execution state while preserving
15
+ the conversation.
16
+ - **Mid-turn input targets an exact run.** TUI type-ahead carries an `expectedTurnId`, late input remains a
17
+ steer rather than becoming an accidental new task, and Desktop/serve exposes additive `session.steer`,
18
+ task/turn events, resumed task metadata, plus explicit `newTask: true`. Task context is injected separately
19
+ from transcript/project context so a conversational reply cannot redefine the active coding objective.
20
+ - **Terminal text paste follows bracketed-paste framing.** Hara enables terminal mode 2004, buffers split
21
+ `ESC[200~`/`ESC[201~` frames into one logical insert, handles immediate paste+Enter correctly, restores the
22
+ terminal on exit, and fails visibly on incomplete or over-2 MiB input. Multiline Claude output no longer
23
+ freezes, disappears, or requires Ctrl+C before it can be pasted.
24
+ - **Three reported path/alias escapes are closed.** Windows Guardian containment uses platform-aware relative
25
+ paths; global `~/.hara/config.json` reads reject symlink/hard-link aliases and writes use a private atomic
26
+ replacement without modifying an external inode; organization role sync accepts only portable role IDs
27
+ and verifies every final path remains directly below `~/.hara/org-roles`.
28
+ - **Home remains private without becoming inconvenient.** `hara --cwd /path/to/project` explicitly selects a
29
+ project as an alternative to `cd`, and every Home-boundary refusal now shows both choices. Hara deliberately
30
+ does not scan Home and guess a repository, avoiding ambiguous selection and renewed private-directory
31
+ inventory.
32
+ - **Cancellation gates no longer probe PID 0.** Computer/cron process-tree fixtures wait for a fully published
33
+ positive child PID before cancellation assertions, retaining the original strict settlement and process
34
+ disappearance deadlines under full-suite contention.
35
+
8
36
  ## 0.122.7 — 2026-07-15 — isolated build-context parity
9
37
 
10
38
  - **Docker builds carry every package-build dependency.** The image build stage now copies the local-link
package/README.md CHANGED
@@ -18,8 +18,8 @@
18
18
  - **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
19
19
  - **Delegate to other agents** — the **`external_agent`** tool hands a self-contained task to **Claude Code** or **Codex** running headless, and returns the result — so you pick the best engine per task. It is a trusted extension outside Hara's protected-file boundary: every interactive call requires confirmation, and non-interactive use is disabled by default.
20
20
  - **Honest under a slow network** — a live "waiting for the model… Ns" status, a stall watchdog that
21
- auto-fails-over instead of hanging, big pastes folding to a token, and a startup update notice — the
22
- terminal never feels dead.
21
+ auto-fails-over instead of hanging, terminal-native bracketed paste, big pastes folding to a token, and a
22
+ startup update notice — the terminal never feels dead.
23
23
  - **Solid coding core** — `edit_file` / `apply_patch` (atomic multi-file) with colored diffs · `grep`/`glob`/`ls`/`codebase_search` (lexical + optional semantic search over the repo) /`web_fetch` · fuzzy `@file` · `/undo` · `/compact` · **Esc-to-interrupt** · parallel sub-agents · MCP client · macOS sandbox.
24
24
 
25
25
  Track it: https://github.com/hara-cli/hara · https://hara.run
@@ -176,6 +176,7 @@ hara -p "extract package metadata" --schema ./schema.json # stdout is ex
176
176
  hara -p "review the current diff" --role reviewer # persona + model + tool policy from the role
177
177
  hara --approval auto-edit # suggest (default) | auto-edit | full-auto (-y = full-auto)
178
178
  hara --sandbox workspace-write # confine shell writes to the project (macOS Seatbelt)
179
+ hara --cwd /path/to/project # explicitly select a workspace without changing your shell directory
179
180
  hara -c # resume the most recent session in this directory
180
181
  hara --profile work # use a named profile from ~/.hara/config.json
181
182
  hara -m glm-5 # pick a model
@@ -183,7 +184,8 @@ hara -m glm-5 # pick a model
183
184
 
184
185
  Run Hara from a project directory. When the current directory resolves to your Home root, Hara does not treat
185
186
  the whole Home tree as a repository: project init/index and default recursive grep/glob/codebase inventory are
186
- disabled with a `cd /path/to/project` hint. Directory listing and child-directory recursion are also refused so
187
+ disabled with a `cd /path/to/project` / `hara --cwd /path/to/project` hint. Directory listing and
188
+ child-directory recursion are also refused so
187
189
  the model cannot promote a discovered Home folder into an implicit project; explicitly named single files still
188
190
  work. Resuming a session reuses its persisted conversation and must not trigger workspace rediscovery.
189
191
 
@@ -193,7 +195,10 @@ stderr and missing/invalid output exits non-zero. `--role reviewer` resolves loc
193
195
  uses the portable global persona in the current project, and `--role shop:reviewer` runs at that registered
194
196
  project home. Each form enforces the role's persona, model, `allowTools`/`denyTools`, and `readOnly` policy.
195
197
 
196
- Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/usage` `/doctor` `/sessions` `/undo` `/compact` `/recall` `/reset` `/exit` (type `/`+Tab to complete). Type `@` + Tab to attach a file (fuzzy, walks subdirectories).
198
+ Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/task` `/usage`
199
+ `/doctor` `/sessions` `/undo` `/compact` `/recall` `/reset` `/exit` (type `/`+Tab to complete). `/task`
200
+ shows the active execution record; `/task clear` drops it without deleting the conversation. Type `@` + Tab
201
+ to attach a file (fuzzy, walks subdirectories).
197
202
 
198
203
  The interactive REPL is an **ink TUI**: a bordered **input box pinned at the bottom** — session name in
199
204
  the top-right corner, approval modes + token usage + concurrency in the bottom border — with the
@@ -205,6 +210,10 @@ token inline where your cursor is (backspace over it to remove it). hara auto-de
205
210
  a vision model sees the image directly; a text-only model routes it through a `visionModel` describer (see
206
211
  Setup), shown in the header at startup. Set `HARA_TUI=0` for the classic readline REPL.
207
212
 
213
+ Text pasted by a modern terminal is handled as one bracketed-paste event, including multiline Claude/Codex
214
+ output and a paste immediately followed by Enter. It is inserted for review and never auto-submitted merely
215
+ because it contains newlines; malformed/incomplete frames are bounded and surfaced instead of hanging raw mode.
216
+
208
217
  Each session gets a **UUID** and an **auto-summarized name** from your first message (kept verbatim, CJK
209
218
  included); `hara sessions` lists them by short id, and `--resume <prefix>` accepts the short id.
210
219
 
@@ -246,7 +255,14 @@ only changed files re-embed (a full repo rebuild that takes ~a minute re-runs in
246
255
  `hara config set computerUse read|click|full` and allowlist apps with `hara config set computerApps "App, …"`. Guarded
247
256
  by the tier, the frontmost-app allowlist, a dangerous-key blocklist, and a once-per-session grant. Screenshots are read via your
248
257
  vision model into **actionable** output — interactive elements + positions (pass `focus` to target what you're after) — so even a text-only main model can click.
249
- **Sessions**: conversations are saved automatically — `-c` / `--resume <id>` or `hara resume <id>` to continue, `hara sessions` to list, `hara export [id] [--out file]` to render one as a Markdown transcript. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary installs.
258
+ **Sessions and task execution**: conversations are saved automatically — `-c` / `--resume <id>` or
259
+ `hara resume <id>` to continue, `hara sessions` to list, `hara export [id] [--out file]` to render one as a
260
+ Markdown transcript. The current task is persisted separately with stable task/turn identity and resumes as
261
+ paused rather than being inferred from chat text; `/task` inspects it and `/task clear` leaves the transcript
262
+ intact. Type-ahead input steers the exact live turn. Desktop/serve clients can use `session.steer` with
263
+ `expectedTurnId`, and may pass `newTask: true` to `session.send` when an unfinished resumed task should be
264
+ replaced explicitly. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
265
+ installs.
250
266
  **MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch); their tools appear to the agent as `mcp__<server>__<tool>`. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive tool call requires confirmation (even in `full-auto`), and non-interactive runs disable them by default; reviewed automation can explicitly opt in before launch with `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
251
267
  **Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
252
268
  **Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`. Every job has a 30-minute deadline and the whole sequential tick has a non-renewable 60-minute watchdog: a job timeout kills its process tree, records `timed out` + duration/error, then continues with the next due job; a tick timeout stops the remainder and releases the global lock. Add `--deliver feishu:<chatId>` (or Telegram/webhook) for outcomes and `--alert-after N` for the consecutive-failure 🚨 threshold (default 3). Delivery intent is durable before transport, uses a stable idempotency key, and retries with bounded backoff on later ticks until confirmed. A failed channel cannot grow `jobs.json` forever: each job keeps at most 64 pending effects, reserves outcome/alert room before launch, and disables itself with a visible backlog error when full; restore delivery, let the queue drain, then re-enable it. Tune milliseconds with `HARA_CRON_JOB_TIMEOUT_MS` (hard max 24h) and `HARA_CRON_TICK_TIMEOUT_MS` (hard max 5h); scheduled jobs are also capped by the tick. After upgrading from a version whose tick is already stuck, terminate that specific legacy `hara cron tick` process tree once (or reboot); the next scheduler minute marks over-age state interrupted/disabled and recovers the lock without replaying a possibly orphaned task.
@@ -321,7 +337,7 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
321
337
  - **Type-ahead steering**: keep typing while hara works — your message is held, then **folded into the next model call** (not deferred to a new turn), so a clarification or "also do X" course-corrects the task already in flight (codex-style). Messages typed after the final step start a fresh turn; **Esc** drops the queue and stops.
322
338
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
323
339
  - **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
324
- - **Explicit workspace boundary**: launching at Home does not inventory its directories or permit coding mutations. Start Hara from a concrete project to enable search, `@` completion, shell/external agents, and file edits; explicitly named single-file reads remain available at Home.
340
+ - **Explicit workspace boundary**: launching at Home does not inventory its directories or permit coding mutations. Start Hara from a concrete project, or pass `hara --cwd /path/to/project`, to enable search, `@` completion, shell/external agents, and file edits; explicitly named single-file reads remain available at Home.
325
341
  - **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
326
342
  - **Chat gateway**: drive your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), agent switching (`/agent`), and **two-way images on byte-upload-capable platforms**. Setup, platform capability details, and the group-flow security model: **[docs/gateway.md](docs/gateway.md)**.
327
343
 
@@ -132,12 +132,13 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
132
132
  "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
133
133
  "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
134
134
  "directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
135
- function composeSystem(cwd, projectContext, override, memory, continuationSession = false) {
135
+ function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext) {
136
136
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
137
137
  const skills = skillsDigest(cwd);
138
138
  return (head +
139
139
  gatewayNote() +
140
140
  (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
141
+ (executionContext ? `\n\n${executionContext}` : "") +
141
142
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
142
143
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
143
144
  (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
@@ -427,7 +428,7 @@ async function runAgentInner(history, opts, life) {
427
428
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
428
429
  }
429
430
  return activeProvider.turn({
430
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession),
431
+ system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext),
431
432
  history,
432
433
  tools: specs,
433
434
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
package/dist/config.js CHANGED
@@ -1,6 +1,7 @@
1
+ import { randomUUID } from "node:crypto";
1
2
  import { homedir } from "node:os";
2
3
  import { join, dirname, resolve } from "node:path";
3
- import { chmodSync, existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
4
+ import { closeSync, constants, existsSync, fstatSync, fsyncSync, lstatSync, mkdirSync, openSync, realpathSync, renameSync, rmSync, writeFileSync, } from "node:fs";
4
5
  import { agentMaxRounds, agentRunTimeoutMs } from "./agent/limits.js";
5
6
  import { ensurePrivateHaraState } from "./security/private-state.js";
6
7
  import { readVerifiedRegularFileSnapshotSync } from "./fs-read.js";
@@ -41,6 +42,7 @@ export const APPROVAL_MODES = ["suggest", "auto-edit", "full-auto"];
41
42
  export const SANDBOX_MODES = ["off", "workspace-write", "read-only"];
42
43
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
43
44
  const MAX_PROJECT_CONFIG_BYTES = 256 * 1024;
45
+ const MAX_GLOBAL_CONFIG_BYTES = 1024 * 1024;
44
46
  const KNOWN_CONFIG_KEYS = new Set([
45
47
  ...CONFIG_KEYS,
46
48
  "hooks", "mcpServers", "modelVision", "overlays", "profiles",
@@ -111,7 +113,12 @@ export function readRawConfig() {
111
113
  if (!existsSync(p))
112
114
  return {};
113
115
  try {
114
- return configRecord(JSON.parse(readFileSync(p, "utf8")));
116
+ const snapshot = readVerifiedRegularFileSnapshotSync(p, MAX_GLOBAL_CONFIG_BYTES, {
117
+ action: "read global config",
118
+ protectSensitive: false,
119
+ rejectHardLinks: true,
120
+ });
121
+ return configRecord(JSON.parse(snapshot.text));
115
122
  }
116
123
  catch {
117
124
  return {};
@@ -232,20 +239,72 @@ function readProjectConfig(cwd) {
232
239
  }
233
240
  return {};
234
241
  }
235
- /** Write the config 0600 (it can hold `apiKey`) + tighten an existing file. */
242
+ function existingConfigIdentity(path) {
243
+ try {
244
+ const info = lstatSync(path);
245
+ if (info.isSymbolicLink())
246
+ throw new Error(`refusing global config write: '${path}' is a symbolic link`);
247
+ if (!info.isFile())
248
+ throw new Error(`refusing global config write: '${path}' is not a regular file`);
249
+ if (info.nlink !== 1)
250
+ throw new Error(`refusing global config write: '${path}' is hard-linked`);
251
+ return { dev: info.dev, ino: info.ino };
252
+ }
253
+ catch (error) {
254
+ if (error?.code === "ENOENT")
255
+ return null;
256
+ throw error;
257
+ }
258
+ }
259
+ /** Atomically replace the global 0600 config without opening its current directory entry for write. */
236
260
  function persistConfig(p, cfg) {
237
261
  ensurePrivateHaraState();
238
- mkdirSync(dirname(p), { recursive: true, mode: 0o700 });
239
- try {
240
- chmodSync(dirname(p), 0o700);
262
+ const parent = dirname(p);
263
+ mkdirSync(parent, { recursive: true, mode: 0o700 });
264
+ const parentInfo = lstatSync(parent);
265
+ const canonicalParent = realpathSync.native(parent);
266
+ if (!parentInfo.isDirectory() || parentInfo.isSymbolicLink()) {
267
+ throw new Error(`refusing global config write: '${parent}' is not a canonical directory`);
241
268
  }
242
- catch { /* best effort */ }
243
- writeFileSync(p, JSON.stringify(cfg, null, 2) + "\n", { encoding: "utf8", mode: 0o600 });
269
+ const existing = existingConfigIdentity(p);
270
+ const temp = join(parent, `.config-${process.pid}-${randomUUID()}.tmp`);
271
+ const noFollow = typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
272
+ let fd;
244
273
  try {
245
- chmodSync(p, 0o600);
274
+ fd = openSync(temp, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollow, 0o600);
275
+ writeFileSync(fd, JSON.stringify(cfg, null, 2) + "\n", "utf8");
276
+ fsyncSync(fd);
277
+ const staged = fstatSync(fd);
278
+ if (!staged.isFile() || staged.nlink !== 1)
279
+ throw new Error("refusing global config write: unsafe staging inode");
280
+ closeSync(fd);
281
+ fd = undefined;
282
+ const parentAfter = lstatSync(parent);
283
+ if (!parentAfter.isDirectory() || parentAfter.isSymbolicLink() ||
284
+ parentAfter.dev !== parentInfo.dev || parentAfter.ino !== parentInfo.ino ||
285
+ realpathSync.native(parent) !== canonicalParent)
286
+ throw new Error(`refusing global config write: '${parent}' changed before commit`);
287
+ const current = existingConfigIdentity(p);
288
+ if ((existing === null) !== (current === null) || (existing && current && (existing.dev !== current.dev || existing.ino !== current.ino))) {
289
+ throw new Error(`refusing global config write: '${p}' changed before commit`);
290
+ }
291
+ // rename replaces the path entry; it never follows an alias planted after the last identity check.
292
+ renameSync(temp, p);
293
+ const committed = lstatSync(p);
294
+ if (!committed.isFile() || committed.isSymbolicLink() || committed.dev !== staged.dev || committed.ino !== staged.ino || committed.nlink !== 1) {
295
+ throw new Error(`global config changed during commit: '${p}'`);
296
+ }
246
297
  }
247
- catch {
248
- /* best-effort */
298
+ finally {
299
+ if (fd !== undefined)
300
+ try {
301
+ closeSync(fd);
302
+ }
303
+ catch { /* preserve original error */ }
304
+ try {
305
+ rmSync(temp, { force: true });
306
+ }
307
+ catch { /* preserve original error */ }
249
308
  }
250
309
  }
251
310
  export function writeConfigValue(key, value) {
@@ -28,17 +28,17 @@ export function recursiveRootContainsHome(root, home = homedir()) {
28
28
  }
29
29
  export function homeWorkspaceActionError(action) {
30
30
  return (`Refusing to ${action} from the home directory: it is not an implicit project workspace. ` +
31
- "Run `cd /path/to/project` first.");
31
+ "Run `cd /path/to/project` first, or launch with `hara --cwd /path/to/project`.");
32
32
  }
33
33
  export function recursiveHomeSearchError(tool) {
34
34
  return (`Error: ${tool} will not recursively scan the home directory. ` +
35
- "Run Hara from a project (`cd /path/to/project`). Explicit single-file reads remain available.");
35
+ "Run Hara from a project (`cd /path/to/project` or `hara --cwd /path/to/project`). Explicit single-file reads remain available.");
36
36
  }
37
37
  /** A model rooted at Home must not discover a child and silently promote it into the project scope. The
38
38
  * user establishes that scope by launching Hara from the concrete project directory. */
39
39
  export function homeWorkspaceDirectoryScanError(tool) {
40
40
  return (`Error: ${tool} will not enumerate or recursively scan directories while Hara is rooted at the home directory. ` +
41
- "Run `cd /path/to/project` and start Hara there. Explicit single-file reads remain available.");
41
+ "Run `cd /path/to/project` or relaunch with `hara --cwd /path/to/project`. Explicit single-file reads remain available.");
42
42
  }
43
43
  /** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
44
44
  * policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
@@ -49,5 +49,5 @@ export function homeWorkspaceGuidance(cwd) {
49
49
  "The working directory resolves to the user's home directory, which is not an implicit project. " +
50
50
  "Do not initialize a project, create or modify files, build a repository index, run shell/external " +
51
51
  "executable tools, enumerate directories, or grep/glob/search Home or one of its child directories. " +
52
- "Ask the user to run `cd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
52
+ "Ask the user to run `cd /path/to/project` or `hara --cwd /path/to/project` for project work. Only explicitly named single-file reads remain available.");
53
53
  }