@nanhara/hara 0.70.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.
Files changed (47) hide show
  1. package/CHANGELOG.md +395 -0
  2. package/README.md +7 -2
  3. package/dist/agent/compact.js +10 -0
  4. package/dist/agent/context-report.js +33 -0
  5. package/dist/agent/failover.js +53 -0
  6. package/dist/agent/loop.js +120 -13
  7. package/dist/agent/rewind.js +20 -0
  8. package/dist/agent/route.js +47 -0
  9. package/dist/checkpoints.js +91 -0
  10. package/dist/config.js +33 -9
  11. package/dist/context/subdir-hints.js +76 -0
  12. package/dist/cron/runner.js +7 -4
  13. package/dist/exec/jobs.js +82 -0
  14. package/dist/gateway/dingtalk.js +209 -0
  15. package/dist/gateway/discord.js +164 -0
  16. package/dist/gateway/feishu.js +144 -0
  17. package/dist/gateway/matrix.js +188 -0
  18. package/dist/gateway/mattermost.js +206 -0
  19. package/dist/gateway/serve.js +339 -0
  20. package/dist/gateway/sessions.js +89 -0
  21. package/dist/gateway/signal.js +220 -0
  22. package/dist/gateway/slack.js +190 -0
  23. package/dist/gateway/telegram.js +115 -0
  24. package/dist/gateway/tmux-routes.js +135 -0
  25. package/dist/gateway/tts.js +100 -0
  26. package/dist/gateway/wecom.js +383 -0
  27. package/dist/gateway/weixin.js +590 -0
  28. package/dist/index.js +1073 -120
  29. package/dist/org-fleet/enroll.js +28 -5
  30. package/dist/plugins/plugins.js +49 -1
  31. package/dist/profile/profile.js +436 -0
  32. package/dist/providers/anthropic.js +28 -1
  33. package/dist/providers/openai.js +16 -1
  34. package/dist/sandbox.js +15 -12
  35. package/dist/security/external-content.js +57 -0
  36. package/dist/security/permissions.js +211 -0
  37. package/dist/session/session-model.js +36 -0
  38. package/dist/statusbar.js +12 -3
  39. package/dist/tools/builtin.js +49 -2
  40. package/dist/tools/external_agent.js +118 -0
  41. package/dist/tools/send.js +35 -0
  42. package/dist/tools/skill.js +6 -2
  43. package/dist/tools/todo.js +65 -8
  44. package/dist/tools/web.js +4 -3
  45. package/dist/tui/App.js +241 -30
  46. package/dist/tui/run.js +36 -1
  47. package/package.json +4 -2
@@ -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. Use it to plan a multi-step task up front, then " +
20
- "update it as you go: keep exactly one item 'in_progress', flip items to 'done' as you finish, add items " +
21
- "you discover. Pass the FULL list each call (it replaces the previous). Skip it for trivial one-step tasks.",
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: String(t?.text ?? "").trim(),
46
- status: (["pending", "in_progress", "done"].includes(t?.status) ? t.status : "pending"),
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
  });
package/dist/tools/web.js CHANGED
@@ -5,6 +5,7 @@
5
5
  import { registerTool } from "./registry.js";
6
6
  import { lookup } from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
+ import { wrapUntrusted } from "../security/external-content.js";
8
9
  const MAX = 60_000;
9
10
  /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
