@nanhara/hara 0.125.1 → 0.125.3

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,43 @@ 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.125.3 — 2026-07-18 — conversation/task boundary and deliberate execution
9
+
10
+ - **Chat delivery is now separate from task execution.** Slash controls such as `/model` no longer create a
11
+ fake steer target; input typed while a picker or other local control is busy is visibly queued as the next
12
+ turn. If a real turn ends between enqueue and delivery, Hara promotes the raced steer to a normal turn
13
+ instead of rejecting it with `there is no task to steer` or dropping the input. Queued controls execute
14
+ alone rather than absorbing the following task as command arguments, and paused/completed tasks are never
15
+ implicit steer targets—only explicit `/continue` may reopen one. User-invocable slash Skills remain real
16
+ Agent turns, so refinements typed while `/design`, `/video`, or another Skill is running steer that Skill
17
+ instead of becoming an unrelated task.
18
+ - **Main tasks now establish an explicit understanding checkpoint before side effects.** The engine-owned
19
+ `task_intake` records intent, interpreted goal, constraints, acceptance checks, and short steps in the
20
+ durable task state. Reads and diagnosis may happen first, but edits, non-read-only commands, computer
21
+ actions, external agents, and MCP connections are blocked until the brief is accepted in a completed tool
22
+ round. Starting or stopping a background process is also treated as a state change even when its command or
23
+ job tool is otherwise read-only; malformed string-valued `background` flags are rejected. Read-only actions
24
+ inside mixed tools (`task list`, `cronjob list`) stay available for evidence gathering. Brief revisions
25
+ replace the old prompt copy and cannot share a round with a side effect. A later steer cannot be overwritten
26
+ by a concurrent or revised checkpoint.
27
+ - **Alibaba Coding Plan discovery follows the current exact model contract.** `/model` prefers the endpoint's
28
+ live response and uses the documented ten-model list only for official Coding Plan hosts. Context guards now
29
+ use each model's published window, and `qwen3-coder-next` / `qwen3-coder-plus` no longer expose an unsupported
30
+ thinking control. Desktop/serve derives the reasoning dial from each session's pinned model rather than the
31
+ current global default.
32
+ - **Configured MCP servers no longer execute or ask for permission when Hara starts.** Hara exposes a
33
+ zero-side-effect `mcp_connect` capability; the agent calls it only when the current task materially needs
34
+ one named server, receives the existing external-boundary approval, and discovers that server's tools for
35
+ the next model round. Other configured servers remain stopped.
36
+ - Interactive calls to connected MCP tools still require confirmation every time, protected-file-shaped
37
+ inputs remain blocked, read-only roles cannot receive the launcher, and headless runs remain fail-closed
38
+ unless `HARA_ALLOW_TRUSTED_EXTENSIONS=1` was set before launch.
39
+ - MCP stderr now drops only npm 11's repeated `Unknown user config "always-auth"` and `"home"` deprecation
40
+ lines. Other npm warnings and all actual server errors remain visible and redacted. Hara does not read,
41
+ rewrite, or weaken the user's `.npmrc`. Esc, interruption, and the total task deadline now also cancel a
42
+ lazy MCP startup/tool call and close an unresponsive child instead of leaving it behind.
43
+ - Upgrade with `npm i -g @nanhara/hara@0.125.3`.
44
+
8
45
  ## 0.125.1 — 2026-07-18 — installed plugin commands inside agent tasks
9
46
 
10
47
  - **Commands contributed by an installed Hara plugin are now available to Hara's own tool subprocesses.**
package/README.md CHANGED
@@ -109,8 +109,11 @@ hara config set model qwen-plus # or qwen-max, qwen3-coder-plus, …
109
109
  # coding-plan keys (sk-sp-…) use the coding endpoint instead:
110
110
  # hara config set baseURL https://coding.dashscope.aliyuncs.com/v1
111
111
  # hara config set model qwen3.7-plus
