@nanhara/hara 0.89.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
// Guardian — an internal safety layer that sits ON TOP of the existing stack (permission rules →
|
|
2
|
+
// PreToolUse hooks → approval gate → soft stuck-guard). It exists for the one failure mode those don't
|
|
3
|
+
// cover: a genuinely dangerous, irreversible action that slips through (no deny rule, auto-approved mode,
|
|
4
|
+
// a cron/gateway run with `confirm: () => true`). Two moving parts:
|
|
5
|
+
//
|
|
6
|
+
// 1. A DETERMINISTIC risk classifier that runs FIRST and is the whole point of "zero latency on normal
|
|
7
|
+
// work": read tools, in-project edits, and ordinary shell commands are classified `low` in pure Node
|
|
8
|
+
// (no LLM, no I/O) and skip the guardian entirely. Only a handful of genuinely destructive shapes
|
|
9
|
+
// (rm -rf, dd, mkfs, curl|sh, sudo, force-push, broad chmod -R, writes outside the project root, …)
|
|
10
|
+
// are classified `high` and escalate.
|
|
11
|
+
// 2. A conservative LLM veto that runs ONLY on `high` actions: it asks a cheap model "is this action
|
|
12
|
+
// clearly dangerous or clearly off-task?" and defaults to ALLOW. Any glitch (timeout, error, no model
|
|
13
|
+
// configured) FAILS OPEN — a guardian hiccup must never break legitimate work, because the permission
|
|
14
|
+
// rules + the user's own approval gate still apply independently.
|
|
15
|
+
//
|
|
16
|
+
// Plus a deterministic circuit-breaker: N guardian BLOCKS in one run (or an escalating-destructive runaway)
|
|
17
|
+
// trips a hard stop that requires explicit user confirmation to continue — a harder stop than the soft
|
|
18
|
+
// stuck-guard nudge, and one that aborts safely (never hangs) when there's no interactive user.
|
|
19
|
+
import { resolve, isAbsolute } from "node:path";
|
|
20
|
+
import { canonicalize, splitCompound } from "./permissions.js";
|
|
21
|
+
// ── Deterministic risk classifier ────────────────────────────────────────────────────────────────────
|
|
22
|
+
// The classifier is intentionally NARROW: false positives here cost latency + an LLM call + potential
|
|
23
|
+
// annoyance, so we only flag shapes that are destructive/irreversible in practice. Everything else is `low`.
|
|
24
|
+
// A path is "broad" (a whole tree / outside the sandbox) when it's `/`, a top-level system dir, a home dir,
|
|
25
|
+
// `.`/`*`/`~`, or absolute-outside-cwd. `chmod -R`/`chown -R`/`rm -rf` on a broad path is the danger, not on
|
|
26
|
+
// a project-local subdir.
|
|
27
|
+
const SYSTEM_ROOTS = /^(\/|\/(bin|boot|dev|etc|home|lib|opt|proc|root|sbin|srv|sys|usr|var|Users|Applications|System|Library|Volumes)(\/|$)|~|\$HOME)/;
|
|
28
|
+
/** Genuinely destructive/irreversible single-command shapes (checked against the canonical form of each
|
|
29
|
+
* part of a compound command). Kept tight — see the module header. */
|
|
30
|
+
function isDestructiveCommand(canonical) {
|
|
31
|
+
const c = canonical;
|
|
32
|
+
if (!c)
|
|
33
|
+
return false;
|
|
34
|
+
// rm -rf / rm -fr / recursive-forced removal (the classic).
|
|
35
|
+
if (/\brm\s+(-[a-z]*r[a-z]*f|-[a-z]*f[a-z]*r|-r\s+-f|-f\s+-r|--recursive\s+--force|--force\s+--recursive)\b/.test(c))
|
|
36
|
+
return true;
|
|
37
|
+
// rm -r/-rf targeting a broad/system path (rm -rf / , rm -rf ~ , rm -r /etc …).
|
|
38
|
+
if (/\brm\s+-[a-z]*r/.test(c) && /\brm\s+-[a-z]*r[a-z]*\s+(\/|~|\$HOME|\*|\.\s|\.$)/.test(c))
|
|
39
|
+
return true;
|
|
40
|
+
// Raw-disk / filesystem-destroying tools.
|
|
41
|
+
if (/\b(dd)\b/.test(c) && /\bof=\/dev\//.test(c))
|
|
42
|
+
return true;
|
|
43
|
+
if (/\b(mkfs(\.\w+)?|mke2fs|fdisk|parted|wipefs|shred)\b/.test(c))
|
|
44
|
+
return true;
|
|
45
|
+
if (/\bdiskutil\s+(erase|reformat|partitionDisk)/i.test(c))
|
|
46
|
+
return true;
|
|
47
|
+
// Fork bomb.
|
|
48
|
+
if (/:\s*\(\s*\)\s*\{\s*:\s*\|\s*:\s*&\s*\}\s*;\s*:/.test(c) || /\.\(\)\{\s*\.\|\.&\s*\}/.test(c))
|
|
49
|
+
return true;
|
|
50
|
+
// Pipe-to-shell from the network (curl … | sh, wget … | bash) — arbitrary remote code execution.
|
|
51
|
+
if (/\b(curl|wget|fetch)\b[^|]*\|\s*(sudo\s+)?(ba|z)?sh\b/.test(c))
|
|
52
|
+
return true;
|
|
53
|
+
// Privilege escalation.
|
|
54
|
+
if (/\bsudo\b/.test(c))
|
|
55
|
+
return true;
|
|
56
|
+
if (/\bkillall\b/.test(c))
|
|
57
|
+
return true;
|
|
58
|
+
// Force-push (rewrites shared history irreversibly).
|
|
59
|
+
if (/\bgit\s+push\b[^\n]*(--force\b|--force-with-lease\b|(^|\s)-f\b)/.test(c))
|
|
60
|
+
return true;
|
|
61
|
+
// Broad recursive permission/ownership change.
|
|
62
|
+
if (/\b(chmod|chown|chgrp)\s+(-[a-z]*R|--recursive)/.test(c)) {
|
|
63
|
+
if (SYSTEM_ROOTS.test(commandTarget(c)) || /\s(\/|~|\$HOME|\*|\.)(\s|$)/.test(c))
|
|
64
|
+
return true;
|
|
65
|
+
}
|
|
66
|
+
// Destructive git tree resets / clean.
|
|
67
|
+
if (/\bgit\s+clean\b[^\n]*-[a-z]*f/.test(c) && /-[a-z]*d/.test(c))
|
|
68
|
+
return true;
|
|
69
|
+
// History overwrite: `> /path` (truncation) onto a system/absolute-outside path is handled by the
|
|
70
|
+
// out-of-project write check below (redirection carries a path); nothing extra here.
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
/** The last whitespace token that looks like a path target of a command (best-effort, for chmod/chown). */
|
|
74
|
+
function commandTarget(canonical) {
|
|
75
|
+
const toks = canonical.split(/\s+/).filter((t) => t && !t.startsWith("-"));
|
|
76
|
+
return toks[toks.length - 1] ?? "";
|
|
77
|
+
}
|
|
78
|
+
/** Extract `> file` / `>> file` redirection targets from a canonical command (quote-naive; classifier only). */
|
|
79
|
+
function redirectionTargets(canonical) {
|
|
80
|
+
const out = [];
|
|
81
|
+
const re = />>?\s*("([^"]*)"|'([^']*)'|([^\s;|&]+))/g;
|
|
82
|
+
let m;
|
|
83
|
+
while ((m = re.exec(canonical))) {
|
|
84
|
+
const p = m[2] ?? m[3] ?? m[4];
|
|
85
|
+
if (p && p !== "/dev/null" && !p.startsWith("&") && !/^\d$/.test(p))
|
|
86
|
+
out.push(p);
|
|
87
|
+
}
|
|
88
|
+
return out;
|
|
89
|
+
}
|
|
90
|
+
/** True if `p` resolves OUTSIDE the project/cwd root (a write/delete escaping the sandbox). Non-file
|
|
91
|
+
* pseudo-paths (/dev/null) and empties are treated as in-scope. */
|
|
92
|
+
export function isOutsideRoot(p, cwd) {
|
|
93
|
+
if (!p || p === "/dev/null" || p === "-")
|
|
94
|
+
return false;
|
|
95
|
+
const expanded = p.replace(/^~(?=\/|$)/, process.env.HOME ?? "~");
|
|
96
|
+
const abs = isAbsolute(expanded) ? resolve(expanded) : resolve(cwd, expanded);
|
|
97
|
+
const root = resolve(cwd);
|
|
98
|
+
return abs !== root && !abs.startsWith(root + "/");
|
|
99
|
+
}
|
|
100
|
+
/** Collect the file paths an edit-kind tool would write/delete, from its tool input. */
|
|
101
|
+
export function editPaths(name, input) {
|
|
102
|
+
const paths = [];
|
|
103
|
+
if (typeof input.path === "string")
|
|
104
|
+
paths.push(input.path);
|
|
105
|
+
// apply_patch: { changes: [{ path, ... }] }
|
|
106
|
+
if (Array.isArray(input.changes)) {
|
|
107
|
+
for (const ch of input.changes)
|
|
108
|
+
if (ch && typeof ch.path === "string")
|
|
109
|
+
paths.push(ch.path);
|
|
110
|
+
}
|
|
111
|
+
return paths;
|
|
112
|
+
}
|
|
113
|
+
/** Deterministic risk classifier. Runs FIRST, in pure Node — this is what keeps normal work at zero cost.
|
|
114
|
+
* Returns `low` for read tools, in-project edits, and ordinary shell commands (→ guardian is skipped
|
|
115
|
+
* entirely); `high` only for destructive/irreversible bash or writes/deletes outside the project root. */
|
|
116
|
+
export function classifyRisk(toolName, toolKind, input, cwd) {
|
|
117
|
+
if (toolKind === "read" || !input)
|
|
118
|
+
return { level: "low", reason: "" };
|
|
119
|
+
if (toolKind === "edit") {
|
|
120
|
+
for (const p of editPaths(toolName, input)) {
|
|
121
|
+
if (isOutsideRoot(p, cwd))
|
|
122
|
+
return { level: "high", reason: `writes/deletes outside the project root: ${p}` };
|
|
123
|
+
}
|
|
124
|
+
return { level: "low", reason: "" };
|
|
125
|
+
}
|
|
126
|
+
if (toolKind === "exec" && typeof input.command === "string") {
|
|
127
|
+
const command = input.command;
|
|
128
|
+
const whole = canonicalize(command);
|
|
129
|
+
// Whole-command check FIRST — catches cross-part shapes a compound split would hide (curl … | sh, where
|
|
130
|
+
// the danger is the pipe connecting two individually-benign parts).
|
|
131
|
+
if (isDestructiveCommand(whole))
|
|
132
|
+
return { level: "high", reason: `destructive/irreversible command: ${whole.slice(0, 120)}` };
|
|
133
|
+
// Then per-part: strictest part wins; if we can't safely parse it, fall back to the whole canonical form.
|
|
134
|
+
const parts = splitCompound(command) ?? [whole];
|
|
135
|
+
for (const part of parts) {
|
|
136
|
+
if (isDestructiveCommand(part))
|
|
137
|
+
return { level: "high", reason: `destructive/irreversible command: ${part.slice(0, 120)}` };
|
|
138
|
+
for (const t of redirectionTargets(part)) {
|
|
139
|
+
if (isOutsideRoot(t, cwd))
|
|
140
|
+
return { level: "high", reason: `writes outside the project root via redirection: ${t}` };
|
|
141
|
+
if (SYSTEM_ROOTS.test(t))
|
|
142
|
+
return { level: "high", reason: `writes to a system path: ${t}` };
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { level: "low", reason: "" };
|
|
146
|
+
}
|
|
147
|
+
// computer / unknown non-read kinds are already gated hard elsewhere (always-confirm); leave them to that.
|
|
148
|
+
return { level: "low", reason: "" };
|
|
149
|
+
}
|
|
150
|
+
// ── LLM veto (conservative; high-risk only) ──────────────────────────────────────────────────────────
|
|
151
|
+
const GUARDIAN_SYSTEM = `You are a safety guardian for a coding agent. You are shown a SINGLE high-risk action the agent is about ` +
|
|
152
|
+
`to take, plus a short summary of the user's task. Decide whether to ALLOW or BLOCK it. Default to ALLOW. ` +
|
|
153
|
+
`Only BLOCK when the action is CLEARLY dangerous/irreversible AND not plausibly part of the stated task ` +
|
|
154
|
+
`(e.g. deleting unrelated files, wiping a disk, exfiltrating data, force-pushing over shared history when ` +
|
|
155
|
+
`the task never asked for it), or when it is CLEARLY misaligned with the task. If the action is a ` +
|
|
156
|
+
`reasonable step toward the task — even a destructive one the user likely wants — ALLOW it. When unsure, ` +
|
|
157
|
+
`ALLOW. Reply with ONLY compact JSON: {"decision":"allow"|"block","reason":"<one short sentence>"}.`;
|
|
158
|
+
/** Parse the model's reply into a verdict. Anything we can't confidently read as a BLOCK is treated as
|
|
159
|
+
* ALLOW (fail-open on ambiguity). */
|
|
160
|
+
export function parseVerdict(text) {
|
|
161
|
+
const raw = (text ?? "").trim();
|
|
162
|
+
// Try strict JSON first, then the first {...} blob.
|
|
163
|
+
const tryParse = (s) => {
|
|
164
|
+
try {
|
|
165
|
+
const j = JSON.parse(s);
|
|
166
|
+
if (j && (j.decision === "block" || j.decision === "allow")) {
|
|
167
|
+
return { decision: j.decision, reason: typeof j.reason === "string" ? j.reason : "" };
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
catch {
|
|
171
|
+
/* fall through */
|
|
172
|
+
}
|
|
173
|
+
return null;
|
|
174
|
+
};
|
|
175
|
+
const direct = tryParse(raw);
|
|
176
|
+
if (direct)
|
|
177
|
+
return direct;
|
|
178
|
+
const m = /\{[\s\S]*\}/.exec(raw);
|
|
179
|
+
if (m) {
|
|
180
|
+
const blob = tryParse(m[0]);
|
|
181
|
+
if (blob)
|
|
182
|
+
return blob;
|
|
183
|
+
}
|
|
184
|
+
// No parseable verdict → fail open.
|
|
185
|
+
return { decision: "allow", reason: "" };
|
|
186
|
+
}
|
|
187
|
+
/** A brief, safe task-context summary for the guardian prompt: the most recent genuine user message,
|
|
188
|
+
* truncated. Kept short (cheap call, and we don't want to leak the whole transcript). */
|
|
189
|
+
export function taskSummary(history) {
|
|
190
|
+
for (let i = history.length - 1; i >= 0; i--) {
|
|
191
|
+
const m = history[i];
|
|
192
|
+
if (m.role === "user" && typeof m.content === "string" && m.content.trim()) {
|
|
193
|
+
return m.content.trim().replace(/\s+/g, " ").slice(0, 500);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return "(no task context available)";
|
|
197
|
+
}
|
|
198
|
+
const DEFAULT_TIMEOUT_MS = 6000;
|
|
199
|
+
/** Ask the cheap model to veto a single high-risk action. Conservative + fail-open:
|
|
200
|
+
* - no provider → allow (guardian effectively off; deterministic layers still apply)
|
|
201
|
+
* - timeout / error → allow (a guardian glitch must never break legit work)
|
|
202
|
+
* - unparseable reply → allow
|
|
203
|
+
* Only a clean, parsed `block` blocks. */
|
|
204
|
+
export async function guardianVeto(provider, action, history, opts = {}) {
|
|
205
|
+
if (!provider)
|
|
206
|
+
return { decision: "allow", reason: "" }; // fail-open: no model → deterministic layers still guard
|
|
207
|
+
const prompt = `TASK CONTEXT:\n${taskSummary(history)}\n\n` +
|
|
208
|
+
`HIGH-RISK ACTION (flagged: ${action.classifierReason}):\n` +
|
|
209
|
+
`tool: ${action.tool}\n${action.detail}\n\n` +
|
|
210
|
+
`Allow or block this action? Reply with only the JSON verdict.`;
|
|
211
|
+
const ac = new AbortController();
|
|
212
|
+
const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
213
|
+
// Chain the caller's interrupt signal so Esc also aborts the guardian call.
|
|
214
|
+
if (opts.signal) {
|
|
215
|
+
if (opts.signal.aborted)
|
|
216
|
+
ac.abort();
|
|
217
|
+
else
|
|
218
|
+
opts.signal.addEventListener("abort", () => ac.abort(), { once: true });
|
|
219
|
+
}
|
|
220
|
+
try {
|
|
221
|
+
const r = await provider.turn({
|
|
222
|
+
system: GUARDIAN_SYSTEM,
|
|
223
|
+
history: [{ role: "user", content: prompt }],
|
|
224
|
+
tools: [],
|
|
225
|
+
onText: () => { },
|
|
226
|
+
signal: ac.signal,
|
|
227
|
+
});
|
|
228
|
+
if (r.stop === "error")
|
|
229
|
+
return { decision: "allow", reason: "" }; // fail-open on model error
|
|
230
|
+
return parseVerdict(r.text);
|
|
231
|
+
}
|
|
232
|
+
catch {
|
|
233
|
+
return { decision: "allow", reason: "" }; // fail-open on timeout/abort/throw
|
|
234
|
+
}
|
|
235
|
+
finally {
|
|
236
|
+
clearTimeout(timer);
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
export function newBreaker() {
|
|
240
|
+
return { blocks: 0, tripped: false };
|
|
241
|
+
}
|
|
242
|
+
/** Record a guardian BLOCK; trip the breaker at the threshold. Returns the (possibly updated) state's
|
|
243
|
+
* tripped flag. Deterministic — a harder stop than the soft stuck-guard nudge. */
|
|
244
|
+
export function recordBlock(state, threshold = GUARDIAN_BLOCK_THRESHOLD) {
|
|
245
|
+
state.blocks += 1;
|
|
246
|
+
if (state.blocks >= threshold)
|
|
247
|
+
state.tripped = true;
|
|
248
|
+
return state.tripped;
|
|
249
|
+
}
|
|
250
|
+
export const GUARDIAN_BLOCK_THRESHOLD = 3;
|
|
251
|
+
// ── Config ─────────────────────────────────────────────────────────────────────────────────────────────
|
|
252
|
+
/** Guardian is ON by default and only engages on high-risk actions (so normal turns are untouched).
|
|
253
|
+
* Off via `HARA_GUARDIAN=0` (or config key `guardian: "off"`). Any other value / unset → on. */
|
|
254
|
+
export function guardianEnabled(config) {
|
|
255
|
+
const env = process.env.HARA_GUARDIAN;
|
|
256
|
+
if (env !== undefined)
|
|
257
|
+
return !(env === "0" || env.toLowerCase() === "off" || env.toLowerCase() === "false");
|
|
258
|
+
if (config?.guardian !== undefined)
|
|
259
|
+
return !(config.guardian === "off" || config.guardian === "false");
|
|
260
|
+
return true; // default on
|
|
261
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Session-level model selection — small module so the four role-provider call sites in index.ts
|
|
2
|
+
// (runOrg / executeAtom / review-chain / runSubagent) can consult "is the user forcing all roles
|
|
3
|
+
// to the session model?" without importing index.ts. The state is plain process-local, reset
|
|
4
|
+
// between processes (we never persist `--force` across runs — only the session-pinned model).
|
|
5
|
+
//
|
|
6
|
+
// Priority chain (highest first):
|
|
7
|
+
// /model X --force (this session, all roles) ← sessionForceModel === true
|
|
8
|
+
// /model X (this session, defaults respect role.model)
|
|
9
|
+
// --model X (CLI flag, written into meta.model)
|
|
10
|
+
// meta.model (resume; restored into cfg.model at startup)
|
|
11
|
+
// role.model (org role frontmatter — respected by default)
|
|
12
|
+
// profile.model / defaultModel / provider default
|
|
13
|
+
let _forced = false;
|
|
14
|
+
/** Switch on/off "force all roles to use the session model" for this process. */
|
|
15
|
+
export function setSessionForceModel(on) {
|
|
16
|
+
_forced = !!on;
|
|
17
|
+
}
|
|
18
|
+
/** Is the session-force flag on? */
|
|
19
|
+
export function isSessionForceModel() {
|
|
20
|
+
return _forced;
|
|
21
|
+
}
|
|
22
|
+
/** Resolve the model a role should run under given current force state.
|
|
23
|
+
* - default (no --force): role's frontmatter model wins if set, else fall back to session model.
|
|
24
|
+
* - with --force: session model wins, role.model is ignored.
|
|
25
|
+
* Returns undefined when there's no override needed (role.model is unset OR equals cfg.model OR
|
|
26
|
+
* force is on AND we should rebuild with cfg.model only when role.model would otherwise have
|
|
27
|
+
* switched providers — i.e. callers should still gate "rebuild provider only if different"). */
|
|
28
|
+
export function effectiveRoleModel(roleModel, sessionModel) {
|
|
29
|
+
if (_forced)
|
|
30
|
+
return undefined; // force → use the session/base provider, ignore role.model entirely
|
|
31
|
+
if (!roleModel)
|
|
32
|
+
return undefined;
|
|
33
|
+
if (roleModel === sessionModel)
|
|
34
|
+
return undefined;
|
|
35
|
+
return roleModel;
|
|
36
|
+
}
|
package/dist/statusbar.js
CHANGED
|
@@ -21,11 +21,20 @@ export function contextWindow(model) {
|
|
|
21
21
|
return 200_000;
|
|
22
22
|
}
|
|
23
23
|
export const ctxPctFor = (model, lastInput) => lastInput > 0 ? Math.min(99, Math.round((lastInput / contextWindow(model)) * 100)) : 0;
|
|
24
|
-
/** Top border with the session name in the right corner:
|
|
24
|
+
/** Top border with the session name in the right corner: `────── [profile] ⏺ session ─`.
|
|
25
|
+
* The profile chip on the left flank surfaces "which identity am I as right now". gateway
|
|
26
|
+
* profiles get a cyan ORG badge (drawing the eye — your traffic is going somewhere else),
|
|
27
|
+
* personal/byok profiles get a dim badge so they fade into the background. */
|
|
25
28
|
export function borderTop(s, cols) {
|
|
26
29
|
const name = truncate(s.sessionName || "new session", Math.max(8, cols - 14));
|
|
27
|
-
const
|
|
28
|
-
|
|
30
|
+
const right = `${c.cyan("⏺")} ${c.bold(name)}`;
|
|
31
|
+
const chip = s.profileId
|
|
32
|
+
? (s.profileKind === "gateway" ? c.cyan(`[${s.profileId} · ORG]`) : c.dim(`[${s.profileId}]`))
|
|
33
|
+
: "";
|
|
34
|
+
const left = chip ? chip + " " : "";
|
|
35
|
+
const innerLen = vlen(left) + vlen(right);
|
|
36
|
+
const pad = Math.max(0, cols - innerLen - 4);
|
|
37
|
+
return rule(1) + " " + left + rule(pad) + " " + right + " " + rule(1);
|
|
29
38
|
}
|
|
30
39
|
/** Bottom border carrying mode · tokens · concurrency: `── ◆suggest auto-edit full-auto · ↑0 ↓0 · ⛁ ──` */
|
|
31
40
|
export function borderBottom(s, cols, agents) {
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// ask_user — pause mid-turn to ask the user a structured question and continue with their answer.
|
|
2
|
+
// Mirrors Claude Code's AskUserQuestion / cc-haha + hermes `ask_user`: use it ONLY when genuinely blocked
|
|
3
|
+
// on a decision only the user can make (an ambiguous requirement, a real fork in approach) — never for
|
|
4
|
+
// anything you can derive from the code/context. The question (and optional numbered choices) is shown
|
|
5
|
+
// through the SAME input channel as the approval prompt (ctx.ask), so it works in both the classic REPL and
|
|
6
|
+
// the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
|
|
7
|
+
// tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
|
|
8
|
+
// itself triggers the approval gate (the interaction IS the prompt).
|
|
9
|
+
import { registerTool } from "./registry.js";
|
|
10
|
+
/** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
|
|
11
|
+
* keeps going on its own judgment rather than re-asking or stalling. */
|
|
12
|
+
export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
|
|
13
|
+
registerTool({
|
|
14
|
+
name: "ask_user",
|
|
15
|
+
description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
|
|
16
|
+
"Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
|
|
17
|
+
"requirement, a missing preference, or a real fork in approach where guessing wrong is costly. " +
|
|
18
|
+
"Do NOT use it for anything you can infer from the code, files, or context, and do NOT use it to narrate " +
|
|
19
|
+
"or ask permission for an action (the approval gate already handles that). " +
|
|
20
|
+
"Provide `options` (a short list of likely answers) when the choice is constrained — they are shown as a " +
|
|
21
|
+
"numbered menu — but the user may always type a free-text answer instead. The tool returns the user's " +
|
|
22
|
+
"answer (chosen option or free text) as its result. " +
|
|
23
|
+
"In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
|
|
24
|
+
"blocking, so prefer making a reasonable call over asking when context already answers the question.",
|
|
25
|
+
kind: "read", // the prompt itself is the interaction; never route it through the approval gate
|
|
26
|
+
input_schema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
question: { type: "string", description: "the single, specific question to put to the user" },
|
|
30
|
+
options: {
|
|
31
|
+
type: "array",
|
|
32
|
+
items: { type: "string" },
|
|
33
|
+
description: "optional likely answers, shown as a numbered menu (the user may still type their own answer)",
|
|
34
|
+
},
|
|
35
|
+
header: { type: "string", description: "optional short label/topic for the question (e.g. 'Database choice')" },
|
|
36
|
+
context: { type: "string", description: "optional one-line context shown before the question (keep it short)" },
|
|
37
|
+
},
|
|
38
|
+
required: ["question"],
|
|
39
|
+
},
|
|
40
|
+
async run(input, ctx) {
|
|
41
|
+
const question = typeof input.question === "string" ? input.question.trim() : "";
|
|
42
|
+
if (!question)
|
|
43
|
+
return "ask_user needs a non-empty `question`.";
|
|
44
|
+
// No interactive user (headless / non-TTY / -p / gateway / sub-agent): do NOT block — let the model proceed.
|
|
45
|
+
if (typeof ctx.ask !== "function")
|
|
46
|
+
return NO_INTERACTIVE_USER;
|
|
47
|
+
const options = Array.isArray(input.options)
|
|
48
|
+
? input.options.map((o) => String(o ?? "").trim()).filter((o) => o.length > 0)
|
|
49
|
+
: undefined;
|
|
50
|
+
const header = typeof input.header === "string" ? input.header.trim() : "";
|
|
51
|
+
const context = typeof input.context === "string" ? input.context.trim() : "";
|
|
52
|
+
// Compose a compact prompt: [header] (context) question — the channel renders it.
|
|
53
|
+
const prompt = [header ? `[${header}] ` : "", context ? `${context}\n` : "", question].join("");
|
|
54
|
+
try {
|
|
55
|
+
const answer = await ctx.ask(prompt, options && options.length ? options : undefined);
|
|
56
|
+
const text = typeof answer === "string" ? answer.trim() : "";
|
|
57
|
+
return text || "(the user gave an empty answer)";
|
|
58
|
+
}
|
|
59
|
+
catch (e) {
|
|
60
|
+
// If the interactive prompt fails for any reason, degrade gracefully rather than crash the turn.
|
|
61
|
+
return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
|
|
62
|
+
}
|
|
63
|
+
},
|
|
64
|
+
});
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// external_agent — delegate a self-contained task to an EXTERNAL coding-agent CLI (claude-code / codex / …)
|
|
2
|
+
// running headless on the host, and return its final text. Inspired by openclaw's ACP subagent feature, but
|
|
3
|
+
// zero-dep: we drive each agent's native headless flag (`claude -p`, `codex exec`) over node:child_process
|
|
4
|
+
// instead of the heavy ACP/acpx stack.
|
|
5
|
+
//
|
|
6
|
+
// Safety: kind:"exec" → inherits the loop's approval gate. It can read/write/run on the host, so it is the most
|
|
7
|
+
// privileged tool — and because fan-out sub-agents only get the read-only allow-list (READONLY_TOOLS), they
|
|
8
|
+
// never see this tool. Trust tiers (off|gated|full) gate the dangerous bypass/full-access sub-modes.
|
|
9
|
+
import { spawn, execFileSync } from "node:child_process";
|
|
10
|
+
import { registerTool } from "./registry.js";
|
|
11
|
+
import { capHeadTail } from "./builtin.js";
|
|
12
|
+
import { loadConfig } from "../config.js";
|
|
13
|
+
/** Pure: build the spawn (cmd, args) for a backend, or null if unknown. Maps hara's sandbox/trust → the agent's
|
|
14
|
+
* own permission flags; the dangerous bypass/full-access modes are only reachable at trust "full". */
|
|
15
|
+
export function buildExternalArgv(backend, task, o) {
|
|
16
|
+
if (backend === "claude") {
|
|
17
|
+
const mode = o.trust === "full" ? "bypassPermissions" : o.sandbox === "workspace-write" ? "acceptEdits" : "plan";
|
|
18
|
+
return { cmd: "claude", args: ["-p", task, "--output-format", "text", ...(o.model ? ["--model", o.model] : []), "--permission-mode", mode] };
|
|
19
|
+
}
|
|
20
|
+
if (backend === "codex") {
|
|
21
|
+
const sb = o.trust === "full" ? "danger-full-access" : o.sandbox === "workspace-write" ? "workspace-write" : "read-only";
|
|
22
|
+
return { cmd: "codex", args: ["exec", task, "--cd", o.cwd, ...(o.model ? ["-m", o.model] : []), "--sandbox", sb] };
|
|
23
|
+
}
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
const BUILTIN_BACKENDS = ["claude", "codex"];
|
|
27
|
+
/** Probe a CLI's availability via `<bin> --version` (cached per process). */
|
|
28
|
+
const availCache = new Map();
|
|
29
|
+
function available(bin) {
|
|
30
|
+
if (availCache.has(bin))
|
|
31
|
+
return availCache.get(bin);
|
|
32
|
+
let ok = false;
|
|
33
|
+
try {
|
|
34
|
+
execFileSync(bin, ["--version"], { stdio: "ignore", timeout: 5000 });
|
|
35
|
+
ok = true;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
ok = false;
|
|
39
|
+
}
|
|
40
|
+
availCache.set(bin, ok);
|
|
41
|
+
return ok;
|
|
42
|
+
}
|
|
43
|
+
function resolveTrust() {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
const v = (process.env.HARA_EXTERNAL_AGENT_TRUST ?? cfg.externalAgentTrust ?? "gated");
|
|
46
|
+
return v === "off" || v === "full" ? v : "gated";
|
|
47
|
+
}
|
|
48
|
+
registerTool({
|
|
49
|
+
name: "external_agent",
|
|
50
|
+
description: "Delegate a self-contained coding task to an EXTERNAL agent CLI — `claude` (Claude Code) or `codex` — " +
|
|
51
|
+
"running headless in the current directory, and return its result. Use for heavy, isolated work you want " +
|
|
52
|
+
"another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
|
|
53
|
+
"Args: task (required), backend (claude|codex; default = first installed), model (optional).",
|
|
54
|
+
kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
|
|
55
|
+
input_schema: {
|
|
56
|
+
type: "object",
|
|
57
|
+
properties: {
|
|
58
|
+
task: { type: "string", description: "the self-contained task for the external agent" },
|
|
59
|
+
backend: { type: "string", description: "claude | codex (default: first available on PATH)" },
|
|
60
|
+
model: { type: "string", description: "optional model id override for the external agent" },
|
|
61
|
+
timeout_ms: { type: "number", description: "hard cap in ms (default 600000, max 1800000)" },
|
|
62
|
+
},
|
|
63
|
+
required: ["task"],
|
|
64
|
+
},
|
|
65
|
+
async run(input, ctx) {
|
|
66
|
+
const trust = resolveTrust();
|
|
67
|
+
if (trust === "off")
|
|
68
|
+
return "external_agent is disabled (set externalAgentTrust to gated|full, or HARA_EXTERNAL_AGENT_TRUST).";
|
|
69
|
+
const task = typeof input.task === "string" ? input.task.trim() : "";
|
|
70
|
+
if (!task)
|
|
71
|
+
return "external_agent needs a non-empty `task`.";
|
|
72
|
+
const installed = BUILTIN_BACKENDS.filter((b) => available(b));
|
|
73
|
+
const backend = String(input.backend ?? "").trim() || installed[0] || "";
|
|
74
|
+
if (!BUILTIN_BACKENDS.includes(backend))
|
|
75
|
+
return `Unknown backend '${backend || "(none)"}'. Supported: ${BUILTIN_BACKENDS.join(", ")}.`;
|
|
76
|
+
if (!available(backend))
|
|
77
|
+
return `'${backend}' CLI not found on PATH. Installed external agents: ${installed.join(", ") || "none"}.`;
|
|
78
|
+
const built = buildExternalArgv(backend, task, { cwd: ctx.cwd, model: input.model ? String(input.model) : undefined, sandbox: ctx.sandbox ?? "off", trust });
|
|
79
|
+
if (!built)
|
|
80
|
+
return `Unknown backend '${backend}'.`;
|
|
81
|
+
const timeout = Math.min(Math.max(30_000, Number(input.timeout_ms) || 600_000), 1_800_000);
|
|
82
|
+
return await new Promise((resolve) => {
|
|
83
|
+
const child = spawn(built.cmd, built.args, { cwd: ctx.cwd, env: process.env });
|
|
84
|
+
let out = "";
|
|
85
|
+
let err = "";
|
|
86
|
+
let done = false;
|
|
87
|
+
const finish = (s) => {
|
|
88
|
+
if (done)
|
|
89
|
+
return;
|
|
90
|
+
done = true;
|
|
91
|
+
clearTimeout(timer);
|
|
92
|
+
resolve(capHeadTail(s));
|
|
93
|
+
};
|
|
94
|
+
const timer = setTimeout(() => {
|
|
95
|
+
try {
|
|
96
|
+
child.kill("SIGKILL");
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
/* already gone */
|
|
100
|
+
}
|
|
101
|
+
finish(`[${backend}] timed out after ${timeout}ms\n${out}`);
|
|
102
|
+
}, timeout);
|
|
103
|
+
child.stdin.end(); // task is passed via argv
|
|
104
|
+
child.stdout.on("data", (d) => {
|
|
105
|
+
out += d.toString();
|
|
106
|
+
ctx.ui?.notice?.(d.toString().trimEnd());
|
|
107
|
+
});
|
|
108
|
+
child.stderr.on("data", (d) => {
|
|
109
|
+
err += d.toString();
|
|
110
|
+
});
|
|
111
|
+
child.on("error", (e) => finish(`[${backend}] failed to start: ${e.message} (is it installed?)`));
|
|
112
|
+
child.on("close", (code) => {
|
|
113
|
+
const text = out.trim() || "(external agent produced no output)";
|
|
114
|
+
finish(code === 0 ? text : `[${backend} exit ${code}]\n${text}${err ? `\n[stderr]\n${err.trim()}` : ""}`);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
},
|
|
118
|
+
});
|
package/dist/tools/skill.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// The `skill` tool — load a skill's full instructions on demand. The system prompt lists available
|
|
2
2
|
// skills (id + description); the model calls this to pull the body before doing a task the skill covers.
|
|
3
3
|
// Returning the body as a tool RESULT (not editing the system prompt) keeps the cached prefix stable.
|
|
4
|
+
import { dirname } from "node:path";
|
|
4
5
|
import { registerTool } from "./registry.js";
|
|
5
6
|
import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
|
|
6
7
|
import { scanMemory } from "../memory/guard.js";
|
|
@@ -21,10 +22,13 @@ registerTool({
|
|
|
21
22
|
const scan = scanMemory(body); // skills may come from plugins (untrusted) — guard at load time
|
|
22
23
|
if (!scan.ok)
|
|
23
24
|
return `Skill '${id}' blocked: its content looks unsafe (${scan.hits.join(", ")}).`;
|
|
25
|
+
// Tell the model where this skill lives so it can read sibling files (assets/, references/) by absolute
|
|
26
|
+
// path — relying on "~" or cwd-relative guessing is unreliable across tools/sandboxes.
|
|
27
|
+
const located = `Skill directory (absolute): ${dirname(sk.file)}\nRead any sibling files this skill mentions (e.g. references/…, assets/…) from under that directory.\n\n${body}`;
|
|
24
28
|
if (sk.context === "fork" && ctx.spawn) {
|
|
25
29
|
// fork: run the skill as a delegated sub-agent rather than inlining it into this turn
|
|
26
|
-
return await ctx.spawn(`Follow this skill to complete the current task:\n\n${
|
|
30
|
+
return await ctx.spawn(`Follow this skill to complete the current task:\n\n${located}`);
|
|
27
31
|
}
|
|
28
|
-
return
|
|
32
|
+
return located; // inline (default): the body enters the conversation as this tool's result
|
|
29
33
|
},
|
|
30
34
|
});
|
package/dist/tools/todo.js
CHANGED
|
@@ -7,6 +7,29 @@ let todos = [];
|
|
|
7
7
|
export function currentTodos() {
|
|
8
8
|
return todos;
|
|
9
9
|
}
|
|
10
|
+
const listeners = new Set();
|
|
11
|
+
/** Subscribe to checklist changes. Returns an unsubscribe fn. */
|
|
12
|
+
export function onTodosChange(fn) {
|
|
13
|
+
listeners.add(fn);
|
|
14
|
+
return () => {
|
|
15
|
+
listeners.delete(fn);
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function emit() {
|
|
19
|
+
for (const fn of listeners) {
|
|
20
|
+
try {
|
|
21
|
+
fn(todos);
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
/* listeners must not break the tool */
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
/** Reset between sessions/turns if a runner wants a clean slate (not used by the tool itself). */
|
|
29
|
+
export function clearTodos() {
|
|
30
|
+
todos = [];
|
|
31
|
+
emit();
|
|
32
|
+
}
|
|
10
33
|
const MARK = { pending: "☐", in_progress: "▶", done: "☑" };
|
|
11
34
|
export function renderTodos(list) {
|
|
12
35
|
if (!list.length)
|
|
@@ -16,9 +39,33 @@ export function renderTodos(list) {
|
|
|
16
39
|
}
|
|
17
40
|
registerTool({
|
|
18
41
|
name: "todo_write",
|
|
19
|
-
description: "Maintain a short task checklist for the CURRENT work.
|
|
20
|
-
"
|
|
21
|
-
"
|
|
42
|
+
description: "Maintain a short task checklist for the CURRENT work. Pass the FULL list each call (it replaces the previous). " +
|
|
43
|
+
"Each item has `text` (imperative, e.g. 'Run tests') AND `activeForm` (present-continuous, e.g. 'Running tests') — " +
|
|
44
|
+
"the UI shows activeForm while the item is in_progress. Exactly ONE item should be in_progress at a time; flip " +
|
|
45
|
+
"items to 'done' as you finish; add items you discover. " +
|
|
46
|
+
"\n\n## Use this tool when" +
|
|
47
|
+
"\n - the task takes 3+ distinct steps (refactor across files, new feature, multi-file fix, migrations)" +
|
|
48
|
+
"\n - the user gave you a numbered/comma-separated list of things to do" +
|
|
49
|
+
"\n - you discover mid-work that scope grew past a single edit" +
|
|
50
|
+
"\n - the user explicitly asks for a plan/checklist" +
|
|
51
|
+
"\n\n## Skip this tool when" +
|
|
52
|
+
"\n - reading one file or answering one question" +
|
|
53
|
+
"\n - running one shell command and reporting output" +
|
|
54
|
+
"\n - a single straight-line edit to one location" +
|
|
55
|
+
"\n - pure conversation / explanation" +
|
|
56
|
+
"\n\n## Examples — use it" +
|
|
57
|
+
"\n user: \"add a dark-mode toggle and run tests\" → 4-5 items (component, state, styles, tests)" +
|
|
58
|
+
"\n user: \"rename getCwd to getCurrentWorkingDirectory across the project\" → one item per file after grepping" +
|
|
59
|
+
"\n user: \"implement registration, catalog, cart, checkout\" → break each feature into 2-3 sub-items" +
|
|
60
|
+
"\n user: \"optimize this slow React app\" → one item per identified bottleneck" +
|
|
61
|
+
"\n\n## Examples — skip it" +
|
|
62
|
+
"\n user: \"how do I print 'hello' in python?\" → answer directly" +
|
|
63
|
+
"\n user: \"what does git status do?\" → explain" +
|
|
64
|
+
"\n user: \"add a comment to calculateTotal\" → one edit, no plan needed" +
|
|
65
|
+
"\n user: \"run npm install\" → one exec, report output" +
|
|
66
|
+
"\n\n## After updating the list" +
|
|
67
|
+
"\nBriefly say what changed in one short line (e.g. \"marked 2 done, starting on tests\"); do NOT repeat the full " +
|
|
68
|
+
"checklist back to the user — the UI already renders it live.",
|
|
22
69
|
input_schema: {
|
|
23
70
|
type: "object",
|
|
24
71
|
properties: {
|
|
@@ -28,7 +75,11 @@ registerTool({
|
|
|
28
75
|
items: {
|
|
29
76
|
type: "object",
|
|
30
77
|
properties: {
|
|
31
|
-
text: { type: "string", description: "the task, a short imperative phrase" },
|
|
78
|
+
text: { type: "string", description: "the task, a short imperative phrase (e.g. 'Run tests')" },
|
|
79
|
+
activeForm: {
|
|
80
|
+
type: "string",
|
|
81
|
+
description: "present-continuous form shown while in_progress (e.g. 'Running tests'). Always provide.",
|
|
82
|
+
},
|
|
32
83
|
status: { type: "string", enum: ["pending", "in_progress", "done"] },
|
|
33
84
|
},
|
|
34
85
|
required: ["text", "status"],
|
|
@@ -41,11 +92,17 @@ registerTool({
|
|
|
41
92
|
async run(input) {
|
|
42
93
|
const raw = Array.isArray(input.todos) ? input.todos : [];
|
|
43
94
|
todos = raw
|
|
44
|
-
.map((t) =>
|
|
45
|
-
text
|
|
46
|
-
status
|
|
47
|
-
|
|
95
|
+
.map((t) => {
|
|
96
|
+
const text = String(t?.text ?? "").trim();
|
|
97
|
+
const status = (["pending", "in_progress", "done"].includes(t?.status) ? t.status : "pending");
|
|
98
|
+
const activeFormRaw = typeof t?.activeForm === "string" ? t.activeForm.trim() : "";
|
|
99
|
+
const item = { text, status };
|
|
100
|
+
if (activeFormRaw)
|
|
101
|
+
item.activeForm = activeFormRaw;
|
|
102
|
+
return item;
|
|
103
|
+
})
|
|
48
104
|
.filter((t) => t.text);
|
|
105
|
+
emit();
|
|
49
106
|
return renderTodos(todos);
|
|
50
107
|
},
|
|
51
108
|
});
|