10
11
  export function isPrivateIp(ip) {
@@ -150,7 +151,7 @@ registerTool({
150
151
  const j = (await res.json());
151
152
  const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
152
153
  if (rs.length)
153
- return fmt(rs);
154
+ return wrapUntrusted(fmt(rs), `web_search: ${q}`);
154
155
  }
155
156
  // Tavily failed → fall through to the keyless best-effort path.
156
157
  }
@@ -171,7 +172,7 @@ registerTool({
171
172
  const results = parseSearchResults(await res.text(), limit);
172
173
  if (!results.length)
173
174
  return "(no results — the keyless endpoint is rate-limited or changed. Set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.)";
174
- return fmt(results);
175
+ return wrapUntrusted(fmt(results), `web_search: ${q}`);
175
176
  }
176
177
  catch (e) {
177
178
  return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
@@ -231,7 +232,7 @@ registerTool({
231
232
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
232
233
  if (text.length > cap)
233
234
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
234
- return `# ${current.href} (HTTP ${res.status})\n\n${text || "(empty body)"}`;
235
+ return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
235
236
  }
236
237
  catch (e) {
237
238
  return `Error fetching ${url.href}: ${e?.name === "AbortError" ? "timed out (30s)" : (e?.message ?? e)}`;
package/dist/tui/App.js CHANGED
@@ -7,13 +7,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
7
7
  //
8
8
  // The agent machinery is injected via `onSubmit` (a turn runner) so this view is testable with
9
9
  // ink-testing-library against a fake runner — no provider/network needed.
10
- import { Box, Static, Text, useApp, useInput } from "ink";
10
+ import { Box, Static, Text, useApp, useInput, useStdout } from "ink";
11
11
  import { useCallback, useEffect, useRef, useState } from "react";
12
12
  import { InputBox } from "./InputBox.js";
13
13
  import { activity } from "../activity.js";
14
14
  import { ctxPctFor } from "../statusbar.js";
15
15
  import { accent } from "./theme.js";
16
16
  import { renderMarkdown } from "../md.js";
17
+ import { currentTodos, onTodosChange } from "../tools/todo.js";
17
18
  let _id = 0;
18
19
  const nid = () => ++_id;
19
20
  const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
@@ -24,12 +25,15 @@ function Block({ item, open }) {
24
25
  case "assistant":
25
26
  return _jsx(Text, { children: renderMarkdown(item.text) }); // headers/bold/inline-code/bullets + verbatim fences
26
27
  case "reasoning": {
27
- // fixed-height window: show the last 5 lines while thinking; ctrl-r toggles the full text.
28
+ // Codex-style: stream the reasoning dim + italic with a leading "• " bullet; show the full text up to
29
+ // MAX lines, fold longer to the live tail with a "… +N lines" summary. ctrl-r expands/collapses.
30
+ const MAX = 10;
28
31
  const lines = item.text.replace(/\n+$/, "").split("\n");
29
- const long = lines.length > 5;
30
- const shown = open || !long ? lines : lines.slice(-5);
31
- const hint = long ? (open ? " · ctrl-r collapse" : " · ctrl-r expand") : "";
32
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), dimColor: true, children: `✻ thinking${lines.length} line${lines.length === 1 ? "" : "s"}${hint}` }), shown.map((l, i) => (_jsx(Text, { dimColor: true, children: `│ ${l}` }, i)))] }));
32
+ const long = lines.length > MAX;
33
+ const shown = open || !long ? lines : lines.slice(-MAX); // short or expanded → all; long & collapsed → live tail
34
+ const omitted = long && !open ? lines.length - MAX : 0;
35
+ const hint = long ? (open ? " · ctrl-r collapse" : ` ·+${omitted} lines · ctrl-r expand`) : "";
36
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), dimColor: true, children: `✻ thinking … ${lines.length} line${lines.length === 1 ? "" : "s"}${hint}` }), shown.map((l, i) => (_jsx(Text, { dimColor: true, italic: true, children: `${i === 0 ? "• " : " "}${l}` }, i)))] }));
33
37
  }
34
38
  case "tool":
35
39
  return _jsx(Text, { dimColor: true, children: " " + item.text });
@@ -39,30 +43,173 @@ function Block({ item, open }) {
39
43
  return _jsx(Text, { dimColor: true, children: item.text });
40
44
  }
41
45
  }