112
- # plan models: qwen3.7-plus, qwen3.6-plus, qwen3-coder-plus, qwen3-coder-next,
113
- # qwen3-max-2026-01-23, glm-5, glm-4.7 (switch with -m or /model)
112
+ # supported exact ids (2026-07-18):
113
+ # qwen3.7-plus, qwen3.6-plus, qwen3.5-plus, qwen3-max-2026-01-23,
114
+ # qwen3-coder-next, qwen3-coder-plus, kimi-k2.5, glm-5, glm-4.7,
115
+ # MiniMax-M2.5
116
+ # /model uses the endpoint's live list first and this official list only as a fallback.
114
117
  ```
115
118
 
116
119
  > Plan keys (Coding Plan / Token Plan) are licensed **only** for use inside AI coding agents /
@@ -266,7 +269,7 @@ Type-ahead Enter steers the exact live turn; `/next <message>` queues a separate
266
269
  clients use `session.steer` with `expectedTurnId` (durable before ACK) and may pass `newTask: true` to force a
267
270
  new execution. The `hara resume` launcher preserves terminal input in both npm/Node and standalone-binary
268
271
  installs.
269
- **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).
272
+ **MCP**: add an `mcpServers` map to global config (a reviewed project config additionally needs `HARA_TRUST_PROJECT_CONFIG=1` at launch). Hara starts with every configured server stopped and exposes only `mcp_connect`; when the current task first needs one server, the agent requests permission to connect that server and its tools appear on the next round as `mcp__<server>__<tool>`. Unrelated servers remain stopped. Configured MCP servers, like `external_agent`, are trusted host extensions outside Hara's protected-file boundary. Every interactive external-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).
270
273
  **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.
271
274
  **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.
272
275
  **Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
@@ -274,6 +277,14 @@ session and is isolated between simultaneous sub-agents and serve sessions. `tas
274
277
  for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
275
278
  owner, and `blockedBy` dependencies. The private, atomic store is shared by concurrent hara processes for the
276
279
  same project and rejects missing/self/cyclic dependencies.
280
+ **Conversation and execution**: the composer first routes an input as a local control, a new task, a steer of
281
+ the exact live task, or an explicit next task. User-invocable slash Skills are executable turns and accept
282
+ live steering; local controls such as `/model` are serialized without pretending to own a task. A main task keeps the raw user request and a separate accepted
283
+ brief (intent, interpreted goal, constraints, acceptance checks, and steps). Hara permits read-only
284
+ investigation before that brief, but its engine blocks side effects until the understanding has been
285
+ checkpointed in its own model/tool round. Mixed tools are classified by action (`task list` is a read), and
286
+ a brief revision cannot share its round with a side effect. `/task` shows the current execution identity and brief. See
287
+ [`docs/conversation-task-execution.md`](docs/conversation-task-execution.md).
277
288
  **Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
278
289
  **Run limits and loop alarms**: every agent turn has a non-renewable 30-minute total deadline and a 64-round
279
290
  model/tool cap. Hara warns after five minutes or at 75% of the round budget, stops the third identical failing
@@ -286,8 +297,8 @@ provider that ignores cancellation cannot strand the CLI outside the main loop.
286
297
  **Hooks**: run your own shell commands around tool calls via a `"hooks"` map in global config; hooks from a reviewed project config require the launch-time `HARA_TRUST_PROJECT_CONFIG=1` opt-in. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
287
298
  Reviewer/read-only/plan runs and parallel read-only sub-agents suppress both hook phases: PreToolUse and
288
299
  PostToolUse commands are arbitrary shell, so either could otherwise bypass their read-only contract indirectly.
289
- They also skip configured and plugin-provided MCP server processes, so starting an external tool server cannot
290
- bypass the same contract before the first model turn.
300
+ They also omit the lazy connector for configured and plugin-provided MCP servers, so a read-only role cannot
301
+ start an external tool server.
291
302
  **Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
292
303
  `~/.hara/config.json` for named config overlays. Project `.hara/config.json` files get the safe preference
293
304
  allowlist above; project-specific routing requires `HARA_TRUST_PROJECT_CONFIG=1` before launch.
package/SECURITY.md CHANGED
@@ -70,11 +70,13 @@ local user (who already has your shell).
70
70
  - **MCP/external agents are trusted extensions.** They execute outside Hara's protected-file boundary. Every
71
71
  interactive tool call requires confirmation, including in `full-auto`; non-interactive runs disable them by
72
72
  default. Reviewed automation can explicitly enable them before launch with
