@nanhara/hara 0.89.0 → 0.95.2
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 +45 -8
- package/dist/config.js +25 -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 +820 -71
- 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/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- 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 +241 -30
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
|
@@ -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,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
|
});
|