42
- // ASCII rendering of the nanhara "Λi" mark (small peak + big peak + italic i), in the brand violet.
43
- // hara wordmark — FIGlet "ANSI Shadow". A recognizable banner reads better in a terminal than a
44
- // pixel-faithful logo. Printed once at the top of the session; scrolls away with the transcript.
45
- const BANNER = [
46
- "██╗ ██╗ █████╗ ██████╗ █████╗",
47
- "██║ ██║██╔══██╗██╔══██╗██╔══██╗",
48
- "███████║███████║██████╔╝███████║",
49
- "██╔══██║██╔══██║██╔══██╗██╔══██║",
50
- "██║ ██║██║ ██║██║ ██║██║ ██║",
51
- "╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝╚═╝ ╚═╝",
52
- ];
53
- function HeaderCard({ version, model, cwd, tip, vision, session }) {
54
- return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [BANNER.map((row, i) => (_jsx(Text, { color: accent(), children: row }, i))), _jsx(Text, { dimColor: true, children: ` the coding agent that runs like an org · v${version}` }), _jsx(Text, { dimColor: true, children: ` ${model} · ${cwd}` }), session ? _jsx(Text, { dimColor: true, children: ` session ${session}` }) : null, vision ? (_jsxs(Text, { children: [_jsx(Text, { color: accent(), children: " 👁 " }), _jsx(Text, { dimColor: true, children: vision })] })) : null, tip ? _jsx(Text, { dimColor: true, children: ` ${tip}` }) : null] }));
46
+ function flattenTranscript(items) {
47
+ const out = [];
48
+ for (const it of items) {
49
+ const body = (it.full ?? it.text).replace(/\n+$/, "");
50
+ if (!body && it.kind !== "user")
51
+ continue;
52
+ out.push({ t: "" }); // blank line between blocks
53
+ if (it.kind === "user") {
54
+ body.split("\n").forEach((l, i) => out.push({ t: (i === 0 ? "› " : " ") + l, color: "cyan" }));
55
+ }
56
+ else if (it.kind === "reasoning" || (it.kind === "notice" && it.full !== undefined)) {
57
+ const lines = body.split("\n");
58
+ out.push({ t: `✻ thinking (${lines.length} line${lines.length === 1 ? "" : "s"})`, dim: true, color: accent() });
59
+ lines.forEach((l, i) => out.push({ t: (i === 0 ? "• " : " ") + l, dim: true, italic: true }));
60
+ }
61
+ else if (it.kind === "tool") {
62
+ out.push({ t: it.text, dim: true });
63
+ if (it.full && it.full !== it.text)
64
+ it.full.replace(/\n+$/, "").split("\n").forEach((l) => out.push({ t: " " + l, dim: true }));
65
+ }
66
+ else if (it.kind === "diff") {
67
+ body.split("\n").forEach((l) => out.push({ t: l }));
68
+ }
69
+ else {
70
+ body.split("\n").forEach((l) => out.push({ t: l, dim: it.kind === "notice" }));
71
+ }
72
+ }
73
+ return out;
74
+ }
75
+ function Transcript({ items, onClose }) {
76
+ const { stdout } = useStdout();
77
+ const rows = Math.max(6, (stdout?.rows ?? 30) - 2); // leave a row for the header
78
+ const lines = flattenTranscript(items);
79
+ const maxScroll = Math.max(0, lines.length - rows);
80
+ const [scroll, setScroll] = useState(1e9); // open at the bottom (latest); clamped to maxScroll below
81
+ const at = Math.min(scroll, maxScroll);
82
+ useInput((input, key) => {
83
+ if (key.escape || (key.ctrl && input === "t"))
84
+ return onClose();
85
+ if (key.upArrow)
86
+ setScroll(Math.max(0, at - 1));
87
+ else if (key.downArrow)
88
+ setScroll(Math.min(maxScroll, at + 1));
89
+ else if (key.pageUp)
90
+ setScroll(Math.max(0, at - rows));
91
+ else if (key.pageDown)
92
+ setScroll(Math.min(maxScroll, at + rows));
93
+ else if (input === "g")
94
+ setScroll(0);
95
+ else if (input === "G")
96
+ setScroll(maxScroll);
97
+ });
98
+ const view = lines.slice(at, at + rows);
99
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { color: accent(), bold: true, children: ` TRANSCRIPT · full, nothing folded · ↑↓/PgUp·PgDn/g·G scroll · esc or ctrl+t closes · ${lines.length ? at + 1 : 0}–${Math.min(at + rows, lines.length)}/${lines.length}` }), view.map((l, i) => (_jsx(Text, { color: l.color, dimColor: l.dim, italic: l.italic, children: l.t || " " }, i)))] }));
100
+ }
101
+ // ─── header helpers ────────────────────────────────────────────────────────────
102
+ // Pure functions so the view stays declarative and tests can pin the formatting
103
+ // without rendering ink. Exported for unit tests (see test/tui-header.test.mjs).
104
+ /** Extract the URL host (no scheme, no path, no query). Falls back to the raw
105
+ * string when `url` isn't parseable — better to surface something than swallow it. */
106
+ export function extractHost(url) {
107
+ if (!url)
108
+ return "";
109
+ try {
110
+ return new URL(url).host;
111
+ }
112
+ catch {
113
+ // Strip a leading scheme + leading // if present (handles "git@host:..." style which URL() rejects).
114
+ const noScheme = url.replace(/^[a-z][a-z0-9+.-]*:\/\//i, "");
115
+ return noScheme.split("/")[0].split("?")[0];
116
+ }
117
+ }
118
+ /** Tilde-collapse the user's home directory. If the path is too long, keep the
119
+ * TAIL (most-specific segments) — the project name reads better than `~/work/…`.
120
+ * `maxLen` clamps the displayed length; default 60 fits in a 80-col terminal next
121
+ * to the "cwd " label + an optional " · AGENTS.md" suffix. */
122
+ export function shortenHome(abs, home = process.env.HOME ?? "", maxLen = 60) {
123
+ let p = abs;
124
+ if (home && (p === home || p.startsWith(home + "/"))) {
125
+ p = "~" + p.slice(home.length);
126
+ }
127
+ if (p.length <= maxLen)
128
+ return p;
129
+ // Keep the last `maxLen - 2` chars, prefixed with `…/` to signal truncation.
130
+ const tail = p.slice(-(maxLen - 2));
131
+ // If the truncation lands inside a segment, advance to the next `/` for a clean break.
132
+ const firstSlash = tail.indexOf("/");
133
+ const clean = firstSlash > 0 ? tail.slice(firstSlash) : tail;
134
+ return "…" + clean;
135
+ }
136
+ /** Render a session uuid (or any id) as its first 8 chars — same convention as `shortId`
137
+ * in src/session/store.ts. A second helper here so the view never reaches into the session
138
+ * module + so headers in tests can pass any string and get a stable display. */
139
+ export function shortenSession(uuid) {
140
+ if (!uuid)
141
+ return "";
142
+ return uuid.slice(0, 8);
55
143
  }
56
- function Working() {
144
+ /** Layout constants for the field grid. Field names live in a 10-char column;
145
+ * values start at column 12 (after 2 spaces). The view uses `padField` to
146
+ * pad a label so the values align vertically across rows. */
147
+ const FIELD_PAD = 10;
148
+ const padField = (name) => name.padEnd(FIELD_PAD, " ");
149
+ function HeaderCard(props) {
150
+ const { version, modelLabel, cwd, agentsMdLoaded, session, kind } = props;
151
+ const home = process.env.HOME ?? "";
152
+ const cwdShort = shortenHome(cwd, home);
153
+ const sessionShort = shortenSession(session);
154
+ // Identity line — branches on kind. Personal collapses kind+model into one line;
155
+ // org splits identity (org/label/id/route) from model (with its source).
156
+ const identity = kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("org")}` }), _jsx(Text, { children: props.orgLabel ?? props.orgId ?? "(unnamed)" }), props.orgId && props.orgLabel ? _jsx(Text, { dimColor: true, children: ` · ${props.orgId}` }) : null, props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null] })) : (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField(props.profileId ? `personal:${props.profileId}` : "personal")}` }), _jsx(Text, { children: modelLabel }), props.routeHost ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: " → " }), _jsx(Text, { dimColor: true, children: props.routeHost })] })) : null] }));
157
+ return (_jsxs(Box, { flexDirection: "column", marginBottom: 1, children: [_jsxs(Text, { children: [_jsx(Text, { color: accent(), children: "> " }), _jsx(Text, { color: accent(), bold: true, children: `hara` }), _jsx(Text, { dimColor: true, children: ` · v${version} — the coding agent that runs like an org` })] }), _jsx(Text, { children: " " }), identity, kind === "org" ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("model")}` }), _jsx(Text, { children: modelLabel }), props.modelSource ? _jsx(Text, { dimColor: true, children: ` · from ${props.modelSource}` }) : null] })) : null, _jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("cwd")}` }), _jsx(Text, { dimColor: true, children: cwdShort }), agentsMdLoaded ? _jsx(Text, { dimColor: true, children: " · AGENTS.md" }) : null] }), sessionShort ? (_jsxs(Text, { children: [_jsx(Text, { dimColor: true, children: ` ${padField("session")}` }), _jsx(Text, { dimColor: true, children: sessionShort })] })) : null, _jsx(Text, { children: " " }), _jsx(Text, { dimColor: true, children: " /help · @file · shift+tab · esc" })] }));
158
+ }
159
+ // Spinner verb: while a turn is running, prefer the in_progress todo's activeForm (or its text),
160
+ // so the bottom line reads "▶ updating tests…" instead of an abstract "working". Falls back to
161
+ // the elapsed-seconds form when no checklist is active. Exported for unit testing.
162
+ export function spinnerVerb(list, elapsedSec) {
163
+ const active = list.find((t) => t.status === "in_progress");
164
+ if (active) {
165
+ const phrase = active.activeForm?.trim() || active.text;
166
+ return `${phrase}… ${elapsedSec}s · esc to interrupt`;
167
+ }
168
+ return `working ${elapsedSec}s · esc to interrupt`;
169
+ }
170
+ function Working({ todos }) {
57
171
  const [n, setN] = useState(0);
58
172
  useEffect(() => {
59
173
  const id = setInterval(() => setN((x) => x + 1), 100);
60
174
  return () => clearInterval(id);
61
175
  }, []);
62
176
  const frames = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏";
63
- return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` working ${Math.floor(n / 10)}s · esc to interrupt` })] }));
177
+ return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[n % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${spinnerVerb(todos, Math.floor(n / 10))}` })] }));
178
+ }
179
+ // Live task panel: renders the current todo_write checklist between the in-progress turn output
180
+ // and the input box. Highlights the in_progress item; caps at 8 rows and folds the rest into
181
+ // `… +N pending/done`. Hidden when the list is empty.
182
+ const PANEL_MAX_ROWS = 8;
183
+ const TODO_MARK = { pending: "☐", in_progress: "▶", done: "☑" };
184
+ function TodoPanel({ todos }) {
185
+ if (!todos.length)
186
+ return null;
187
+ const doneCount = todos.filter((t) => t.status === "done").length;
188
+ // Prioritize visible rows: in_progress first, then pending, then done — show the most informative
189
+ // slice when the list outgrows the cap. Stable order within each group via the original index.
190
+ const indexed = todos.map((t, i) => ({ t, i }));
191
+ const rank = (s) => (s === "in_progress" ? 0 : s === "pending" ? 1 : 2);
192
+ const prioritized = [...indexed].sort((a, b) => rank(a.t.status) - rank(b.t.status) || a.i - b.i);
193
+ const visible = prioritized.slice(0, PANEL_MAX_ROWS).sort((a, b) => a.i - b.i).map((x) => x.t);
194
+ const hidden = todos.length - visible.length;
195
+ const hiddenSummary = hidden > 0 ? (() => {
196
+ const remaining = prioritized.slice(PANEL_MAX_ROWS).map((x) => x.t);
197
+ const p = remaining.filter((t) => t.status === "pending").length;
198
+ const d = remaining.filter((t) => t.status === "done").length;
199
+ const parts = [];
200
+ if (p)
201
+ parts.push(`${p} pending`);
202
+ if (d)
203
+ parts.push(`${d} done`);
204
+ return ` … +${parts.join(", ")}`;
205
+ })() : "";
206
+ return (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: accent(), children: ` Todos (${doneCount}/${todos.length} done)` }), visible.map((t, i) => {
207
+ const inProg = t.status === "in_progress";
208
+ const done = t.status === "done";
209
+ return (_jsx(Text, { color: inProg ? accent() : undefined, bold: inProg, dimColor: done, children: ` ${TODO_MARK[t.status]} ${t.text}` }, i));
210
+ }), hiddenSummary ? _jsx(Text, { dimColor: true, children: hiddenSummary }) : null] }));
64
211
  }
65
- export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim }) {
212
+ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval, onClipboardImage, vim, visionNotice }) {
66
213
  const { exit } = useApp();
67
214
  const [history, setHistory] = useState([]);
68
215
  const [current, setCurrent] = useState([]);
@@ -71,6 +218,14 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
71
218
  const [prompt, setPrompt] = useState(null);
72
219
  const [promptSel, setPromptSel] = useState(0);
73
220
  const [reasoningOpen, setReasoningOpen] = useState(false);
221
+ const [showTranscript, setShowTranscript] = useState(false); // Ctrl+T full-transcript overlay
222
+ // Live checklist mirror: TodoPanel reads this, and `Working` derives its spinner verb from the
223
+ // in_progress item. The tool emits on every todo_write — keeps the UI in lockstep with the agent.
224
+ const [todos, setTodos] = useState(() => currentTodos());
225
+ // Collapse-after-turn: once a turn ends, leave the panel visible briefly (so the user sees the
226
+ // final state) then fold it into a single-line "Todos: N/M done" notice in history. Cleared if
227
+ // a new turn starts before the timer fires.
228
+ const collapseTimerRef = useRef(null);
74
229
  const ctrlRef = useRef(null);
75
230
  const queueRef = useRef([]); // type-ahead: FIFO of messages entered while working
76
231
  const [pool, setPool] = useState([]); // type-ahead pool: queued message lines, shown above the input
@@ -84,6 +239,22 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
84
239
  activity.onChange(fn);
85
240
  return () => activity.onChange(null);
86
241
  }, []);
242
+ // Subscribe to todo_write updates so the panel re-renders when the agent edits the checklist.
243
+ useEffect(() => {
244
+ const unsub = onTodosChange((list) => {
245
+ setTodos([...list]); // copy so React sees a new array (the tool reuses one)
246
+ // A change mid-turn cancels any pending collapse — the user is still working with this list.
247
+ if (collapseTimerRef.current) {
248
+ clearTimeout(collapseTimerRef.current);
249
+ collapseTimerRef.current = null;
250
+ }
251
+ });
252
+ return () => {
253
+ unsub();
254
+ if (collapseTimerRef.current)
255
+ clearTimeout(collapseTimerRef.current);
256
+ };
257
+ }, []);
87
258
  const pushCurrent = useCallback((kind, text, merge = false) => {
88
259
  setCurrent((cur) => {
89
260
  const last = cur[cur.length - 1];
@@ -92,6 +263,16 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
92
263
  return [...cur, { id: nid(), kind, text }];
93
264
  });
94
265
  }, []);
266
+ // Lazy vision notice: 顾雅 spec — the header no longer carries an always-on "👁 …" line.
267
+ // Instead, the first time an image attachment shows up in this session, we print the
268
+ // routing notice once (inline). `visionShownRef` is the session-scoped flag.
269
+ const visionShownRef = useRef(false);
270
+ const noteVisionIfNeeded = useCallback(() => {
271
+ if (visionShownRef.current || !visionNotice)
272
+ return;
273
+ visionShownRef.current = true;
274
+ setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ⓘ ${visionNotice}` }]);
275
+ }, [visionNotice]);
95
276
  // Type-ahead steering: hand the runner everything queued while the turn ran, showing each message
