@nanhara/hara 0.125.3 → 0.126.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/dist/tools/all.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
4
4
  // as "the model called write_file and nothing happened".
5
5
  import "./builtin.js"; // read_file / write_file / bash / job
6
+ import "./runtime.js"; // tool_search / tool_result_read
6
7
  import "./edit.js"; // edit_file
7
8
  import "./search.js"; // grep / glob / ls
8
9
  import "./patch.js"; // apply_patch
@@ -6,11 +6,11 @@
6
6
  // the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
7
7
  // tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
8
8
  // itself triggers the approval gate (the interaction IS the prompt).
9
- import { registerTool } from "./registry.js";
9
+ import { getTool, registerTool } from "./registry.js";
10
10
  /** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
11
11
  * keeps going on its own judgment rather than re-asking or stalling. */
12
12
  export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
13
- registerTool({
13
+ const definition = {
14
14
  name: "ask_user",
15
15
  description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
16
16
  "Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
@@ -23,6 +23,7 @@ registerTool({
23
23
  "In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
24
24
  "blocking, so prefer making a reasonable call over asking when context already answers the question.",
25
25
  kind: "read", // the prompt itself is the interaction; never route it through the approval gate
26
+ classify: () => ({ effect: "interactive", concurrencySafe: false }),
26
27
  input_schema: {
27
28
  type: "object",
28
29
  properties: {
@@ -65,4 +66,8 @@ registerTool({
65
66
  return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
66
67
  }
67
68
  },
68
- });
69
+ };
70
+ registerTool(definition);
71
+ /** Exact registered identity used by the agent loop to grant the engine-owned prompt its pausable
72
+ * active-budget semantics. A plugin that later shadows the public name does not inherit that authority. */
73
+ export const askUserTool = getTool(definition.name);
@@ -13,6 +13,7 @@ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelP
13
13
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
14
14
  import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
15
15
  import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
16
+ import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
16
17
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
17
18
  const MAX = 100_000;
18
19
  /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
@@ -123,6 +124,7 @@ registerTool({
123
124
  required: ["path"],
124
125
  },
125
126
  kind: "read",
127
+ concurrencySafe: true,
126
128
  async run(input, ctx) {
127
129
  const p = abs(input.path, ctx.cwd);
128
130
  const denied = sensitiveFileError(p, "read");
@@ -211,6 +213,16 @@ registerTool({
211
213
  required: ["command"],
212
214
  },
213
215
  kind: "exec",
216
+ classify(input) {
217
+ const command = typeof input?.command === "string" ? input.command : "";
218
+ const parts = command ? splitCompound(command) : null;
219
+ const readOnly = !Boolean(input?.background)
220
+ && !!parts?.length
221
+ && parts.every(isReadOnlyCommand);
222
+ return readOnly
223
+ ? { effect: "read", concurrencySafe: true }
224
+ : { effect: "exec", concurrencySafe: false };
225
+ },
214
226
  requiresProjectWorkspace: true,
215
227
  async run(input, ctx) {
216
228
  if (input.background !== undefined && typeof input.background !== "boolean") {
@@ -310,7 +322,13 @@ registerTool({
310
322
  },
311
323
  required: ["action"],
312
324
  },
313
- kind: "read", // manages only the agent's own background jobs; safe to run unconfirmed
325
+ kind: "exec", // conservative default; list/tail are downgraded per input, kill remains approval-gated
326
+ classify(input) {
327
+ const action = String(input?.action ?? "");
328
+ return action === "list" || action === "tail"
329
+ ? { effect: "read", concurrencySafe: true }
330
+ : { effect: "exec", concurrencySafe: false, destructive: action === "kill" };
331
+ },
314
332
  async run(input) {
315
333
  const action = String(input.action);
316
334
  if (action === "list") {
@@ -32,6 +32,7 @@ registerTool({
32
32
  required: ["query"],
33
33
  },
34
34
  kind: "read",
35
+ concurrencySafe: true,
35
36
  async run(input, ctx) {
36
37
  if (ctx.signal?.aborted)
37
38
  throw new Error("codebase_search interrupted by agent run deadline or cancellation");
@@ -391,6 +391,7 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
391
391
  required: ["action"],
392
392
  },
393
393
  kind: "computer",
394
+ visibility: "deferred",
394
395
  async run(input, ctx) {
395
396
  if (ctx.signal?.aborted)
396
397
  throw new ComputerInterruptedError();
@@ -62,6 +62,16 @@ registerTool({
62
62
  required: ["action"],
63
63
  },
64
64
  kind: "exec", // scheduling machinery on the user's machine — approval-gated like any exec
65
+ visibility: "deferred",
66
+ classify(input) {
67
+ return input?.action === "list"
68
+ ? { effect: "read", concurrencySafe: true }
69
+ : {
70
+ effect: "exec",
71
+ concurrencySafe: false,
72
+ destructive: input?.action === "remove" || input?.action === "disable",
73
+ };
74
+ },
65
75
  async run(input, ctx) {
66
76
  if (process.env.HARA_CRON === "1")
67
77
  return "Error: cron-run sessions cannot manage cron jobs (recursion guard — a scheduled task must not schedule more tasks).";
@@ -109,6 +109,7 @@ registerTool({
109
109
  "another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
110
110
  "Args: task (required), backend (claude|codex; default = first installed), model (optional).",
111
111
  kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
112
+ visibility: "deferred",
112
113
  requiresProjectWorkspace: true,
113
114
  trustBoundary: "external",
114
115
  input_schema: {
@@ -26,6 +26,7 @@ registerTool({
26
26
  required: ["query"],
27
27
  },
28
28
  kind: "read",
29
+ concurrencySafe: true,
29
30
  async run(input, ctx) {
30
31
  const hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10), signal: ctx.signal });
31
32
  if (!hits.length)
@@ -38,6 +39,7 @@ registerTool({
38
39
  description: "Read a memory file in full (use after memory_search to pull the exact entry).",
39
40
  input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
40
41
  kind: "read",
42
+ concurrencySafe: true,
41
43
  async run(input, ctx) {
42
44
  const p = isAbsolute(String(input.path)) ? String(input.path) : resolve(ctx.cwd, String(input.path));
43
45
  if (!memoryRoots(ctx.cwd).some((root) => {
@@ -1,4 +1,4 @@
1
- import { limitToolResult } from "./result-limit.js";
1
+ import { prepareToolResult } from "./result-limit.js";
2
2
  import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
3
3
  /** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
4
4
  * against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
@@ -31,7 +31,10 @@ export function registerTool(t) {
31
31
  if (t.requiresProjectWorkspace && isUnsafeProjectWorkspace(ctx.cwd)) {
32
32
  return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
33
33
  }
34
- return limitToolResult(await run(input, ctx));
34
+ // Verified read_file content already passed the protected-file policy. Preserve its historical
35
+ // explicit opt-in semantics and harmless template placeholders in the immediate preview; oversized
36
+ // continuation storage is still independently redacted by storeToolResult().
37
+ return prepareToolResult(await run(input, ctx), undefined, { redactPreview: t.name !== "read_file" });
35
38
  },
36
39
  });
37
40
  specsCache = null;
@@ -42,8 +45,53 @@ export function getTool(name) {
42
45
  export function getTools() {
43
46
  return [...registry.values()];
44
47
  }
48
+ function defaultEffect(tool) {
49
+ if (tool.kind === "edit")
50
+ return "edit";
51
+ if (tool.kind === "exec")
52
+ return "exec";
53
+ if (tool.kind === "computer")
54
+ return "computer";
55
+ if (tool.kind === "read")
56
+ return "read";
57
+ // Legacy/plugin tools that omitted their safety declaration used to remain approval-gated. Preserve that
58
+ // conservative boundary: missing metadata must never silently mean read-only.
59
+ return "exec";
60
+ }
61
+ const TOOL_EFFECTS = new Set(["read", "state", "edit", "exec", "computer", "interactive"]);
62
+ /** Conservative, non-throwing classifier shared by approval, understanding, guardian, and scheduling. */
63
+ export function toolOperationTraits(tool, input, ctx) {
64
+ const effect = defaultEffect(tool);
65
+ const fallback = { effect, concurrencySafe: tool.concurrencySafe === true };
66
+ if (!tool.classify)
67
+ return fallback;
68
+ try {
69
+ const classified = tool.classify(input, ctx);
70
+ if (!classified || typeof classified !== "object" || !TOOL_EFFECTS.has(classified.effect)) {
71
+ return { ...fallback, concurrencySafe: false };
72
+ }
73
+ return {
74
+ effect: classified.effect,
75
+ concurrencySafe: classified.concurrencySafe === true,
76
+ ...(classified.destructive === true ? { destructive: true } : {}),
77
+ };
78
+ }
79
+ catch {
80
+ // A failed classifier may never downgrade into parallel execution.
81
+ return { ...fallback, concurrencySafe: false };
82
+ }
83
+ }
84
+ export function approvalKindForOperation(traits) {
85
+ if (traits.effect === "edit")
86
+ return "edit";
87
+ if (traits.effect === "exec")
88
+ return "exec";
89
+ if (traits.effect === "computer")
90
+ return "computer";
91
+ return "read";
92
+ }
45
93
  /** Provider-neutral tool specs derived from the registry. */
46
- export function toolSpecs() {
94
+ export function toolSpecs(options) {
47
95
  if (!specsCache) {
48
96
  specsCache = getTools().map((t) => ({
49
97
  name: t.name,
@@ -51,7 +99,50 @@ export function toolSpecs() {
51
99
  input_schema: t.input_schema,
52
100
  }));
53
101
  }
102
+ const visible = options === undefined || options.includeDeferred
103
+ ? specsCache
104
+ : specsCache.filter((spec) => {
105
+ const tool = registry.get(spec.name);
106
+ return tool?.visibility !== "deferred" || options.activatedDeferred?.has(spec.name);
107
+ });
54
108
  // Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
55
109
  // stable cached snapshot shared by subsequent agent rounds.
56
- return specsCache.slice();
110
+ return visible.slice();
111
+ }
112
+ function catalogTerms(value) {
113
+ return value.toLowerCase().split(/[^a-z0-9_\-\u3400-\u9fff]+/u).filter(Boolean);
114
+ }
115
+ /** Search deferred tools without exposing every JSON schema. Names rank above descriptions. */
116
+ export function searchDeferredToolCatalog(query, limit = 8) {
117
+ const normalized = query.trim().toLowerCase();
118
+ if (!normalized)
119
+ return [];
120
+ const terms = catalogTerms(normalized);
121
+ const matches = [];
122
+ for (const tool of getTools()) {
123
+ if (tool.visibility !== "deferred")
124
+ continue;
125
+ const name = tool.name.toLowerCase();
126
+ const description = tool.description.toLowerCase();
127
+ let score = 0;
128
+ if (name === normalized)
129
+ score += 100;
130
+ if (name.includes(normalized))
131
+ score += 30;
132
+ if (description.includes(normalized))
133
+ score += 15;
134
+ for (const term of terms) {
135
+ if (name === term)
136
+ score += 20;
137
+ else if (name.includes(term))
138
+ score += 8;
139
+ if (description.includes(term))
140
+ score += 3;
141
+ }
142
+ if (score > 0)
143
+ matches.push({ name: tool.name, description: tool.description, score });
144
+ }
145
+ return matches
146
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
147
+ .slice(0, Math.max(1, Math.min(32, Math.floor(limit))));
57
148
  }
@@ -1,6 +1,21 @@
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.
1
+ // Tool results cross two boundaries: the model needs a bounded preview, while useful command/MCP output
2
+ // must remain recoverable. Oversized redacted values are therefore written to Hara's private state and
3
+ // represented by an opaque id that the model can page with tool_result_read.
4
+ import { randomBytes } from "node:crypto";
5
+ import { lstatSync, readdirSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
9
+ import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
3
10
  export const MAX_TOOL_RESULT_CHARS = 24_000;
11
+ export const MAX_TOOL_RESULT_BATCH_CHARS = 64_000;
12
+ export const MAX_TOOL_RESULT_READ_CHARS = 18_000;
13
+ export const MAX_STORED_TOOL_RESULT_BYTES = 4 * 1024 * 1024;
14
+ const MAX_STORED_TOOL_RESULT_TOTAL_BYTES = 64 * 1024 * 1024;
15
+ const MAX_STORED_TOOL_RESULT_FILES = 128;
16
+ const STORED_TOOL_RESULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
17
+ const RESULT_ID = /^tr_[a-f0-9]{32}$/;
18
+ const RESULT_FILE = /^(tr_[a-f0-9]{32})\.txt$/;
4
19
  function safeHead(value, end) {
5
20
  let at = Math.max(0, Math.min(value.length, end));
6
21
  if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
@@ -35,3 +50,158 @@ export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
35
50
  const tailChars = room - headChars;
36
51
  return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
37
52
  }
53
+ function resultBinding(id) {
54
+ if (!RESULT_ID.test(id))
55
+ throw new Error("invalid tool result id");
56
+ return bindPrivateHaraStateFile(homedir(), ["tool-results"], `${id}.txt`);
57
+ }
58
+ /** Remove only verified single-link private-state files. Changed, linked, or aliased entries are retained
59
+ * and count against the quota, making a hostile local preseed fail closed. */
60
+ function pruneStore(directory, incomingBytes, now = Date.now()) {
61
+ const entries = [];
62
+ let totalBytes = 0;
63
+ let totalFiles = 0;
64
+ for (const name of readdirSync(directory.path)) {
65
+ if (!RESULT_FILE.test(name))
66
+ continue;
67
+ const path = join(directory.path, name);
68
+ try {
69
+ const info = lstatSync(path);
70
+ totalFiles++;
71
+ totalBytes += info.size;
72
+ if (info.isFile() && !info.isSymbolicLink() && info.nlink === 1) {
73
+ entries.push({ path, size: info.size, mtimeMs: info.mtimeMs });
74
+ }
75
+ }
76
+ catch {
77
+ // A disappearing or changed entry is not safe to remove.
78
+ }
79
+ }
80
+ entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
81
+ for (const entry of entries) {
82
+ const expired = now - entry.mtimeMs > STORED_TOOL_RESULT_TTL_MS;
83
+ const overQuota = totalFiles >= MAX_STORED_TOOL_RESULT_FILES
84
+ || totalBytes + incomingBytes > MAX_STORED_TOOL_RESULT_TOTAL_BYTES;
85
+ if (!expired && !overQuota)
86
+ continue;
87
+ try {
88
+ const snapshot = readPrivateStateFileSnapshotSync(entry.path, MAX_STORED_TOOL_RESULT_BYTES);
89
+ if (!snapshot)
90
+ continue;
91
+ removePrivateStateFile(entry.path, snapshot, directory);
92
+ totalFiles--;
93
+ totalBytes -= entry.size;
94
+ }
95
+ catch {
96
+ // Never unlink a path that failed the private-state identity boundary.
97
+ }
98
+ }
99
+ return (totalFiles < MAX_STORED_TOOL_RESULT_FILES
100
+ && totalBytes + incomingBytes <= MAX_STORED_TOOL_RESULT_TOTAL_BYTES);
101
+ }
102
+ /** Store only a redacted, bounded UTF-8 value. Failure degrades to an ordinary preview; the original tool
103
+ * action must not fail merely because this optional continuation store is unavailable. */
104
+ export function storeToolResult(value) {
105
+ const raw = typeof value === "string" ? value : String(value ?? "");
106
+ const text = redactToolSubprocessOutput(raw);
107
+ const bytes = Buffer.byteLength(text, "utf8");
108
+ if (!bytes || bytes > MAX_STORED_TOOL_RESULT_BYTES)
109
+ return null;
110
+ try {
111
+ const id = `tr_${randomBytes(16).toString("hex")}`;
112
+ const binding = resultBinding(id);
113
+ if (!pruneStore(binding.directory, bytes))
114
+ return null;
115
+ writePrivateStateFileSync(binding, text);
116
+ return { id, chars: text.length };
117
+ }
118
+ catch {
119
+ return null;
120
+ }
121
+ }
122
+ function referenceFooter(stored, compact = false) {
123
+ if (compact)
124
+ return `[tool result ${stored.id}; use tool_result_read]`;
125
+ return (`\n…[hara: ${stored.chars} redacted chars stored as ${stored.id}; ` +
126
+ `call tool_result_read with {"id":"${stored.id}","offset":0,"limit":${MAX_TOOL_RESULT_READ_CHARS}} to continue]…`);
127
+ }
128
+ function previewWithReference(text, stored, max) {
129
+ const cap = Math.max(0, Math.floor(max));
130
+ if (!cap)
131
+ return "";
132
+ const footer = referenceFooter(stored);
133
+ if (footer.length + 32 < cap)
134
+ return limitToolResult(text, cap - footer.length) + footer;
135
+ return limitToolResult(referenceFooter(stored, true), cap);
136
+ }
137
+ /** Registry boundary: redact ordinary results and spool them before trimming so direct callers and the main
138
+ * loop receive the same safe representation. Verified file reads may keep their immediate preview because
139
+ * their protected-file policy has already run; continuation storage remains redacted independently. */
140
+ export function prepareToolResult(value, max = MAX_TOOL_RESULT_CHARS, options = {}) {
141
+ const raw = typeof value === "string" ? value : String(value ?? "");
142
+ const text = options.redactPreview === false ? raw : redactToolSubprocessOutput(raw);
143
+ const cap = Math.max(0, Math.floor(max));
144
+ if (text.length <= cap)
145
+ return text;
146
+ const stored = storeToolResult(raw);
147
+ return stored ? previewWithReference(text, stored, cap) : limitToolResult(text, cap);
148
+ }
149
+ function existingResultReference(text) {
150
+ const match = text.match(/\[hara:\s+(\d+)\s+redacted chars stored as (tr_[a-f0-9]{32});/);
151
+ if (!match)
152
+ return null;
153
+ const [, charsText, id] = match;
154
+ try {
155
+ const binding = resultBinding(id);
156
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_STORED_TOOL_RESULT_BYTES);
157
+ if (!snapshot)
158
+ return null;
159
+ const chars = Number(charsText);
160
+ return { id, chars: Number.isFinite(chars) ? chars : snapshot.text.length };
161
+ }
162
+ catch {
163
+ return null;
164
+ }
165
+ }
166
+ /** Bound one parallel tool round. Values reduced only because siblings consumed the round budget receive
167
+ * their own continuation id instead of losing content. */
168
+ export function limitToolResultBatch(values, max = MAX_TOOL_RESULT_BATCH_CHARS) {
169
+ const cap = Math.max(0, Math.floor(max));
170
+ if (values.reduce((sum, value) => sum + value.length, 0) <= cap)
171
+ return [...values];
172
+ if (!values.length)
173
+ return [];
174
+ const allowance = Math.floor(cap / values.length);
175
+ return values.map((value) => {
176
+ if (value.length <= allowance)
177
+ return value;
178
+ const stored = existingResultReference(value) ?? storeToolResult(value);
179
+ return stored ? previewWithReference(value, stored, allowance) : limitToolResult(value, allowance);
180
+ });
181
+ }
182
+ /** Page one opaque result id. Paths are never accepted, and the private reader rejects symlinks/hard links. */
183
+ export function readStoredToolResult(id, offsetValue = 0, limitValue = MAX_TOOL_RESULT_READ_CHARS) {
184
+ if (!RESULT_ID.test(id))
185
+ return "Error: invalid tool result id.";
186
+ const offset = Math.max(0, Math.floor(Number(offsetValue) || 0));
187
+ const limit = Math.min(MAX_TOOL_RESULT_READ_CHARS, Math.max(1, Math.floor(Number(limitValue) || MAX_TOOL_RESULT_READ_CHARS)));
188
+ try {
189
+ const binding = resultBinding(id);
190
+ const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_STORED_TOOL_RESULT_BYTES);
191
+ if (!snapshot)
192
+ return `Error: tool result ${id} was not found or has expired. Re-run the narrow source query.`;
193
+ const text = snapshot.text;
194
+ if (offset >= text.length)
195
+ return `(tool result ${id} has ${text.length} chars; offset ${offset} is past the end)`;
196
+ const end = Math.min(text.length, offset + limit);
197
+ let slice = safeTail(text, offset);
198
+ slice = safeHead(slice, end - offset);
199
+ const actualEnd = offset + slice.length;
200
+ const header = `(tool result ${id}: chars ${offset}–${actualEnd} of ${text.length}` +
201
+ `${actualEnd < text.length ? `; continue with offset:${actualEnd}` : ""})\n`;
202
+ return header + slice;
203
+ }
204
+ catch (error) {
205
+ return `Error: cannot read tool result ${id}: ${error instanceof Error ? error.message : String(error)}`;
206
+ }
207
+ }
@@ -0,0 +1,67 @@
1
+ // Provider-neutral deferred tool discovery and continuation reads for oversized tool output.
2
+ // These eager tools replace two context-hostile patterns: exposing every long-tail schema up front, and
3
+ // rerunning a broad command because the useful middle of its output was discarded.
4
+ import { registerTool, searchDeferredToolCatalog } from "./registry.js";
5
+ import { MAX_TOOL_RESULT_READ_CHARS, readStoredToolResult } from "./result-limit.js";
6
+ registerTool({
7
+ name: "tool_search",
8
+ description: "Search Hara's deferred tool catalog when the current task needs a capability that is not already listed. " +
9
+ "Matching tools become available on the NEXT model round without loading every long-tail/MCP schema up front. " +
10
+ "Search by capability or service, for example browser, wechat, spreadsheet, or calendar.",
11
+ input_schema: {
12
+ type: "object",
13
+ properties: {
14
+ query: { type: "string", description: "Capability, service, or action to find." },
15
+ max_results: { type: "integer", minimum: 1, maximum: 8, description: "Maximum matches to activate (default 5)." },
16
+ },
17
+ required: ["query"],
18
+ },
19
+ kind: "read",
20
+ classify: () => ({ effect: "state", concurrencySafe: false }),
21
+ async run(input, ctx) {
22
+ const query = typeof input?.query === "string" ? input.query.trim() : "";
23
+ if (!query)
24
+ return "Error: tool_search needs a non-empty query.";
25
+ const limit = Math.max(1, Math.min(8, Math.floor(Number(input?.max_results) || 5)));
26
+ // Search beyond the display limit because a role filter may reject high-ranked candidates.
27
+ const candidates = searchDeferredToolCatalog(query, 32);
28
+ const selected = [];
29
+ for (const match of candidates) {
30
+ const accepted = ctx.activateTools ? ctx.activateTools([match.name]) : [match.name];
31
+ if (!accepted.includes(match.name))
32
+ continue;
33
+ selected.push(match);
34
+ if (selected.length >= limit)
35
+ break;
36
+ }
37
+ if (!selected.length) {
38
+ return `No deferred tools matching "${query}" are available to this run. Use an existing tool or refine the query.`;
39
+ }
40
+ return (`Activated ${selected.length} tool(s) for the next model round:\n` +
41
+ selected.map((match) => `- ${match.name} — ${match.description.slice(0, 300)}`).join("\n"));
42
+ },
43
+ });
44
+ registerTool({
45
+ name: "tool_result_read",
46
+ description: "Read the next bounded character slice of an oversized tool result stored under an opaque tr_* id. " +
47
+ "Use the id and continuation offset from the truncation notice; this tool never accepts filesystem paths.",
48
+ input_schema: {
49
+ type: "object",
50
+ properties: {
51
+ id: { type: "string", pattern: "^tr_[a-f0-9]{32}$", description: "Opaque result id from a tool output." },
52
+ offset: { type: "integer", minimum: 0, description: "0-based character offset (default 0)." },
53
+ limit: {
54
+ type: "integer",
55
+ minimum: 1,
56
+ maximum: MAX_TOOL_RESULT_READ_CHARS,
57
+ description: `Characters to return (default/max ${MAX_TOOL_RESULT_READ_CHARS}).`,
58
+ },
59
+ },
60
+ required: ["id"],
61
+ },
62
+ kind: "read",
63
+ concurrencySafe: true,
64
+ async run(input) {
65
+ return readStoredToolResult(String(input?.id ?? ""), input?.offset, input?.limit);
66
+ },
67
+ });
@@ -522,6 +522,7 @@ registerTool({
522
522
  required: ["pattern"],
523
523
  },
524
524
  kind: "read",
525
+ concurrencySafe: true,
525
526
  async run(input, ctx) {
526
527
  const pattern = typeof input.pattern === "string" ? input.pattern : "";
527
528
  if (pattern.length > MAX_PATTERN_CHARS)
@@ -606,6 +607,7 @@ registerTool({
606
607
  required: ["pattern"],
607
608
  },
608
609
  kind: "read",
610
+ concurrencySafe: true,
609
611
  async run(input, ctx) {
610
612
  const root = absOf(input.path, ctx.cwd);
611
613
  const denied = sensitiveFileError(root, "search");
@@ -672,6 +674,7 @@ registerTool({
672
674
  properties: { path: { type: "string", description: "directory (default: cwd)" } },
673
675
  },
674
676
  kind: "read",
677
+ concurrencySafe: true,
675
678
  async run(input, ctx) {
676
679
  if (isHomeWorkspace(ctx.cwd))
677
680
  return homeWorkspaceDirectoryScanError("ls");
@@ -11,6 +11,7 @@ registerTool({
11
11
  "call this to get a skill's steps before performing a task it covers, then follow them.",
12
12
  input_schema: { type: "object", properties: { id: { type: "string", description: "the skill id from the Skills list" } }, required: ["id"] },
13
13
  kind: "read",
14
+ concurrencySafe: true,
14
15
  async run(input, ctx) {
15
16
  const id = String(input.id ?? "").trim();
16
17
  const sk = loadSkillIndex(ctx.cwd).find((s) => s.id === id);
@@ -436,6 +436,11 @@ registerTool({
436
436
  required: ["action"],
437
437
  },
438
438
  kind: "edit",
439
+ classify(input) {
440
+ return input?.action === "list"
441
+ ? { effect: "read", concurrencySafe: true }
442
+ : { effect: "edit", concurrencySafe: false, destructive: input?.action === "remove" };
443
+ },
439
444
  async run(input, ctx) {
440
445
  try {
441
446
  return withTaskLock(ctx.cwd, () => {
@@ -1,6 +1,6 @@
1
1
  // todo_write — an inline task checklist the agent maintains during a turn (like codex's update_plan /
2
2
  // Claude Code's TodoWrite). Keeps the model organized on multi-step work and shows the user live progress.
3
- // In-memory, replace-whole-list semantics; kind:"read" so it never prompts and is safe to call freely.
3
+ // In-memory, replace-whole-list semantics; approval-safe but serialized because it mutates shared run state.
4
4
  import { registerTool } from "./registry.js";
5
5
  const DEFAULT_SCOPE = "default";
6
6
  const stores = new Map([[DEFAULT_SCOPE, []]]);
@@ -133,7 +133,8 @@ registerTool({
133
133
  },
134
134
  required: ["todos"],
135
135
  },
136
- kind: "read", // pure state + display: never prompts, parallel-safe
136
+ kind: "read", // state/display only: never prompts; input-level traits keep replacement writes serial
137
+ classify: () => ({ effect: "state", concurrencySafe: false }),
137
138
  async run(input, ctx) {
138
139
  const scope = scopeKey(ctx.todoScope);
139
140
  const raw = Array.isArray(input.todos) ? input.todos : [];
package/dist/tools/web.js CHANGED
@@ -399,6 +399,8 @@ registerTool({
399
399
  required: ["query"],
400
400
  },
401
401
  kind: "read",
402
+ concurrencySafe: true,
403
+ visibility: "deferred",
402
404
  async run(input, ctx) {
403
405
  if (ctx.signal?.aborted)
404
406
  throw interrupted("web search");
@@ -506,6 +508,8 @@ registerTool({
506
508
  required: ["url"],
507
509
  },
508
510
  kind: "read",
511
+ concurrencySafe: true,
512
+ visibility: "deferred",
509
513
  async run(input, ctx) {
510
514
  if (ctx.signal?.aborted)
511
515
  throw interrupted("web fetch");
package/dist/tui/App.js CHANGED
@@ -299,7 +299,11 @@ function StatusRow({ working, steerable, todos, queued }) {
299
299
  const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
300
300
  // Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
301
301
  // generic "working" when the network is slow — the user knows the request is out, not dead.
302
- const verb = (phase === "waiting" ? `waiting for the model… ${elapsedSec}s · esc to interrupt` : spinnerVerb(todos, elapsedSec)) + bgTag;
302
+ const verb = (phase === "awaiting_user"
303
+ ? "waiting for your answer · task timer paused · esc to cancel"
304
+ : phase === "waiting"
305
+ ? `waiting for the model… ${elapsedSec}s · esc to interrupt`
306
+ : spinnerVerb(todos, elapsedSec)) + bgTag;
303
307
  return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ${steerable ? "⏎ steers · /next queues" : "⏎ queues next"}${queued ? ` (${queued})` : ""}` })] }));
304
308
  }
305
309
  // Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.125.3",
3
+ "version": "0.126.0",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "runtime-bootstrap.cjs"