@nanhara/hara 0.122.5 → 0.122.6

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,28 @@ 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.122.6 — 2026-07-15 — resumed continuity and workspace/input hardening
9
+
10
+ - **Resume continues the persisted task instead of rediscovering the workspace.** CLI `--resume`/`-c`,
11
+ headless gateway/cron continuations, Desktop `session.resume`, and session forks tell the model that the
12
+ existing history is already authoritative context. A process restart is no longer a reason to restart the
13
+ task, inventory files, or answer with an unrelated workspace summary.
14
+ - **Home cannot be promoted into a project one child directory at a time.** When Hara is rooted at the user's
15
+ Home, runtime guards now reject `ls`, directory `grep`, `glob`, `codebase_search`, `@directory` expansion,
16
+ fuzzy `@` inventory, and coding mutations even when the model supplies a named child path. Explicit
17
+ single-file reads remain available; launching Hara from that concrete child project restores normal project
18
+ search, completion, and coding behavior.
19
+ - **The regression exercises a disobedient model, not only the prompt.** An end-to-end resumed session uses a
20
+ temporary Home containing a sentinel project, forces the provider to call `ls`, and verifies the tool result
21
+ is a bounded refusal while the persisted history and latest request reach the model without any Home entry
22
+ or file name.
23
+ - **Composer tabs no longer move the cursor away from the text.** Literal tabs render and wrap as one-column
24
+ spaces while the editable and submitted prompt preserves the original tab characters.
25
+ - **Source builds diagnose stale pre-bootstrap npm links.** If an old `npm link` still points `hara` at the
26
+ now-internal non-executable `dist/index.js`, builds and `npm run doctor:local-link` print the owning Node bin
27
+ directory and the exact relink/`rehash` repair instead of suggesting an unsafe `chmod` workaround. Clean npm
28
+ installs and standalone binaries are unaffected.
29
+
8
30
  ## 0.122.5 — 2026-07-15 — standalone ambient-config security boundary
9
31
 
10
32
  - **Standalone binaries no longer trust the directory they are launched from.** Bun's runtime `.env` and
package/README.md CHANGED
@@ -59,6 +59,12 @@ npm install # builds via the prepare script
59
59
  npm install -g . # or: npm link
60
60
  ```
61
61
 
62
+ If a source checkout linked before 0.122.1 later reports `zsh: permission denied: hara`, the old npm
63
+ link is still targeting the now-internal `dist/index.js`. Activate the same Node installation that owns
64
+ the link, run `npm link` again from this directory, then run `rehash`. `npm run build` and
65
+ `npm run doctor:local-link` detect this stale link and print its exact owning bin directory. Do not repair
66
+ it with `chmod`; the guarded executable entry is `runtime-bootstrap.cjs`.
67
+
62
68
  ## Quickstart
63
69
 
64
70
  ```bash
@@ -177,8 +183,9 @@ hara -m glm-5 # pick a model
177
183
 
178
184
  Run Hara from a project directory. When the current directory resolves to your Home root, Hara does not treat
179
185
  the whole Home tree as a repository: project init/index and default recursive grep/glob/codebase inventory are
180
- disabled with a `cd /path/to/project` hint. Non-recursive `ls`, explicitly named files, and explicitly selected
181
- child directories still work.
186
+ disabled with a `cd /path/to/project` hint. Directory listing and child-directory recursion are also refused so
187
+ the model cannot promote a discovered Home folder into an implicit project; explicitly named single files still
188
+ work. Resuming a session reuses its persisted conversation and must not trigger workspace rediscovery.
182
189
 
183
190
  For automation, `--schema` accepts inline JSON Schema or a schema file. The model must return through the
184
191
  validated `structured_output` tool; on success stdout contains only the JSON value, while diagnostics go to
@@ -314,6 +321,7 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
314
321
  - **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.
315
322
  - **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
316
323
  - **`@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.
317
325
  - **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
318
326
  - **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)**.
319
327
 
@@ -127,11 +127,17 @@ function gatewayNote() {
127
127
  `PLAIN TEXT ONLY: chat bubbles render markdown literally — never use **bold**, # headers, backticks, tables, ` +
