@nanhara/hara 0.125.0 → 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 +47 -0
- package/README.md +16 -5
- package/SECURITY.md +6 -4
- package/dist/agent/loop.js +175 -11
- package/dist/index.js +87 -79
- package/dist/mcp/client.js +72 -6
- package/dist/providers/models.js +39 -6
- package/dist/providers/reasoning.js +12 -0
- package/dist/security/subprocess-env.js +30 -0
- package/dist/serve/server.js +18 -5
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- package/dist/tools/builtin.js +3 -0
- package/dist/tui/App.js +44 -16
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,53 @@ 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
|
+
|
|
45
|
+
## 0.125.1 — 2026-07-18 — installed plugin commands inside agent tasks
|
|
46
|
+
|
|
47
|
+
- **Commands contributed by an installed Hara plugin are now available to Hara's own tool subprocesses.**
|
|
48
|
+
Hara appends the user-approved `~/.hara/bin` directory after the inherited `PATH`, so a plugin command
|
|
49
|
+
such as `hara-video` can be called during an agent task without shadowing a project or system executable.
|
|
50
|
+
Interactive shells remain user-controlled and still receive the existing one-time PATH hint.
|
|
51
|
+
- This closes the failure mode where the Video Skill was loaded but the agent saw `hara-video: command not
|
|
52
|
+
found`, hand-built an ungoverned replacement project, and then bypassed the plugin's verification command.
|
|
53
|
+
- Upgrade with `npm i -g @nanhara/hara@0.125.1`.
|
|
54
|
+
|
|
8
55
|
## 0.125.0 — 2026-07-18 — Claude-compatible specialist orchestration
|
|
9
56
|
|
|
10
57
|
- **Personal Claude Code subagents now work in Hara without copying prompts.** Hara discovers
|
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
|
-
#
|
|
113
|
-
#
|
|
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)
|
|
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
|
|
290
|
-
|
|
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`.
|
|
74
|
-
|
|
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
|
|
77
|
-
plugin
|
|
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
|
package/dist/agent/loop.js
CHANGED
|
@@ -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 =
|
|
395
|
-
? [...baseSpecs, ...
|
|
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) =>
|
|
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
|
-
//
|
|
974
|
-
|
|
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
|