96
277
  // inline (as a user block) at the point it gets folded into the conversation. Drained mid-turn so an
97
278
  // addition reaches the model on its next call; whatever's still queued at turn end is the effect below.
@@ -101,10 +282,12 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
101
282
  const batch = queueRef.current;
102
283
  queueRef.current = [];
103
284
  setPool([]);
285
+ if (batch.some((b) => b.images?.length))
286
+ noteVisionIfNeeded();
104
287
  for (const b of batch)
105
288
  pushCurrent("user", b.line.trim() || "🖼 (image)");
106
289
  return batch;
107
- }, [pushCurrent]);
290
+ }, [pushCurrent, noteVisionIfNeeded]);
108
291
  const handleSubmit = useCallback(async (line, images) => {
109
292
  const t = line.trim();
110
293
  if ((!t && !images?.length) || prompt)
@@ -115,6 +298,8 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
115
298
  setPool(queueRef.current.map((q) => q.line.trim() || "🖼 (image)"));
116
299
  return;
117
300
  }
301
+ if (images?.length)
302
+ noteVisionIfNeeded(); // one-shot inline notice on first image of the session
118
303
  setHistory((h) => [...h, { id: nid(), kind: "user", text: t }]); // t already carries any [Image #N] tokens
119
304
  const ctrl = new AbortController();
120
305
  ctrlRef.current = ctrl;
@@ -145,14 +330,34 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
145
330
  catch (e) {
146
331
  pushCurrent("notice", `error: ${e instanceof Error ? e.message : String(e)}`);
147
332
  }
148
- const committed = currentRef.current.map((it) => it.kind === "reasoning"
149
- ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines` }
150
- : it);
151
- setHistory((h) => [...h, ...committed]);
152
- setCurrent([]);
333
+ // Commit this turn's items to scrollback. Read the LIVE current via the updater — currentRef only
334
+ // syncs on render (line ~225), so a fast slash-only turn (/design, /help, /skills…) that pushes a
335
+ // notice and returns before any re-render would otherwise commit nothing and lose the notice.
336
+ setCurrent((cur) => {
337
+ const committed = cur.map((it) => it.kind === "reasoning"
338
+ ? { ...it, kind: "notice", text: `✻ thought · ${it.text.split("\n").filter((l) => l.trim()).length} lines`, full: it.text }
339
+ : it);
340
+ if (committed.length)
341
+ setHistory((h) => [...h, ...committed]);
342
+ return [];
343
+ });
153
344
  setWorking(false);
154
345
  ctrlRef.current = null;
155
- }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue]);
346
+ // Schedule a panel collapse: if there was a checklist this turn, fold it to a one-line summary
347
+ // in scrollback after ~30s of quiet (i.e. no new todo_write or new turn).
348
+ if (collapseTimerRef.current)
349
+ clearTimeout(collapseTimerRef.current);
350
+ if (currentTodos().length) {
351
+ collapseTimerRef.current = setTimeout(() => {
352
+ const list = currentTodos();
353
+ if (!list.length)
354
+ return;
355
+ const done = list.filter((t) => t.status === "done").length;
356
+ setHistory((h) => [...h, { id: nid(), kind: "notice", text: ` ✓ Todos: ${done}/${list.length} done` }]);
357
+ collapseTimerRef.current = null;
358
+ }, 30_000);
359
+ }
360
+ }, [working, prompt, onSubmit, pushCurrent, model, exit, drainQueue, noteVisionIfNeeded]);
156
361
  // Drain the type-ahead pool: when the turn finishes (working → false) and nothing awaits a choice, COALESCE
157
362
  // every pooled message into ONE turn and send it — additions/clarifications go to the agent together, in order.
158
363
  useEffect(() => {
@@ -169,6 +374,10 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
169
374
  });
170
375
  }, [working, prompt, handleSubmit]);
171
376
  useInput((input, key) => {
377
+ if (key.ctrl && input === "t")
378
+ return setShowTranscript((x) => !x); // open/close the full-transcript overlay
379
+ if (showTranscript)
380
+ return; // while open, the overlay's own useInput owns every key (scroll / esc)
172
381
  if (prompt) {
173
382
  const opts = prompt.options;
174
383
  if (key.upArrow)
@@ -209,5 +418,7 @@ export function App({ initialStatus, model, cwd, header, onSubmit, cycleApproval
209
418
  else if (key.tab && key.shift && cycleApproval)
210
419
  setStatus((s) => ({ ...s, approval: cycleApproval(s.approval) }));
211
420
  });
212
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), working && !prompt && _jsx(Working, {}), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
421
+ if (showTranscript)
422
+ return _jsx(Transcript, { items: [...history, ...current], onClose: () => setShowTranscript(false) });
423
+ return (_jsxs(Box, { flexDirection: "column", children: [_jsx(Static, { items: header ? [{ id: -1, kind: "notice", text: "" }, ...history] : history, children: (item) => (item.id === -1 ? _jsx(HeaderCard, { ...header }, "hdr") : _jsx(Block, { item: item }, item.id)) }), current.map((item) => (_jsx(Block, { item: item, open: reasoningOpen }, item.id))), !prompt && _jsx(TodoPanel, { todos: todos }), working && !prompt && _jsx(Working, { todos: todos }), prompt && (_jsxs(Box, { flexDirection: "column", marginTop: 1, children: [_jsx(Text, { color: "yellow", children: ` ${stripAnsi(prompt.title)}` }), prompt.options.map((o, i) => (_jsx(Text, { color: i === promptSel ? "cyan" : undefined, bold: i === promptSel, children: (i === promptSel ? " ❯ " : " ") + `${i + 1}. ` + o.label }, i))), _jsx(Text, { dimColor: true, children: ` ↑↓ or 1–${prompt.options.length} to choose · Enter · Esc cancels` })] })), pool.length > 0 && !prompt && (_jsx(Box, { flexDirection: "column", children: pool.map((l, i) => (_jsx(Text, { color: accent(), children: ` › ${l.length > 72 ? l.slice(0, 72) + "…" : l}` }, i))) })), _jsx(InputBox, { status: status, cwd: cwd, isActive: !prompt, working: working, queued: pool.length, vim: vim, onSubmit: handleSubmit, onClipboardImage: onClipboardImage })] }));
213
424
  }
package/dist/tui/run.js CHANGED
@@ -1,10 +1,45 @@
1
1
  // Mounts the hara TUI (ink) and resolves when the user exits. Thin shell — all agent wiring
2
2
  // (provider, session history, slash commands, turn execution) is passed in via AppProps.onSubmit
3
3
  // from index.ts, which owns that state.
4
- import { render } from "ink";
4
+ import { render, Box, Text, useApp, useInput } from "ink";
5
5
  import { createElement } from "react";
6
6
  import { App } from "./App.js";
7
7
  export async function runTui(props) {
8
8
  const instance = render(createElement(App, props));
9
9
  await instance.waitUntilExit();
10
10
  }
11
+ // A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
12
+ // MUST be ink, NOT readline: a readline question before the main TUI leaves stdin in a state ink
13
+ // can't read from, which kills the input box. ink restores stdin cleanly on unmount, so the main
14
+ // TUI mounted right after still gets working input. Resolves true on y/Enter, false on n/Esc.
15
+ export async function askConfirm(question, def = true) {
16
+ return new Promise((resolve) => {
17
+ let done = false;
18
+ const finish = (v) => {
19
+ if (!done) {
20
+ done = true;
21
+ resolve(v);
22
+ }
23
+ };
24
+ function Prompt() {
25
+ const { exit } = useApp();
26
+ useInput((input, key) => {
27
+ const v = input.toLowerCase();
28
+ let ans = null;
29
+ if (key.return)
30
+ ans = def;
31
+ else if (v === "y")
32
+ ans = true;
33
+ else if (v === "n" || key.escape)
34
+ ans = false;
35
+ if (ans !== null) {
36
+ finish(ans);
37
+ exit();
38
+ }
39
+ });
40
+ return createElement(Box, { marginY: 1 }, createElement(Text, { color: "yellow" }, ` ${question} `), createElement(Text, { dimColor: true }, def ? "[Y/n] " : "[y/N] "));
41
+ }
42
+ const inst = render(createElement(Prompt));
43
+ void inst.waitUntilExit().then(() => finish(def));
44
+ });
45
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.70.0",
3
+ "version": "0.95.2",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"
@@ -51,6 +51,7 @@
51
51
  },
52
52
  "dependencies": {
53
53
  "@anthropic-ai/sdk": "^0.104.2",
54
+ "@larksuiteoapi/node-sdk": "^1.68.0",
54
55
  "@modelcontextprotocol/sdk": "^1.29.0",
55
56
  "commander": "^15.0.0",
56
57
  "ink": "^6.8.0",
@@ -65,6 +66,7 @@
65
66
  "typescript": "^6.0.3"
66
67
  },
67
68
  "optionalDependencies": {
68
- "@zvec/zvec": "^0.5.0"
69
+ "@zvec/zvec": "^0.5.0",
70
+ "qrcode-terminal": "^0.12.0"
69
71
  }
70
72
  }