128
128
  `or [text](url); write list items as "- " lines and links as bare URLs.`);
129
129
  }
130
- function composeSystem(cwd, projectContext, override, memory) {
130
+ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
131
+ "This turn continues a persisted conversation. Its history is already the authoritative context: do not restart the task, " +
132
+ "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
133
+ "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
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) {
131
136
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
132
137
  const skills = skillsDigest(cwd);
133
138
  return (head +
134
139
  gatewayNote() +
140
+ (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
135
141
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
136
142
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
137
143
  (skills ? `\n\n# Skills (capabilities you can load — call the \`skill\` tool with the id for full instructions before using one)\n${skills}` : ""));
@@ -421,7 +427,7 @@ async function runAgentInner(history, opts, life) {
421
427
  return { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
422
428
  }
423
429
  return activeProvider.turn({
424
- system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
430
+ system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession),
425
431
  history,
426
432
  tools: specs,
427
433
  // Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
@@ -7,7 +7,7 @@ import { fuzzyRank } from "../fuzzy.js";
7
7
  import { mediaTypeFor } from "../images.js";
8
8
  import { readModelContextPrefixSync } from "../fs-read.js";
9
9
  import { sensitiveFileError } from "../security/sensitive-files.js";
10
- import { recursiveRootContainsHome, recursiveHomeSearchError } from "./workspace-scope.js";
10
+ import { homeWorkspaceDirectoryScanError, isHomeWorkspace, recursiveRootContainsHome, recursiveHomeSearchError, } from "./workspace-scope.js";
11
11
  const MAX_FILE = 50_000;
12
12
  // @ at start-of-string or after whitespace; capture a path with no spaces/@ (avoids emails like a@b.com)
13
13
  const MENTION_RE = /(?:^|\s)@([^\s@]+)/g;
@@ -108,6 +108,8 @@ async function expandRefAsync(ref, cwd, options) {
108
108
  return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
109
109
  }
110
110
  if (st.isDirectory()) {
111
+ if (isHomeWorkspace(cwd))
112
+ return homeWorkspaceDirectoryScanError("directory attachment");
111
113
  if (recursiveRootContainsHome(abs))
112
114
  return recursiveHomeSearchError("directory attachment");
113
115
  const inventory = await walkFilesAsync(abs, options);
@@ -148,6 +150,8 @@ function expandRef(ref, cwd) {
148
150
  return `\nReferenced file \`${ref}\`:\n\`\`\`\n${txt}\n\`\`\`\n`;
149
151
  }
150
152
  if (st.isDirectory()) {
153
+ if (isHomeWorkspace(cwd))
154
+ return homeWorkspaceDirectoryScanError("directory attachment");
151
155
  if (recursiveRootContainsHome(abs))
152
156
  return recursiveHomeSearchError("directory attachment");
153
157
  // `@dir` loads a listing of the directory's files (the agent can then read specific ones)
@@ -188,6 +192,11 @@ export function invalidateFileCandidates(cwd) {
188
192
  * ranks path-prefix and basename matches first. Directories carry a trailing `/`.
189
193
  */
190
194
  export function fileCandidates(cwd, query, limit = 25) {
195
+ // Autocomplete is also directory discovery. Do not populate the cache or expose even top-level
196
+ // names when the user launched Hara at Home; the user establishes project scope by starting in a
197
+ // concrete child directory. Canonical comparison closes symlink aliases of Home.
198
+ if (isHomeWorkspace(cwd))
199
+ return [];
191
200
  const entries = projectEntries(cwd);
192
201
  if (!query) {
193
202
  // bare `@`: top-level entries, directories first
@@ -32,8 +32,13 @@ export function homeWorkspaceActionError(action) {
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`) or set `path` to a specific file/subdirectory. " +
36
- "Non-recursive `ls` and explicit file reads remain available.");
35
+ "Run Hara from a project (`cd /path/to/project`). Explicit single-file reads remain available.");
36
+ }
37
+ /** A model rooted at Home must not discover a child and silently promote it into the project scope. The
38
+ * user establishes that scope by launching Hara from the concrete project directory. */
39
+ export function homeWorkspaceDirectoryScanError(tool) {
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.");
37
42
  }
38
43
  /** Injected into model context when Hara was intentionally launched at ~/. Runtime checks enforce the same
39
44
  * policy, but guidance avoids wasting turns on calls that are guaranteed to be rejected. */
@@ -42,8 +47,7 @@ export function homeWorkspaceGuidance(cwd) {
42
47
  return "";
43
48
  return ("# Home-directory workspace boundary\n" +
44
49
  "The working directory resolves to the user's home directory, which is not an implicit project. " +
45
- "Do not initialize a project, build a repository index, run shell/external executable tools, or recursively " +
46
- "grep/glob/search the home root. " +
47
- "Ask the user to run `cd /path/to/project` for project work. Non-recursive `ls`, explicitly named files, " +
48
- "and explicitly named child directories remain available.");
50
+ "Do not initialize a project, create or modify files, build a repository index, run shell/external " +
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.");
49
53
  }
package/dist/index.js CHANGED
@@ -2916,6 +2916,7 @@ program.action(async (opts) => {
2916
2916
  // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
2917
2917
  // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
2918
2918
  let meta = null;
2919
+ let continuationSession = false;
2919
2920
  const history = [];
2920
2921
  if (opts.resume || opts.continue) {
2921
2922
  const resumeArg = opts.resume ? String(opts.resume) : undefined;
@@ -2945,6 +2946,7 @@ program.action(async (opts) => {
2945
2946
  // Re-read only after acquiring the single-writer lock. Loading first would leave a race window where
2946
2947
  // another gateway/cron process appends history that this stale snapshot later overwrites.
2947
2948
  const prior = loadSession(rid);
2949
+ continuationSession = Boolean(prior?.history.length);
2948
2950
  if (sessionFileExists(rid) && !prior) {
2949
2951
  process.stderr.write(`hara: session ${shortId(rid)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${rid}.json.\n`);
2950
2952
  process.exitCode = 2;
@@ -3106,6 +3108,7 @@ program.action(async (opts) => {
3106
3108
  confirm: async () => true,
3107
3109
  projectContext,
3108
3110
  memory: memoryDigest(cwd),
3111
+ continuationSession,
3109
3112
  ...(roleOverride ? { systemOverride: roleOverride } : {}),
3110
3113
  ...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
3111
3114
  hooks: headlessHooks,
@@ -3347,6 +3350,7 @@ program.action(async (opts) => {
3347
3350
  }
3348
3351
  }
3349
3352
  const history = resumed?.history ? [...resumed.history] : [];
3353
+ let continuationSession = Boolean(resumed?.history.length);
3350
3354
  const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
3351
3355
  const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
3352
3356
  if (resumed)
@@ -3868,6 +3872,7 @@ program.action(async (opts) => {
3868
3872
  return void h.sink.notice(getTools().map((t) => `${t.name}${t.kind !== "read" ? " *" : ""} — ${t.description}`).join("\n"));
3869
3873
  if (nm === "reset" || nm === "clear") {
3870
3874
  history.length = 0;
3875
+ continuationSession = false;
3871
3876
  recalledContext = "";
3872
3877
  resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
3873
3878
  resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
@@ -4105,7 +4110,7 @@ program.action(async (opts) => {
4105
4110
  const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
4106
4111
  let skillOutcome;
4107
4112
  try {
4108
- skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4113
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4109
4114
  }
4110
4115
  catch (e) {
4111
4116
  h.sink.notice(`[error] ${e?.message ?? e}`);
@@ -4181,6 +4186,7 @@ program.action(async (opts) => {
4181
4186
  systemOverride: PLAN_SYSTEM,
4182
4187
  memory: buildMemory(),
4183
4188
  projectContext,
4189
+ continuationSession,
4184
4190
  stats,
4185
4191
  signal: h.signal,
4186
4192
  pendingInput,
@@ -4226,6 +4232,7 @@ program.action(async (opts) => {
4226
4232
  confirm: h.confirm,
4227
4233
  autoApprove,
4228
4234
  projectContext,
4235
+ continuationSession,
4229
4236
  stats,
4230
4237
  signal: h.signal,
4231
4238
  pendingInput,
@@ -4260,6 +4267,7 @@ program.action(async (opts) => {
4260
4267
  confirm: h.confirm,
4261
4268
  autoApprove,
4262
4269
  projectContext,
4270
+ continuationSession,
4263
4271
  stats,
4264
4272
  signal: h.signal,
4265
4273
  pendingInput,
@@ -4331,7 +4339,7 @@ program.action(async (opts) => {
4331
4339
  currentTurn = skillTurn;
4332
4340
  let skillOutcome;
4333
4341
  try {
4334
- skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4342
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4335
4343
  }
4336
4344
  catch (e) {
4337
4345
  out(c.red(`\n[error] ${e.message}\n`));
@@ -4369,7 +4377,7 @@ program.action(async (opts) => {
4369
4377
  const t0 = Date.now();
4370
4378
  let turnOutcome;
4371
4379
  try {
4372
- turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4380
+ turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4373
4381
  }
4374
4382
  catch (e) {
4375
4383
  out(c.red(`\n[error] ${e.message}\n`));
@@ -375,6 +375,7 @@ export async function startServe(opts, deps) {
375
375
  autoApprove: s.autoApprove,
376
376
  projectContext: s.projectContext,
377
377
  memory: memoryDigest(s.meta.cwd),
378
+ continuationSession: s.continuationSession,
378
379
  stats: s.stats,
379
380
  signal: turnAbort.signal,
380
381
  onProviderTurn: (turn) => {
@@ -47,7 +47,7 @@ export class SessionHub {
47
47
  const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
48
48
  if (!lock.ok)
49
49
  throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
50
- const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
50
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: false, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
51
51
  try {
52
52
  this.sessions.set(meta.id, s);
53
53
  this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
@@ -76,7 +76,7 @@ export class SessionHub {
76
76
  return { missing: true };
77
77
  // Credential/provider routing is live, while the model remains the session's explicit pin.
78
78
  prior.meta.provider = o.provider.id;
79
- const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
79
+ const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, continuationSession: prior.history.length > 0, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
80
80
  this.sessions.set(id, s);
81
81
  keepLock = true; // live session owns it until delete/releaseAll
82
82
  return { session: s };
@@ -177,6 +177,7 @@ export class SessionHub {
177
177
  autoApprove: new Set(),
178
178
  stats: { input: 0, output: 0 },
179
179
  projectContext: o.projectContext,
180
+ continuationSession: src.history.length > 0,
180
181
  busy: false,
181
182
  configuring: false,
182
183
  pendingProviderTurns: 0,
@@ -157,6 +157,7 @@ registerTool({
157
157
  required: ["path", "content"],
158
158
  },
159
159
  kind: "edit",
160
+ requiresProjectWorkspace: true,
160
161
  async run(input, ctx) {
161
162
  const p = abs(input.path, ctx.cwd);
162
163
  const denied = sensitiveFileError(p, "write");
@@ -10,7 +10,7 @@ import { findProjectRoot } from "../context/agents-md.js";
10
10
  import { loadConfig } from "../config.js";
11
11
  import { getEmbedder } from "../search/embed.js";
12
12
  import { queryIndex, indexExists } from "../search/semindex.js";
13
- import { recursiveRootContainsHome, recursiveHomeSearchError } from "../context/workspace-scope.js";
13
+ import { homeWorkspaceDirectoryScanError, isHomeWorkspace, recursiveRootContainsHome, recursiveHomeSearchError, } from "../context/workspace-scope.js";
14
14
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
15
15
  const MAX_FILE = 200_000; // skip very large files
16
16
  const INVENTORY_TIMEOUT_MS = 2_000;
@@ -21,7 +21,7 @@ registerTool({
21
21
  description: "Find code in THIS project relevant to a natural-language query — ranked by relevance (not exact match). " +
22
22
  "Use it to locate similar/related code while working ('where is auth handled?', 'retry logic'); use grep " +
23
23
  "for exact strings/regex. Returns the top files with their most relevant snippet (file:line). When Hara " +
24
- "was started in the home directory, set path to a specific project subdirectory.",
24
+ "was started in the home directory, cd to a specific project before searching.",
25
25
  input_schema: {
26
26
  type: "object",
27
27
  properties: {
@@ -43,6 +43,8 @@ registerTool({
43
43
  const requestedRoot = typeof input.path === "string" && input.path.trim()
44
44
  ? (isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path))
45
45
  : ctx.cwd;
46
+ if (isHomeWorkspace(ctx.cwd))
47
+ return homeWorkspaceDirectoryScanError("codebase_search");
46
48
  try {
47
49
  if (!statSync(requestedRoot).isDirectory())
48
50
  return `Error: codebase_search path is not a directory: ${input.path ?? "."}`;
@@ -40,6 +40,7 @@ registerTool({
40
40
  required: ["path"],
41
41
  },
42
42
  kind: "edit",
43
+ requiresProjectWorkspace: true,
43
44
  async run(input, ctx) {
44
45
  const p = isAbsolute(input.path) ? input.path : resolve(ctx.cwd, input.path);
45
46
  const denied = sensitiveFileError(p, "edit");
@@ -201,6 +201,7 @@ registerTool({
201
201
  required: ["changes"],
202
202
  },
203
203
  kind: "edit",
204
+ requiresProjectWorkspace: true,
204
205
  async run(input, ctx) {
205
206
  const changes = Array.isArray(input.changes) ? input.changes : [];
206
207
  if (!changes.length)
@@ -25,8 +25,8 @@ export function registerTool(t) {
25
25
  if (ctx.signal?.aborted) {
26
26
  return `Error: ${t.name} cancelled before execution because the agent run has ended.`;
27
27
  }
28
- // Only explicitly project-scoped executable tools are blocked. `kind:"exec"` is also used for safe
29
- // management/delivery side effects, so it must not itself imply a project workspace requirement.
28
+ // Only explicitly project-scoped operations are blocked. Tool kinds are also used for safe
29
+ // management/delivery side effects, so a kind must not itself imply a project workspace requirement.
30
30
  // Canonical comparison closes Home symlink aliases.
31
31
  if (t.requiresProjectWorkspace && isHomeWorkspace(ctx.cwd)) {
32
32
  return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
@@ -7,7 +7,7 @@ import { registerTool } from "./registry.js";
7
7
  import { IGNORE_DIRS, walkFilesAsync } from "../fs-walk.js";
8
8
  import { isSensitiveFilePath, sensitiveFileError, sensitiveFilesAllowed, SENSITIVE_SEARCH_GLOBS, } from "../security/sensitive-files.js";
9
9
  import { toolSubprocessEnv } from "../security/subprocess-env.js";
10
- import { recursiveRootContainsHome, recursiveHomeSearchError } from "../context/workspace-scope.js";
10
+ import { homeWorkspaceDirectoryScanError, isHomeWorkspace, recursiveRootContainsHome, recursiveHomeSearchError, } from "../context/workspace-scope.js";
11
11
  const MAX_OUT = 60_000;
12
12
  const MAX_MATCHES = 300;
13
13
  const MAX_FILE_BYTES = 2 * 1024 * 1024;
@@ -538,6 +538,8 @@ registerTool({
538
538
  catch {
539
539
  return `Error: no such path: ${input.path ?? "."}`;
540
540
  }
541
+ if (!isFile && isHomeWorkspace(ctx.cwd))
542
+ return homeWorkspaceDirectoryScanError("grep");
541
543
  if (!isFile && recursiveRootContainsHome(root))
542
544
  return recursiveHomeSearchError("grep");
543
545
  const searchArgs = [
@@ -605,6 +607,8 @@ registerTool({
605
607
  const denied = sensitiveFileError(root, "search");
606
608
  if (denied)
607
609
  return denied;
610
+ if (isHomeWorkspace(ctx.cwd))
611
+ return homeWorkspaceDirectoryScanError("glob");
608
612
  if (recursiveRootContainsHome(root))
609
613
  return recursiveHomeSearchError("glob");
610
614
  const pattern = typeof input.pattern === "string" ? input.pattern : "";
@@ -665,6 +669,8 @@ registerTool({
665
669
  },
666
670
  kind: "read",
667
671
  async run(input, ctx) {
672
+ if (isHomeWorkspace(ctx.cwd))
673
+ return homeWorkspaceDirectoryScanError("ls");
668
674
  const dir = absOf(input.path, ctx.cwd);
669
675
  const denied = sensitiveFileError(dir, "list");
670
676
  if (denied)
@@ -140,6 +140,12 @@ export function cells(s) {
140
140
  n += charCells(ch);
141
141
  return n;
142
142
  }
143
+ /** One-cell display representation for composer text. The editable/submitted value keeps literal tabs,
144
+ * while wrapping, cursor math, and Ink all see the same one-cell glyph. Removing newlines here preserves
145
+ * renderRow's invariant that hard line breaks are represented by rows rather than nested Text output. */
146
+ export function composerTextForDisplay(text) {
147
+ return text.replace(/\n/g, "").replace(/\t/g, " ");
148
+ }
143
149
  /** Wrap `value` into rows that each fit within `cols` cells, breaking on spaces where possible but
144
150
  * never inside an `[Image #N]` token. Deterministic (no reliance on ink's soft-wrap) so wrapped rows
145
151
  * align under a stable gutter and the cursor position is exact. Always returns at least one row. */
@@ -230,7 +236,7 @@ export function wrapRows(value, cols) {
230
236
  function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
231
237
  // `\n` chars are row SEPARATORS (wrapRows already broke the row on them) — never render them, or ink
232
238
  // would insert a second line break and desync the deterministic layout.
233
- const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: text.replace(/\n/g, "") }, k)) : (_jsx(Text, { children: text.replace(/\n/g, "") }, k));
239
+ const seg = (token, text, k) => token ? (_jsx(Text, { backgroundColor: "magenta", color: "white", children: composerTextForDisplay(text) }, k)) : (_jsx(Text, { children: composerTextForDisplay(text) }, k));
234
240
  // Segments intersected with this row's [start,end) range.
235
241
  const parts = segmentize(value);
236
242
  const nodes = [];
@@ -250,7 +256,7 @@ function renderRow(value, row, cursor, showCursor, isLastRow, keyPrefix) {
250
256
  const rel = cursor - from;
251
257
  if (rel > 0)
252
258
  nodes.push(seg(p.token, slice.slice(0, rel), `${keyPrefix}s${ki++}`));
253
- nodes.push(_jsx(Text, { inverse: true, children: slice[rel] === "\n" ? " " : slice[rel] }, `${keyPrefix}c${ki++}`));
259
+ nodes.push(_jsx(Text, { inverse: true, children: slice[rel] === "\n" || slice[rel] === "\t" ? " " : slice[rel] }, `${keyPrefix}c${ki++}`));
254
260
  if (rel + 1 < slice.length)
255
261
  nodes.push(seg(p.token, slice.slice(rel + 1), `${keyPrefix}e${ki++}`));
256
262
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.122.5",
3
+ "version": "0.122.6",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"
@@ -39,8 +39,9 @@
39
39
  "node": ">=22.12.0"
40
40
  },
41
41
  "scripts": {
42
- "build": "tsc && node scripts/normalize-dist-modes.mjs",
42
+ "build": "tsc && node scripts/normalize-dist-modes.mjs && node scripts/check-local-bin-link.mjs",
43
43
  "prepare": "npm run build",
44
+ "doctor:local-link": "node scripts/check-local-bin-link.mjs",
44
45
  "dev": "tsx src/cli.ts",
45
46
  "start": "node runtime-bootstrap.cjs",
46
47
  "test": "npm run build && node --test --test-timeout=120000 test/*.test.mjs",