@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +11 -10
- package/runtime-bootstrap.cjs +72 -0
package/dist/agent/loop.js
CHANGED
|
@@ -17,6 +17,7 @@ import { setTurnPhase } from "./phase.js";
|
|
|
17
17
|
import { recordTouch } from "./touched.js";
|
|
18
18
|
import { resolve as resolvePath } from "node:path";
|
|
19
19
|
import { redactSensitiveText } from "../security/secrets.js";
|
|
20
|
+
import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
20
21
|
/** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
|
|
21
22
|
const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
|
|
22
23
|
/** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
|
|
@@ -72,7 +73,17 @@ source file or shell command. Reference an environment variable instead (for exa
|
|
|
72
73
|
$API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
|
|
73
74
|
.env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
|
|
74
75
|
echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
|
|
75
|
-
not make embedding credentials acceptable.
|
|
76
|
+
not make embedding credentials acceptable. Built-in file, search, and context paths hard-reject protected
|
|
77
|
+
files (.env/.env.*, credential stores, private keys, and private Hara state) before ordinary approval/dispatch;
|
|
78
|
+
do not try to bypass that policy through shell indirection, another tool, a sub-agent, or full-auto. Safe
|
|
79
|
+
templates such as .env.example may be read. Only a user who restarts Hara with
|
|
80
|
+
HARA_ALLOW_SENSITIVE_FILES=1 explicitly removes the built-in deny and shell protected-read mask for that
|
|
81
|
+
process. Shell subprocesses have credentials removed from their environment. macOS also applies an OS read
|
|
82
|
+
mask to existing protected paths; Linux/Windows shell checks are
|
|
83
|
+
static guardrails, not a kernel sandbox. MCP and external coding agents run outside this boundary: use them
|
|
84
|
+
only as reviewed trusted extensions. Their tool calls require confirmation every time in interactive use and
|
|
85
|
+
are disabled without an interactive approval channel unless the user launched with
|
|
86
|
+
HARA_ALLOW_TRUSTED_EXTENSIONS=1.
|
|
76
87
|
For broad,
|
|
77
88
|
open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
|
|
78
89
|
independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
|
|
@@ -96,8 +107,8 @@ site, build output), check they are newer than their sources (compare mtimes or
|
|
|
96
107
|
the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
|
|
97
108
|
When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
|
|
98
109
|
that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
|
|
99
|
-
two-day-old work. Package-manager installs
|
|
100
|
-
|
|
110
|
+
two-day-old work. Package-manager installs receive a longer attached timeout by default; use background jobs
|
|
111
|
+
only when explicitly appropriate, and poll a background job before depending on it. Before opening a public tunnel,
|
|
101
112
|
verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
|
|
102
113
|
of unrelated tunnel tools. After completing a task, give a one-line summary.`;
|
|
103
114
|
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
@@ -111,7 +122,9 @@ function gatewayNote() {
|
|
|
111
122
|
`To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
|
|
112
123
|
`that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
|
|
113
124
|
`to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
|
|
114
|
-
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly
|
|
125
|
+
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly. ` +
|
|
126
|
+
`PLAIN TEXT ONLY: chat bubbles render markdown literally — never use **bold**, # headers, backticks, tables, ` +
|
|
127
|
+
`or [text](url); write list items as "- " lines and links as bare URLs.`);
|
|
115
128
|
}
|
|
116
129
|
function composeSystem(cwd, projectContext, override, memory) {
|
|
117
130
|
const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
|
|
@@ -129,6 +142,16 @@ export async function runAgent(history, opts) {
|
|
|
129
142
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
130
143
|
let triedFallback = false;
|
|
131
144
|
let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
|
|
145
|
+
const interruptedOutcome = () => {
|
|
146
|
+
const msg = "(interrupted)";
|
|
147
|
+
if (!opts.quiet) {
|
|
148
|
+
if (ctx.ui)
|
|
149
|
+
ctx.ui.notice(msg);
|
|
150
|
+
else
|
|
151
|
+
out(c.dim(`\n${msg}\n`));
|
|
152
|
+
}
|
|
153
|
+
return { status: "error", error: msg };
|
|
154
|
+
};
|
|
132
155
|
// Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
|
|
133
156
|
// transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
|
|
134
157
|
const latestUser = [...history].reverse().find((m) => m.role === "user");
|
|
@@ -166,7 +189,7 @@ export async function runAgent(history, opts) {
|
|
|
166
189
|
// lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
|
|
167
190
|
// sub-agent must not steal the main conversation's reminders.
|
|
168
191
|
if (!opts.quiet) {
|
|
169
|
-
const reminders = drainReminders();
|
|
192
|
+
const reminders = drainReminders(ctx.todoScope);
|
|
170
193
|
if (reminders.length)
|
|
171
194
|
history.push({ role: "user", content: wrapReminders(reminders) });
|
|
172
195
|
}
|
|
@@ -212,7 +235,12 @@ export async function runAgent(history, opts) {
|
|
|
212
235
|
const STALL_MS = stallMs();
|
|
213
236
|
const attempt = new AbortController();
|
|
214
237
|
const onUserAbort = () => attempt.abort();
|
|
215
|
-
|
|
238
|
+
// AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
|
|
239
|
+
// while provider routing is still refreshing, so inherit that state synchronously before the call.
|
|
240
|
+
if (opts.signal?.aborted)
|
|
241
|
+
attempt.abort();
|
|
242
|
+
else
|
|
243
|
+
opts.signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
216
244
|
let lastEvent = Date.now();
|
|
217
245
|
let stalled = false;
|
|
218
246
|
const stallTimer = setInterval(() => {
|
|
@@ -229,17 +257,33 @@ export async function runAgent(history, opts) {
|
|
|
229
257
|
if (!opts.quiet)
|
|
230
258
|
setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
|
|
231
259
|
let r;
|
|
260
|
+
let removeAttemptStop = () => { };
|
|
232
261
|
try {
|
|
233
|
-
|
|
262
|
+
// AbortSignal is advisory: a custom/provider SDK can ignore it and leave its Promise pending forever.
|
|
263
|
+
// Race the attempt itself so the watchdog and user cancellation remain hard boundaries. The abandoned
|
|
264
|
+
// Promise retains a rejection handler through Promise.race, and all late stream callbacks below are muted.
|
|
265
|
+
const attemptStopped = new Promise((resolveStopped) => {
|
|
266
|
+
const onAttemptStop = () => resolveStopped({ text: "", toolUses: [], stop: "error", errorMsg: "interrupted" });
|
|
267
|
+
removeAttemptStop = () => attempt.signal.removeEventListener("abort", onAttemptStop);
|
|
268
|
+
if (attempt.signal.aborted)
|
|
269
|
+
onAttemptStop();
|
|
270
|
+
else
|
|
271
|
+
attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
|
|
272
|
+
});
|
|
273
|
+
const providerTurn = activeProvider.turn({
|
|
234
274
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
235
275
|
history,
|
|
236
276
|
tools: specs,
|
|
237
277
|
// Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
|
|
238
278
|
// reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
|
|
239
279
|
onActivity: () => {
|
|
280
|
+
if (attempt.signal.aborted)
|
|
281
|
+
return;
|
|
240
282
|
lastEvent = Date.now();
|
|
241
283
|
},
|
|
242
284
|
onText: (d) => {
|
|
285
|
+
if (attempt.signal.aborted)
|
|
286
|
+
return;
|
|
243
287
|
alive();
|
|
244
288
|
if (opts.quiet)
|
|
245
289
|
return;
|
|
@@ -256,6 +300,8 @@ export async function runAgent(history, opts) {
|
|
|
256
300
|
},
|
|
257
301
|
onReasoning: sink || tty
|
|
258
302
|
? (d) => {
|
|
303
|
+
if (attempt.signal.aborted)
|
|
304
|
+
return;
|
|
259
305
|
alive();
|
|
260
306
|
if (opts.quiet)
|
|
261
307
|
return;
|
|
@@ -281,12 +327,23 @@ export async function runAgent(history, opts) {
|
|
|
281
327
|
}
|
|
282
328
|
}
|
|
283
329
|
}
|
|
284
|
-
: (
|
|
330
|
+
: () => {
|
|
331
|
+
if (!attempt.signal.aborted)
|
|
332
|
+
alive();
|
|
333
|
+
}, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
|
|
285
334
|
signal: attempt.signal,
|
|
286
335
|
});
|
|
336
|
+
opts.onProviderTurn?.(providerTurn);
|
|
337
|
+
r = await Promise.race([providerTurn, attemptStopped]);
|
|
338
|
+
}
|
|
339
|
+
catch (error) {
|
|
340
|
+
if (!opts.signal?.aborted)
|
|
341
|
+
throw error;
|
|
342
|
+
r = { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
287
343
|
}
|
|
288
344
|
finally {
|
|
289
345
|
clearInterval(stallTimer);
|
|
346
|
+
removeAttemptStop();
|
|
290
347
|
opts.signal?.removeEventListener("abort", onUserAbort);
|
|
291
348
|
}
|
|
292
349
|
// A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
|
|
@@ -304,6 +361,10 @@ export async function runAgent(history, opts) {
|
|
|
304
361
|
opts.stats.output += r.usage.output;
|
|
305
362
|
opts.stats.lastInput = r.usage.input;
|
|
306
363
|
}
|
|
364
|
+
// A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
|
|
365
|
+
// The original run signal is authoritative: do not append/approve/execute any late response.
|
|
366
|
+
if (opts.signal?.aborted)
|
|
367
|
+
return interruptedOutcome();
|
|
307
368
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
308
369
|
if (r.stop === "error") {
|
|
309
370
|
const kind = classifyError(r.errorMsg ?? "");
|
|
@@ -321,13 +382,26 @@ export async function runAgent(history, opts) {
|
|
|
321
382
|
continue; // retry once on the fallback model (guarded by triedFallback)
|
|
322
383
|
}
|
|
323
384
|
const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
|
|
385
|
+
if (r.toolUses.length) {
|
|
386
|
+
// A provider can fail after partially assembling tool calls. The assistant turn is already persisted;
|
|
387
|
+
// close every call explicitly so the next request is valid, while never executing partial work.
|
|
388
|
+
history.push({
|
|
389
|
+
role: "tool",
|
|
390
|
+
results: r.toolUses.map((toolUse) => ({
|
|
391
|
+
id: toolUse.id,
|
|
392
|
+
name: toolUse.name,
|
|
393
|
+
content: `Error: provider failed before this tool call could be executed. ${r.errorMsg ?? "unknown provider error"}`,
|
|
394
|
+
isError: true,
|
|
395
|
+
})),
|
|
396
|
+
});
|
|
397
|
+
}
|
|
324
398
|
if (!opts.quiet) {
|
|
325
399
|
if (sink)
|
|
326
400
|
sink.notice(msg);
|
|
327
401
|
else
|
|
328
402
|
out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
|
|
329
403
|
}
|
|
330
|
-
return;
|
|
404
|
+
return { status: "error", error: msg };
|
|
331
405
|
}
|
|
332
406
|
// Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
|
|
333
407
|
// completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
|
|
@@ -355,16 +429,37 @@ export async function runAgent(history, opts) {
|
|
|
355
429
|
else
|
|
356
430
|
out(c.dim(`${note}\n`));
|
|
357
431
|
}
|
|
358
|
-
return;
|
|
432
|
+
return { status: "empty" };
|
|
359
433
|
}
|
|
360
434
|
// A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
|
|
361
435
|
// rather than pushing an empty tool round and re-requesting in a loop.
|
|
362
436
|
if (r.stop !== "tool_use" || r.toolUses.length === 0)
|
|
363
|
-
return;
|
|
437
|
+
return { status: "completed" };
|
|
438
|
+
// Once an assistant tool_use turn enters history, every tool_use MUST receive a matching tool result.
|
|
439
|
+
// OpenAI/Anthropic both reject a later user turn after an unclosed tool round. Cancellation can happen
|
|
440
|
+
// while planning, approving, or executing, so finalize the round with real results for work that already
|
|
441
|
+
// completed and explicit interruption errors for everything else before persisting the session.
|
|
442
|
+
const results = new Array(r.toolUses.length);
|
|
443
|
+
const finalizeInterruptedToolRound = () => {
|
|
444
|
+
history.push({
|
|
445
|
+
role: "tool",
|
|
446
|
+
results: r.toolUses.map((tu, idx) => results[idx] ?? ({
|
|
447
|
+
id: tu.id,
|
|
448
|
+
name: tu.name,
|
|
449
|
+
content: "Error: interrupted before this tool call completed.",
|
|
450
|
+
isError: true,
|
|
451
|
+
})),
|
|
452
|
+
});
|
|
453
|
+
return interruptedOutcome();
|
|
454
|
+
};
|
|
455
|
+
if (opts.signal?.aborted)
|
|
456
|
+
return finalizeInterruptedToolRound();
|
|
364
457
|
const plans = [];
|
|
365
458
|
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
366
459
|
const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
|
|
367
460
|
for (const tu of r.toolUses) {
|
|
461
|
+
if (opts.signal?.aborted)
|
|
462
|
+
return finalizeInterruptedToolRound();
|
|
368
463
|
if (breakerHalt) {
|
|
369
464
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
370
465
|
// (no hang, no further tools) so the model + user get a definitive stop.
|
|
@@ -377,11 +472,21 @@ export async function runAgent(history, opts) {
|
|
|
377
472
|
continue;
|
|
378
473
|
}
|
|
379
474
|
const input = tu.input;
|
|
380
|
-
const preview = String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
|
|
475
|
+
const preview = redactToolSubprocessOutput(String(input.path ?? input.command ?? input.pattern ?? input.url ?? input.task ?? "")
|
|
381
476
|
.replace(/\s+/g, " ")
|
|
382
|
-
.trim();
|
|
383
|
-
// Screen control
|
|
384
|
-
|
|
477
|
+
.trim());
|
|
478
|
+
// Screen control and opaque host extensions are gated on EVERY action — a prior "don't ask again"
|
|
479
|
+
// and even full-auto must never silently turn them into a side channel.
|
|
480
|
+
const alwaysGate = tool.kind === "computer" || tool.trustBoundary === "external";
|
|
481
|
+
if (tool.trustBoundary === "external" && !ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
482
|
+
plans.push({
|
|
483
|
+
tu,
|
|
484
|
+
tool,
|
|
485
|
+
denied: "Trusted extension blocked in this non-interactive run. MCP and external coding agents run outside Hara's file boundary; " +
|
|
486
|
+
"restart with HARA_ALLOW_TRUSTED_EXTENSIONS=1 only after reviewing that extension.",
|
|
487
|
+
});
|
|
488
|
+
continue;
|
|
489
|
+
}
|
|
385
490
|
// Command-level policy for shell commands: a deny rule blocks even in full-auto; an allow rule (or a
|
|
386
491
|
// read-only command) auto-runs even in suggest mode. Composes with, doesn't replace, the approval mode.
|
|
387
492
|
const cmdDecision = tool.kind === "exec" && typeof input.command === "string" ? decideCommand(input.command, permRules) : null;
|
|
@@ -396,17 +501,20 @@ export async function runAgent(history, opts) {
|
|
|
396
501
|
if (guardianOn && !breakerHalt) {
|
|
397
502
|
const risk = classifyRisk(tu.name, tool.kind, input, ctx.cwd);
|
|
398
503
|
if (risk.level === "high") {
|
|
399
|
-
const
|
|
400
|
-
const
|
|
504
|
+
const safeRiskReason = redactToolSubprocessOutput(risk.reason);
|
|
505
|
+
const detail = redactToolSubprocessOutput(String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400));
|
|
506
|
+
const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: safeRiskReason }, history, { signal: opts.signal });
|
|
507
|
+
if (opts.signal?.aborted)
|
|
508
|
+
return finalizeInterruptedToolRound();
|
|
401
509
|
if (verdict.decision === "block") {
|
|
402
510
|
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
403
511
|
plans.push({
|
|
404
512
|
tu,
|
|
405
513
|
tool,
|
|
406
|
-
denied: `Guardian blocked this high-risk action: ${verdict.reason ||
|
|
514
|
+
denied: `Guardian blocked this high-risk action: ${verdict.reason || safeRiskReason}. Reconsider — take a safer, in-scope step, or ask the user before doing this.`,
|
|
407
515
|
});
|
|
408
516
|
if (!opts.quiet) {
|
|
409
|
-
const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason ||
|
|
517
|
+
const note = `⛔ guardian blocked ${tu.name} — ${verdict.reason || safeRiskReason}`;
|
|
410
518
|
if (sink)
|
|
411
519
|
sink.notice(note);
|
|
412
520
|
else
|
|
@@ -421,6 +529,8 @@ export async function runAgent(history, opts) {
|
|
|
421
529
|
const cont = interactive
|
|
422
530
|
? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
|
|
423
531
|
: false;
|
|
532
|
+
if (opts.signal?.aborted)
|
|
533
|
+
return finalizeInterruptedToolRound();
|
|
424
534
|
if (cont === false) {
|
|
425
535
|
breakerHalt = true;
|
|
426
536
|
}
|
|
@@ -433,8 +543,11 @@ export async function runAgent(history, opts) {
|
|
|
433
543
|
}
|
|
434
544
|
}
|
|
435
545
|
}
|
|
436
|
-
|
|
546
|
+
const shouldConfirm = alwaysGate || (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && !opts.autoApprove?.has(tu.name));
|
|
547
|
+
if (shouldConfirm) {
|
|
437
548
|
const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
|
|
549
|
+
if (opts.signal?.aborted)
|
|
550
|
+
return finalizeInterruptedToolRound();
|
|
438
551
|
if (reply === false) {
|
|
439
552
|
plans.push({ tu, tool, denied: "User denied this action." });
|
|
440
553
|
continue;
|
|
@@ -452,8 +565,11 @@ export async function runAgent(history, opts) {
|
|
|
452
565
|
}
|
|
453
566
|
}
|
|
454
567
|
// Execute: read-only tools run concurrently; edit/exec run alone, in order.
|
|
455
|
-
|
|
568
|
+
if (opts.signal?.aborted)
|
|
569
|
+
return finalizeInterruptedToolRound();
|
|
456
570
|
const runOne = async (idx, p) => {
|
|
571
|
+
if (opts.signal?.aborted)
|
|
572
|
+
return;
|
|
457
573
|
if (p.denied !== undefined) {
|
|
458
574
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
|
|
459
575
|
return;
|
|
@@ -467,28 +583,42 @@ export async function runAgent(history, opts) {
|
|
|
467
583
|
if (missing.length) {
|
|
468
584
|
const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
|
|
469
585
|
`${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
|
|
470
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
586
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
|
|
471
587
|
return;
|
|
472
588
|
}
|
|
473
|
-
|
|
589
|
+
if (opts.signal?.aborted)
|
|
590
|
+
return;
|
|
591
|
+
const pre = opts.hooks === false
|
|
592
|
+
? { block: false, message: "" }
|
|
593
|
+
: runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
|
|
474
594
|
if (pre.block) {
|
|
475
595
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
|
|
476
596
|
return;
|
|
477
597
|
}
|
|
598
|
+
if (opts.signal?.aborted)
|
|
599
|
+
return;
|
|
478
600
|
// Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
|
|
479
601
|
// sub-agents read broadly — their files aren't "what the user was working on").
|
|
480
602
|
if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
|
|
481
|
-
recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
|
|
603
|
+
recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
|
|
482
604
|
}
|
|
483
605
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
484
606
|
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
485
607
|
// + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
|
|
486
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
|
|
487
|
-
|
|
608
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res, false, ctx.todoScope) };
|
|
609
|
+
// The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
|
|
610
|
+
// actual result in the closing tool round, but do not run any post hook or later tool afterward.
|
|
611
|
+
if (opts.signal?.aborted)
|
|
612
|
+
return;
|
|
613
|
+
if (opts.hooks !== false) {
|
|
614
|
+
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
615
|
+
}
|
|
488
616
|
}
|
|
489
617
|
catch (e) {
|
|
490
618
|
const msg = `Error: ${e.message}`;
|
|
491
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
619
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
|
|
620
|
+
if (opts.signal?.aborted)
|
|
621
|
+
return;
|
|
492
622
|
}
|
|
493
623
|
finally {
|
|
494
624
|
activity.dec();
|
|
@@ -503,16 +633,24 @@ export async function runAgent(history, opts) {
|
|
|
503
633
|
await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
|
|
504
634
|
};
|
|
505
635
|
for (let i = 0; i < plans.length; i++) {
|
|
636
|
+
if (opts.signal?.aborted)
|
|
637
|
+
return finalizeInterruptedToolRound();
|
|
506
638
|
const p = plans[i];
|
|
507
639
|
if (p.denied === undefined && p.tool?.kind === "read") {
|
|
508
640
|
batch.push(i); // safe → accumulate to run concurrently
|
|
509
641
|
}
|
|
510
642
|
else {
|
|
511
643
|
await flush(); // flush pending reads before an edit/exec
|
|
644
|
+
if (opts.signal?.aborted)
|
|
645
|
+
return finalizeInterruptedToolRound();
|
|
512
646
|
await runOne(i, p);
|
|
647
|
+
if (opts.signal?.aborted)
|
|
648
|
+
return finalizeInterruptedToolRound();
|
|
513
649
|
}
|
|
514
650
|
}
|
|
515
651
|
await flush();
|
|
652
|
+
if (opts.signal?.aborted)
|
|
653
|
+
return finalizeInterruptedToolRound();
|
|
516
654
|
history.push({ role: "tool", results });
|
|
517
655
|
// Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
|
|
518
656
|
// produced N independent reports — remind the model to merge/reconcile them before acting, instead
|
|
@@ -520,7 +658,7 @@ export async function runAgent(history, opts) {
|
|
|
520
658
|
if (!opts.quiet) {
|
|
521
659
|
const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
|
|
522
660
|
if (fanout >= SYNTHESIS_MIN_AGENTS)
|
|
523
|
-
pushReminder(synthesisReminder(fanout));
|
|
661
|
+
pushReminder(synthesisReminder(fanout), ctx.todoScope);
|
|
524
662
|
}
|
|
525
663
|
// Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
|
|
526
664
|
// unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
|
|
@@ -529,10 +667,10 @@ export async function runAgent(history, opts) {
|
|
|
529
667
|
if (r.toolUses.some((tu) => tu.name === "todo_write")) {
|
|
530
668
|
todoIdleRounds = 0;
|
|
531
669
|
}
|
|
532
|
-
else if (currentTodos().some((t) => t.status !== "done")) {
|
|
670
|
+
else if (currentTodos(ctx.todoScope).some((t) => t.status !== "done")) {
|
|
533
671
|
todoIdleRounds++;
|
|
534
672
|
if (todoIdleRounds >= TODO_STALE_ROUNDS) {
|
|
535
|
-
pushReminder(todoStaleReminder(renderTodos(currentTodos())));
|
|
673
|
+
pushReminder(todoStaleReminder(renderTodos(currentTodos(ctx.todoScope))), ctx.todoScope);
|
|
536
674
|
todoIdleRounds = 0;
|
|
537
675
|
}
|
|
538
676
|
}
|
|
@@ -547,7 +685,7 @@ export async function runAgent(history, opts) {
|
|
|
547
685
|
else
|
|
548
686
|
out(c.red(`${note}\n`));
|
|
549
687
|
}
|
|
550
|
-
return;
|
|
688
|
+
return { status: "halted" };
|
|
551
689
|
}
|
|
552
690
|
if (guard && !nudged) {
|
|
553
691
|
for (const p of plans)
|
package/dist/agent/reminders.js
CHANGED
|
@@ -6,19 +6,34 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
|
|
8
8
|
// injected nudge never derails an unrelated task.
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const queues = new Map();
|
|
11
|
+
function scopeKey(scope) {
|
|
12
|
+
return scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
}
|
|
10
14
|
/** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
|
|
11
15
|
* neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
|
|
12
|
-
export function pushReminder(text) {
|
|
16
|
+
export function pushReminder(text, scope) {
|
|
13
17
|
const t = text.trim();
|
|
14
|
-
if (t)
|
|
15
|
-
|
|
18
|
+
if (!t)
|
|
19
|
+
return;
|
|
20
|
+
const key = scopeKey(scope);
|
|
21
|
+
const queue = queues.get(key) ?? [];
|
|
22
|
+
queue.push(t);
|
|
23
|
+
queues.set(key, queue);
|
|
16
24
|
}
|
|
17
25
|
/** Take everything queued (FIFO), clearing the queue. */
|
|
18
|
-
export function drainReminders() {
|
|
19
|
-
|
|
26
|
+
export function drainReminders(scope) {
|
|
27
|
+
const key = scopeKey(scope);
|
|
28
|
+
const queue = queues.get(key);
|
|
29
|
+
if (!queue?.length)
|
|
20
30
|
return [];
|
|
21
|
-
|
|
31
|
+
queues.delete(key);
|
|
32
|
+
return queue;
|
|
33
|
+
}
|
|
34
|
+
/** Drop an ephemeral session's pending reminders without exposing them to another run. */
|
|
35
|
+
export function disposeReminderScope(scope) {
|
|
36
|
+
queues.delete(scopeKey(scope));
|
|
22
37
|
}
|
|
23
38
|
/** Merge queued reminders into the single injected message. */
|
|
24
39
|
export function wrapReminders(items) {
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
// covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
|
|
5
5
|
// when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
|
|
6
6
|
// repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
|
|
7
|
-
// or a re-run after a fix is legitimate, and a success resets the failure streak.
|
|
8
|
-
|
|
7
|
+
// or a re-run after a fix is legitimate, and a success resets the failure streak. Serve can run several
|
|
8
|
+
// sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const seenByScope = new Map();
|
|
11
|
+
function scopedSeen(scope) {
|
|
12
|
+
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
const seen = seenByScope.get(key) ?? new Map();
|
|
14
|
+
seenByScope.set(key, seen);
|
|
15
|
+
return seen;
|
|
16
|
+
}
|
|
9
17
|
/** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
|
|
10
18
|
* so a space separator is unambiguous). */
|
|
11
19
|
export function keyOf(name, input) {
|
|
@@ -24,12 +32,20 @@ export function looksFailed(content) {
|
|
|
24
32
|
}
|
|
25
33
|
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
26
34
|
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
27
|
-
export function recordCall(name, input, content, isError = false) {
|
|
35
|
+
export function recordCall(name, input, content, isError = false, scope) {
|
|
28
36
|
const k = keyOf(name, input);
|
|
29
37
|
const failed = isError || looksFailed(content);
|
|
38
|
+
const seen = scopedSeen(scope);
|
|
30
39
|
const s = seen.get(k) ?? { fails: 0 };
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
if (!failed) {
|
|
41
|
+
seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
s.fails++;
|
|
45
|
+
seen.delete(k);
|
|
46
|
+
seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
|
|
47
|
+
if (seen.size > 500)
|
|
48
|
+
seen.delete(seen.keys().next().value);
|
|
33
49
|
if (s.fails < 2)
|
|
34
50
|
return "";
|
|
35
51
|
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
|
@@ -37,6 +53,9 @@ export function recordCall(name, input, content, isError = false) {
|
|
|
37
53
|
`or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
|
|
38
54
|
}
|
|
39
55
|
/** Clear the streaks — /reset (fresh start) and tests. */
|
|
40
|
-
export function resetRepeatGuard() {
|
|
41
|
-
|
|
56
|
+
export function resetRepeatGuard(scope) {
|
|
57
|
+
if (scope)
|
|
58
|
+
seenByScope.delete(scope.trim() || DEFAULT_SCOPE);
|
|
59
|
+
else
|
|
60
|
+
seenByScope.clear();
|
|
42
61
|
}
|