73
- `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. Their inherited environment is still scrubbed, but the extension may use
74
- its own credentials or access anything its host process permits.
73
+ `HARA_ALLOW_TRUSTED_EXTENSIONS=1`. Merely configuring an MCP server does not start it: `mcp_connect` requests
74
+ permission and starts one named server only when a task needs it. Their inherited environment is still
75
+ scrubbed, but the extension may use its own credentials or access anything its host process permits.
75
76
  - **Plugins are code you trust.** Installing a plugin (`hara plugin add`) grants its author code execution:
76
- its MCP servers and hooks run shell commands on launch. `hara plugin add` **prints the exact commands** a
77
- plugin will run so you can review them; disable with `hara plugin disable <name>`.
77
+ its MCP servers may run on first relevant use and its hooks may run around matching tool calls. `hara plugin
78
+ add` **prints the exact commands** a plugin can run so you can review them; disable with
79
+ `hara plugin disable <name>`.
78
80
  - **Coding-plan keys.** Provider keys you configure are used only to call the model endpoint you set.
79
81
 
80
82
  ## What is *not* a security boundary
@@ -6,7 +6,7 @@ import { makeRenderer } from "../md.js";
6
6
  import { skillsDigest } from "../skills/skills.js";
7
7
  import { runHooks } from "../hooks.js";
8
8
  import { mapLimit, maxParallel } from "../concurrency.js";
9
- import { decideCommand, loadPermissionRules } from "../security/permissions.js";
9
+ import { decideCommand, isReadOnlyCommand, loadPermissionRules, splitCompound, } from "../security/permissions.js";
10
10
  import { classifyRisk, guardianVeto, guardianEnabled, newBreaker, recordBlock } from "../security/guardian.js";
11
11
  import { keyOf, looksFailed, recordCall } from "./repeat-guard.js";
12
12
  import { agentMaxRounds, agentRunTimeoutMs, formatAgentDuration } from "./limits.js";
@@ -21,6 +21,7 @@ import { redactSensitiveText } from "../security/secrets.js";
21
21
  import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
22
22
  import { prepareHistoryForModel } from "./context-budget.js";
23
23
  import { rolesDigest } from "../org/roles.js";
24
+ import { applyTaskBrief } from "../session/task.js";
24
25
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
25
26
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
26
27
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -86,7 +87,9 @@ mask to existing protected paths; Linux/Windows shell checks are
86
87
  static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
87
88
  only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
88
89
  are disabled without an interactive approval channel unless the user launched with
89
- HARA_ALLOW_TRUSTED_EXTENSIONS=1.
90
+ HARA_ALLOW_TRUSTED_EXTENSIONS=1. Configured MCP servers stay stopped by default; when a task materially needs
91
+ one, call \`mcp_connect\` for that server only, then use the newly available tools on the next round. Never
92
+ connect every configured server speculatively.
90
93
  For broad,
91
94
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
92
95
  independent questions (role "explore") — each returns conclusions, not dumps. When specialist roles are
@@ -142,14 +145,34 @@ const CONTINUATION_SYSTEM = "# Existing-session continuity\n" +
142
145
  "re-inventory the workspace, or summarize files merely to understand what happened before. Follow the latest user request " +
143
146
  "and reuse prior conclusions and tool results. Inspect files only when the latest request requires it. If the working " +
144
147
  "directory is Home, ask the user to start Hara from a concrete project instead of enumerating Home or its children.";
145
- function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext) {
148
+ function composeSystem(cwd, projectContext, override, memory, continuationSession = false, executionContext, intake) {
146
149
  const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
147
150
  const skills = skillsDigest(cwd);
148
151
  const roles = override ? "" : rolesDigest(cwd);
152
+ const intakeContext = !intake?.enabled
153
+ ? ""
154
+ : intake.brief
155
+ ? ("\n\n# Understanding → execution boundary\n" +
156
+ "The task brief below is the accepted interpretation for this run. Keep actions inside it. If new " +
157
+ "user input materially changes the goal, constraints, acceptance checks, or intended side effects, " +
158
+ "call `task_intake` again before further side effects.\n" +
159
+ `Intent: ${intake.brief.intent}\n` +
160
+ `Goal: ${intake.brief.goal}\n` +
161
+ `Constraints:\n${intake.brief.constraints.map((item) => `- ${item}`).join("\n")}\n` +
162
+ `Acceptance:\n${intake.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
163
+ `Steps:\n${intake.brief.steps.map((item, index) => `${index + 1}. ${item}`).join("\n")}`)
164
+ : ("\n\n# Understanding → execution boundary\n" +
165
+ "Do not jump from a raw request straight into side effects. You may answer, inspect files, search, " +
166
+ "ask a necessary question, or build a todo list first. BEFORE the first edit, non-read-only command, " +
167
+ "background-process start/stop, computer action, external agent, or MCP connection, call `task_intake` in its OWN tool round with " +
168
+ "the interpreted goal, intent, constraints, acceptance checks, and short steps. Use intent `answer` " +
169
+ "for a direct answer, `investigate` for evidence gathering/diagnosis, and `change` when the user asked " +
170
+ "you to modify or deliver something. Do not claim completion until the acceptance checks are verified.");
149
171
  return (head +
150
172
  gatewayNote() +
151
173
  (continuationSession ? `\n\n${CONTINUATION_SYSTEM}` : "") +
152
174
  (executionContext ? `\n\n${executionContext}` : "") +
175
+ intakeContext +
153
176
  (projectContext ? `\n\n# Project context (AGENTS.md)\n${projectContext}` : "") +
154
177
  (memory ? `\n\n# Memory (durable — facts/decisions/prefs you've saved; use memory_search/get for more)\n${memory}` : "") +
155
178
  (roles ? `\n\n# Specialist roles (metadata only — use \`agent\` with a role id for bounded read-only expertise)\n${roles}` : "") +
@@ -286,6 +309,54 @@ async function runAgentInner(history, opts, life) {
286
309
  const { provider, ctx } = opts;
287
310
  const runSignal = life.signal;
288
311
  const toolCtx = { ...ctx, signal: runSignal };
312
+ let intakeTask = opts.taskIntake?.task;
313
+ let intakeDirty = false;
314
+ const syncIntakeTask = () => {
315
+ const current = opts.taskIntake?.current?.();
316
+ if (current && (!intakeTask || current.id === intakeTask.id))
317
+ intakeTask = current;
318
+ };
319
+ const taskIntakeTool = opts.taskIntake
320
+ ? {
321
+ name: "task_intake",
322
+ description: "Record or revise your explicit understanding of the active task before side effects. Call this " +
323
+ "in its own tool round only after using the conversation and any needed read-only evidence to identify " +
324
+ "the real goal. Required before edits, non-read-only commands, background-process start/stop, computer actions, external agents, or MCP.",
325
+ input_schema: {
326
+ type: "object",
327
+ properties: {
328
+ intent: {
329
+ type: "string",
330
+ enum: ["answer", "investigate", "change"],
331
+ description: "answer = direct response, investigate = evidence/diagnosis, change = modify or deliver",
332
+ },
333
+ goal: { type: "string", description: "One concrete interpreted outcome; include the actual target, not generic 'help the user'." },
334
+ constraints: { type: "array", items: { type: "string" }, description: "User/project boundaries that must remain true." },
335
+ acceptance: { type: "array", items: { type: "string" }, description: "Observable checks that prove the task is done." },
336
+ steps: { type: "array", items: { type: "string" }, description: "Short ordered approach, normally 2–6 steps." },
337
+ },
338
+ required: ["intent", "goal", "constraints", "acceptance", "steps"],
339
+ },
340
+ kind: "read",
341
+ run: async (input) => {
342
+ syncIntakeTask();
343
+ const applied = applyTaskBrief(intakeTask, input);
344
+ if (!applied.ok)
345
+ return `Error: task brief rejected — ${applied.reason}`;
346
+ intakeTask = applied.task;
347
+ intakeDirty = true;
348
+ return (`Task brief accepted (${applied.brief.intent}).\n` +
349
+ `Goal: ${applied.brief.goal}\n` +
350
+ `Acceptance:\n${applied.brief.acceptance.map((item) => `- ${item}`).join("\n")}\n` +
351
+ "Proceed within this brief; revise task_intake first if the user's intent materially changes.");
352
+ },
353
+ }
354
+ : undefined;
355
+ // `task_intake` is engine-owned and cannot be shadowed by an ad-hoc tool with the same name.
356
+ const runExtraTools = [
357
+ ...(opts.extraTools ?? []).filter((tool) => tool.name !== "task_intake"),
358
+ ...(taskIntakeTool ? [taskIntakeTool] : []),
359
+ ];
289
360
  const permRules = loadPermissionRules(ctx.cwd); // command-level allow/ask/deny policy for the bash tool
290
361
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
291
362
  let triedFallback = false;
@@ -381,6 +452,9 @@ async function runAgentInner(history, opts, life) {
381
452
  return stoppedOutcome();
382
453
  for (const m of pending)
383
454
  history.push(m);
455
+ // pendingInput may have durably accepted steering into the owner's immutable task snapshot. Refresh
456
+ // before composing the system or applying a later brief so that state is never overwritten.
457
+ syncIntakeTask();
384
458
  }
385
459
  // system-reminder injection: event-driven context queued since the last call (todo staleness today)
386
460
  // lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
@@ -391,11 +465,11 @@ async function runAgentInner(history, opts, life) {
391
465
  history.push({ role: "user", content: wrapReminders(reminders) });
392
466
  }
393
467
  const baseSpecs = opts.toolFilter ? toolSpecs().filter((t) => opts.toolFilter(t.name)) : toolSpecs();
394
- const specs = opts.extraTools?.length
395
- ? [...baseSpecs, ...opts.extraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
468
+ const specs = runExtraTools.length
469
+ ? [...baseSpecs, ...runExtraTools.map((t) => ({ name: t.name, description: t.description, input_schema: t.input_schema }))]
396
470
  : baseSpecs;
397
471
  const sink = ctx.ui; // TUI mode: route output to ink instead of stdout
398
- const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext);
472
+ const system = composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory, opts.continuationSession, opts.executionContext, { enabled: !!opts.taskIntake, brief: intakeTask?.brief });
399
473
  const prepared = prepareHistoryForModel(history, {
400
474
  model: activeProvider.model,
401
475
  system,
@@ -736,7 +810,12 @@ async function runAgentInner(history, opts, life) {
736
810
  };
737
811
  const plans = [];
738
812
  // Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
739
- const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
813
+ const resolveTool = (name) => runExtraTools.find((t) => t.name === name) ?? getTool(name);
814
+ // Planning happens before dispatch, so a previously accepted `change` brief must not let the model
815
+ // revise that brief and perform a side effect in the same response. Treat every intake call as a
816
+ // transaction boundary for the whole response: accept/checkpoint the interpretation first, then let
817
+ // the next model round act against the newly authoritative brief.
818
+ const taskBriefTransitionInRound = r.toolUses.some((tu) => tu.name === "task_intake");
740
819
  for (const tu of r.toolUses) {
741
820
  if (runSignal.aborted)
742
821
  return finalizeStoppedToolRound();
@@ -752,9 +831,71 @@ async function runAgentInner(history, opts, life) {
752
831
  continue;
753
832
  }
754
833
  const input = tu.input;
755
- const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
834
+ const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? input.server ?? "")
756
835
  .replace(/\s+/g, " ")
757
836
  .trim());
837
+ // Resolve shell read-only policy before the understanding gate: an `investigate` brief may run a
838
+ // command already classified safe/read-only, while edits and unknown/non-read-only commands require
839
+ // an explicit `change` brief. Opaque external tools always require a brief and retain their separate
840
+ // per-action approval boundary.
841
+ const command = tool.kind === "exec" && typeof input.command === "string" ? input.command : null;
842
+ const cmdDecision = command !== null ? decideCommand(command, permRules) : null;
843
+ // Permission rules answer whether a command may auto-run; they do not answer whether it mutates state.
844
+ // In particular, an explicit `allow: ["git commit"]` must not let an `investigate` brief perform a
845
+ // commit. Classify read-only semantics independently for the understanding boundary.
846
+ const commandParts = command !== null ? splitCompound(command) : null;
847
+ const commandReadOnly = !!commandParts?.length && commandParts.every(isReadOnlyCommand);
848
+ // Bash intentionally treats `background` as a boolean. Be conservative at the policy boundary even
849
+ // for a malformed model call: any truthy value would otherwise reach a truthiness-based/custom exec
850
+ // implementation as a process start and bypass an investigate brief.
851
+ const startsBackgroundProcess = tool.kind === "exec" && Boolean(input.background);
852
+ const stopsBackgroundProcess = tool.name === "job" && input.action === "kill";
853
+ if (opts.taskIntake && tool.name !== "task_intake") {
854
+ // Some action-style tools contain both inspection and mutation behind one registry kind. Classify
855
+ // the concrete operation, not only the approval class: listing tasks/cron jobs is evidence gathering,
856
+ // while their other actions remain state changes.
857
+ const readOnlyAction = (tool.name === "task" || tool.name === "cronjob") &&
858
+ input.action === "list";
859
+ const operationReadOnly = readOnlyAction ||
860
+ (tool.kind === "read" && !stopsBackgroundProcess) ||
861
+ (tool.kind === "exec" && commandReadOnly && !startsBackgroundProcess);
862
+ const needsBrief = tool.trustBoundary === "external" || !operationReadOnly;
863
+ const requiresChange = tool.trustBoundary !== "external" &&
864
+ !operationReadOnly &&
865
+ (tool.kind === "edit" ||
866
+ tool.kind === "computer" ||
867
+ stopsBackgroundProcess ||
868
+ startsBackgroundProcess ||
869
+ (tool.kind === "exec" && !commandReadOnly));
870
+ if (taskBriefTransitionInRound && (requiresChange || tool.trustBoundary === "external")) {
871
+ plans.push({
872
+ tu,
873
+ tool,
874
+ denied: "Understanding gate: task_intake establishes or revises the brief in this tool round, so this " +
875
+ "side effect was NOT executed. Wait for the next model round, then act against the checkpointed brief.",
876
+ });
877
+ continue;
878
+ }
879
+ if (needsBrief && !intakeTask?.brief) {
880
+ plans.push({
881
+ tu,
882
+ tool,
883
+ denied: "Understanding gate: this action was NOT executed. First inspect/ask what is needed, then call " +
884
+ "task_intake in its own tool round with goal, intent, constraints, acceptance, and steps.",
885
+ });
886
+ continue;
887
+ }
888
+ if (requiresChange && intakeTask?.brief?.intent !== "change") {
889
+ plans.push({
890
+ tu,
891
+ tool,
892
+ denied: `Understanding gate: task brief intent is '${intakeTask?.brief?.intent ?? "unset"}', so this ` +
893
+ "side effect was NOT executed. Revise task_intake to intent 'change' with the user's authorized " +
894
+ "goal and acceptance checks before trying again.",
895
+ });
896
+ continue;
897
+ }
898
+ }
758
899
  // Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
759
900
  // and even full-auto must never silently turn them into a side channel.
760
901
  const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
@@ -769,7 +910,6 @@ async function runAgentInner(history, opts, life) {
769
910
  }
770
911
  // Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
771
912
  // read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
772
- const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
773
913
  if (cmdDecision === "deny") {
774
914
  plans.push({ tu, tool, denied: "Denied by a permission rule (~/.hara/permissions.json). Loosen the rule or run it yourself." });
775
915
  continue;
@@ -970,8 +1110,9 @@ async function runAgentInner(history, opts, life) {
970
1110
  return finalizeStoppedToolRound();
971
1111
  const p = plans[i];
972
1112
  // ask_user is interaction-safe but not parallel-safe: the TUI deliberately owns one prompt slot.
973
- // Flush other reads and ask sequentially so two questions cannot overwrite each other and hang.
974
- if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user") {
1113
+ // task_intake is also a state transition: run it alone so its persisted brief has a deterministic
1114
+ // boundary before the next model round. Flush other reads before either.
1115
+ if (p.denied === undefined && p.tool?.kind === "read" && p.tool.name !== "ask_user" && p.tool.name !== "task_intake") {
975
1116
  batch.push(i); // safe → accumulate to run concurrently
976
1117
  }
977
1118
  else {
@@ -987,6 +1128,29 @@ async function runAgentInner(history, opts, life) {
987
1128
  if (runSignal.aborted)
988
1129
  return finalizeStoppedToolRound();
989
1130
  history.push({ role: "tool", results });
1131
+ if (intakeDirty && intakeTask) {
1132
+ try {
1133
+ // The tool-use/result pair is now protocol-complete. Persist here—never inside the tool—so a crash
1134
+ // cannot leave a session ending in an orphaned tool_use, and no later side effect starts before the
1135
+ // accepted understanding has a durable checkpoint. A steer can be acknowledged while another read
1136
+ // from this same tool round is still settling, so merge from the authoritative owner once more at
1137
+ // this exact boundary instead of overwriting that acknowledged input with the earlier snapshot.
1138
+ const acceptedBrief = intakeTask.brief;
1139
+ syncIntakeTask();
1140
+ // The current owner may still carry the previous brief when this call is a revision. The accepted
1141
+ // brief is the state transition from this round; authoritative refresh contributes newer steering,
1142
+ // while this assignment contributes the new interpretation.
1143
+ if (acceptedBrief && intakeTask) {
1144
+ intakeTask = { ...intakeTask, brief: acceptedBrief };
1145
+ }
1146
+ opts.taskIntake?.onUpdate?.(intakeTask);
1147
+ opts.taskIntake?.onCheckpoint?.(intakeTask);
1148
+ intakeDirty = false;
1149
+ }
1150
+ catch (error) {
1151
+ return interactionFailure("task-intake checkpoint", error);
1152
+ }
1153
+ }
990
1154
  if (repeatHalt)
991
1155
  return hardStop(opts, life, "repeat_loop", repeatHalt);
992
1156
  // Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just