@nanhara/hara 0.121.0 → 0.121.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,25 @@ All notable changes to `@nanhara/hara`.
5
5
  > Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
6
6
  > **patch** (last) number bumps for **optimizations/fixes of existing features**.
7
7
 
8
+ ## 0.121.1 — field-feedback reliability & credential safety
9
+
10
+ - **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
11
+ width change; height-only window drags keep the input box visible and width changes repaint immediately.
12
+ - **Credentials stay out of durable transcripts and generated code.** Session writes deeply redact likely
13
+ keys/tokens/passwords (including tool inputs/results); legacy content is redacted when loaded and migrated
14
+ on its next atomic, private save. Interactive pastes get a warning without echoing the value. The agent must use environment references,
15
+ never literal secrets in code/commands, and may only populate a real-secret `.env` when explicitly asked.
16
+ - **`web_search` has a verified mainland path.** A configured Tavily request races the keyless Bing China
17
+ HTML path; Baidu, Google, and DuckDuckGo remain concurrent secondary fallbacks. Provider timeouts are
18
+ isolated, and JavaScript-only `web_fetch` pages now explain that a browser/API/connector is required
19
+ instead of returning a misleading empty body.
20
+ - **Long setup commands fail usefully.** Package installs automatically become background jobs unless the
21
+ caller explicitly sets timeout/background, while ngrok tunnels preflight local authentication once and
22
+ stop with a focused fix instead of cycling through unrelated tunnel tools.
23
+ - **Persistent desktop sessions re-read configuration.** New/resumed `hara serve` sessions pick up rotated
24
+ `apiKey`/model/baseURL values without a restart; empty env/project values no longer mask valid global
25
+ config, and a 401 says the configured credential expired rather than asking users to paste it into chat.
26
+
8
27
  ## 0.121.0 — `hara desk` · crash-safe coding/files · bounded interactive I/O
9
28
 
10
29
  - **`hara desk` connects the CLI to the shared coordination desk.** Register an agent, post/list/
@@ -38,7 +38,7 @@ export function failoverAction(kind, s) {
38
38
  export function errorHint(kind) {
39
39
  switch (kind) {
40
40
  case "auth":
41
- return " — check your API key / auth (try `hara setup`)";
41
+ return " — the configured credential was rejected or expired; update ~/.hara/config.json, the active profile, or its environment variable, then retry. Do not paste the key into chat";
42
42
  case "rate_limit":
43
43
  return " — rate-limited; wait a moment, or set `fallbackModel` to auto-switch";
44
44
  case "overloaded":
@@ -16,6 +16,7 @@ import { drainReminders, wrapReminders, pushReminder, todoStaleReminder, TODO_ST
16
16
  import { setTurnPhase } from "./phase.js";
17
17
  import { recordTouch } from "./touched.js";
18
18
  import { resolve as resolvePath } from "node:path";
19
+ import { redactSensitiveText } from "../security/secrets.js";
19
20
  /** File tools whose `path` input marks the file as "recently worked with" (post-compaction restore). */
20
21
  const FILE_TOUCH_TOOLS = new Set(["read_file", "edit_file", "write_file"]);
21
22
  /** Stall watchdog ceiling: a model attempt that streams NOTHING for this long is treated as a dead /
@@ -65,7 +66,14 @@ re-reading a big file after every edit is the slowest habit an agent can have.
65
66
  When an attempt FAILS, never repeat it unchanged — read the error, form a hypothesis about the cause, and
66
67
  change something (arguments / approach / tool) before trying again. After two failed variants of the same
67
68
  approach, stop: re-plan from what you learned, or ask the user, stating concisely what you tried and what
68
- the errors said. Repeating a failed action hoping for a different result is how sessions die. For broad,
69
+ the errors said. Repeating a failed action hoping for a different result is how sessions die.
70
+ Never put a literal password, API key, token, App Secret, Authorization header, or other credential in a
71
+ source file or shell command. Reference an environment variable instead (for example process.env.API_KEY or
72
+ $API_KEY). Keep real values in the user's environment or an approved secret store; do not create/populate a
73
+ .env file with a real secret unless the user explicitly asks and it is excluded from version control. Never
74
+ echo credentials back. Session persistence redacts likely secrets as a last line of defense, but that does
75
+ not make embedding credentials acceptable.
76
+ For broad,
69
77
  open-ended exploration (more than ~3 searches), spawn \`agent\` sub-agents — several in one response for
70
78
  independent questions (role "explore") — each returns conclusions, not dumps. Messages the user sends
71
79
  mid-task arrive marked as interjections — triage them (refine current / queue as todo / urgent-switch)
@@ -88,7 +96,10 @@ site, build output), check they are newer than their sources (compare mtimes or
88
96
  the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
89
97
  When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
90
98
  that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
91
- two-day-old work. After completing a task, give a one-line summary.`;
99
+ two-day-old work. Package-manager installs auto-start as background jobs when you omit background/timeout;
100
+ poll the returned job until it exits before depending on its packages. Before opening a public tunnel,
101
+ verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
102
+ of unrelated tunnel tools. After completing a task, give a one-line summary.`;
92
103
  /** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
93
104
  * (the only channel that reaches the peer) and never reaches for the desktop client / computer tool. */
94
105
  function gatewayNote() {
@@ -118,6 +129,17 @@ export async function runAgent(history, opts) {
118
129
  let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
119
130
  let triedFallback = false;
120
131
  let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
132
+ // Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
133
+ // transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
134
+ const latestUser = [...history].reverse().find((m) => m.role === "user");
135
+ const sensitive = latestUser?.role === "user" ? redactSensitiveText(latestUser.content).redactions : [];
136
+ if (sensitive.length && !opts.quiet) {
137
+ const note = "⚠ possible credential detected — the saved session copy will be redacted; prefer passing secrets through environment variables.";
138
+ if (ctx.ui)
139
+ ctx.ui.notice(note);
140
+ else if (stdout.isTTY)
141
+ out(c.yellow(note + "\n"));
142
+ }
121
143
  // Stuck/loop guard — only in headless chat (`hara gateway`), where a wrong approach can grind forever with
122
144
  // nobody to hit Esc (e.g. screenshots it can't read). Once per run, when the agent keeps repeating one
123
145
  // non-read tool or acting blind, we inject a reflection nudge so it steps back instead of spinning.
package/dist/config.js CHANGED
@@ -49,6 +49,28 @@ export function readRawConfig() {
49
49
  return {};
50
50
  }
51
51
  }
52
+ const ROUTING_CONFIG_KEYS = new Set(["provider", "apiKey", "model", "baseURL"]);
53
+ /** Empty routing values are not meaningful credentials/endpoints. Ignore them at each precedence layer so
54
+ * an empty project override (or launcher-exported empty env var) cannot hide a valid global config value. */
55
+ function withoutBlankRoutingValues(input) {
56
+ const out = {};
57
+ for (const [key, value] of Object.entries(input)) {
58
+ if (ROUTING_CONFIG_KEYS.has(key) && typeof value === "string") {
59
+ const trimmed = value.trim();
60
+ if (!trimmed)
61
+ continue;
62
+ out[key] = trimmed;
63
+ }
64
+ else {
65
+ out[key] = value;
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ function nonBlankEnv(value) {
71
+ const trimmed = value?.trim();
72
+ return trimmed || undefined;
73
+ }
52
74
  /** Nearest project override `.hara/config.json`, searching cwd up to the repo root. */
53
75
  function readProjectConfig(cwd) {
54
76
  let dir = resolve(cwd);
@@ -121,12 +143,16 @@ export function loadConfig(opts = {}) {
121
143
  const overlayName = process.env.HARA_OVERLAY ?? opts.overlay;
122
144
  const overlayMap = overlays && typeof overlays === "object" ? overlays : profiles && typeof profiles === "object" ? profiles : null;
123
145
  const overlay = overlayName && overlayMap && overlayMap[overlayName] ? overlayMap[overlayName] : {};
124
- const merged = { ...globalBase, ...project, ...overlay };
125
- const provider = (process.env.HARA_PROVIDER ?? merged.provider ?? "anthropic");
146
+ const merged = {
147
+ ...withoutBlankRoutingValues(globalBase),
148
+ ...withoutBlankRoutingValues(project),
149
+ ...withoutBlankRoutingValues(overlay),
150
+ };
151
+ const provider = (nonBlankEnv(process.env.HARA_PROVIDER) ?? merged.provider ?? "anthropic");
126
152
  const d = PROVIDER_DEFAULTS[provider] ?? PROVIDER_DEFAULTS.anthropic;
127
- const model = process.env.HARA_MODEL ?? merged.model ?? d.model;
128
- const baseURL = process.env.HARA_BASE_URL ?? merged.baseURL ?? d.baseURL;
129
- const apiKey = process.env.HARA_API_KEY ?? process.env[d.envKey] ?? merged.apiKey;
153
+ const model = nonBlankEnv(process.env.HARA_MODEL) ?? merged.model ?? d.model;
154
+ const baseURL = nonBlankEnv(process.env.HARA_BASE_URL) ?? merged.baseURL ?? d.baseURL;
155
+ const apiKey = nonBlankEnv(process.env.HARA_API_KEY) ?? nonBlankEnv(process.env[d.envKey]) ?? merged.apiKey;
130
156
  const approval = (process.env.HARA_APPROVAL ?? merged.approval ?? "suggest");
131
157
  const sandbox = (process.env.HARA_SANDBOX ?? merged.sandbox ?? "off");
132
158
  const theme = (process.env.HARA_THEME ?? merged.theme ?? "dark");
package/dist/feedback.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // humans and agents file through the same door (gh CLI when present, copy-paste text otherwise).
4
4
  // The session tail is OFF by default and explicitly opt-in (--session) because issues are public.
5
5
  import { platform, release, arch } from "node:os";
6
+ import { redactSensitiveText } from "./security/secrets.js";
6
7
  export function collectEnv(version, model) {
7
8
  return {
8
9
  version,
@@ -14,13 +15,7 @@ export function collectEnv(version, model) {
14
15
  /** Strip credential-looking material from text that is about to become a PUBLIC issue.
15
16
  * Deliberately aggressive: false positives cost a little readability, false negatives leak keys. */
16
17
  export function redact(text) {
17
- return text
18
- .replace(/\bsk-[A-Za-z0-9_-]{8,}/g, "sk-***")
19
- .replace(/\bgh[pousr]_[A-Za-z0-9]{20,}/g, "gh*_***")
20
- .replace(/\b(AKIA|ASIA)[A-Z0-9]{16}\b/g, "AWS-KEY-***")
21
- .replace(/\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/gi, "Bearer ***")
22
- .replace(/\b(api[_-]?key|apikey|token|secret|password|passwd|authorization)(["']?\s*[:=]\s*["']?)[^\s"',;]{6,}/gi, "$1$2***")
23
- .replace(/\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, "JWT-***");
18
+ return redactSensitiveText(text).text;
24
19
  }
25
20
  /** The structured issue body — same shape as .github/ISSUE_TEMPLATE/bug_report.yml so hand-filed
26
21
  * and command-filed issues read identically to the triage side. */
package/dist/index.js CHANGED
@@ -1574,9 +1574,21 @@ program
1574
1574
  version: pkg.version,
1575
1575
  providerId: cfg.provider,
1576
1576
  model: cfg.model,
1577
- buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
1578
- buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
1579
- listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
1577
+ // `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
1578
+ // new/resumed session and model operation so a repaired/rotated key takes effect without restarting
1579
+ // the desktop server (and, critically, never ask for a key that is already on disk).
1580
+ buildSessionProvider: async () => {
1581
+ const live = loadConfig();
1582
+ return withRouting(await buildProvider(live), live);
1583
+ },
1584
+ buildProviderFor: async (model, effort) => {
1585
+ const live = loadConfig();
1586
+ return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
1587
+ },
1588
+ listModels: () => {
1589
+ const live = loadConfig();
1590
+ return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
1591
+ },
1580
1592
  effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
1581
1593
  spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1582
1594
  guardian: guardianOpt,
@@ -0,0 +1,75 @@
1
+ /** Shared secret redaction for anything that may leave the live model context (session JSON, public
2
+ * feedback, logs). Deliberately conservative: a false positive hides a value in a local transcript;
3
+ * a false negative leaves a credential on disk. */
4
+ const PATTERNS = [
5
+ {
6
+ label: "private-key",
7
+ re: /-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g,
8
+ replace: "<REDACTED:private-key>",
9
+ },
10
+ { label: "sk-key", re: /\bsk-[A-Za-z0-9_-]{8,}\b/g, replace: "sk-***" },
11
+ { label: "github-token", re: /\bgh[pousr]_[A-Za-z0-9]{20,}\b/g, replace: "gh*_***" },
12
+ { label: "aws-key", re: /\b(?:AKIA|ASIA)[A-Z0-9]{16}\b/g, replace: "AWS-KEY-***" },
13
+ { label: "jwt", re: /\beyJ[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, replace: "JWT-***" },
14
+ {
15
+ label: "bearer-token",
16
+ re: /\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi,
17
+ replace: "Bearer ***",
18
+ },
19
+ {
20
+ // Generic all-caps environment variables such as OPENAI_KEY / SOME_SERVICE_PRIVATE_KEY. Keep this
21
+ // case-sensitive so ordinary prose/code identifiers ending in "key" are not over-redacted.
22
+ label: "environment-credential",
23
+ re: /(\b[A-Z][A-Z0-9_]*(?:_KEY|_SECRET|_TOKEN|_PASSWORD|_PASSWD)\b\s*=\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/g,
24
+ replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
25
+ },
26
+ {
27
+ // FEISHU_APP_SECRET=… · apiKey: "…" · "access_token": "…". The optional quote immediately
28
+ // after the key handles JSON without consuming the opening quote of the value.
29
+ label: "credential-assignment",
30
+ re: /(\b(?:[A-Za-z][A-Za-z0-9_.-]*(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*|(?:api[_-]?key|apikey|secret|token|password|passwd)[A-Za-z0-9_.-]*)\b["']?\s*[:=]\s*)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
31
+ replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
32
+ },
33
+ {
34
+ label: "authorization",
35
+ re: /(\bAuthorization\s*[:=]\s*)(?!Bearer\s+)(["']?)([^\s"',;}\]]{6,})(["']?)/gi,
36
+ replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
37
+ },
38
+ {
39
+ label: "credential-flag",
40
+ re: /(--?(?:api[-_]?key|token|secret|password|passwd)(?:\s+|=))(["']?)([^\s"']{6,})(["']?)/gi,
41
+ replace: (_match, prefix, open, _value, close) => `${prefix}${open}***${close}`,
42
+ },
43
+ ];
44
+ export function redactSensitiveText(text) {
45
+ let out = text;
46
+ const redactions = [];
47
+ for (const pattern of PATTERNS) {
48
+ pattern.re.lastIndex = 0;
49
+ out = out.replace(pattern.re, (...args) => {
50
+ redactions.push(pattern.label);
51
+ return typeof pattern.replace === "string" ? pattern.replace : pattern.replace(...args);
52
+ });
53
+ }
54
+ return { text: out, redactions };
55
+ }
56
+ /** Deep-copy a JSON-shaped value while redacting every string. Session history contains secrets not only
57
+ * in user text but also in assistant tool inputs and tool results, so a top-level content-only pass is
58
+ * insufficient. The live value is never mutated. */
59
+ export function redactSensitiveValue(value) {
60
+ const hits = [];
61
+ const walk = (v) => {
62
+ if (typeof v === "string") {
63
+ const r = redactSensitiveText(v);
64
+ hits.push(...r.redactions);
65
+ return r.text;
66
+ }
67
+ if (Array.isArray(v))
68
+ return v.map(walk);
69
+ if (v && typeof v === "object") {
70
+ return Object.fromEntries(Object.entries(v).map(([k, child]) => [k, walk(child)]));
71
+ }
72
+ return v;
73
+ };
74
+ return { value: walk(value), redactions: hits };
75
+ }
@@ -223,10 +223,10 @@ export async function startServe(opts, deps) {
223
223
  case "session.create": {
224
224
  const provider = await deps.buildSessionProvider();
225
225
  if (!provider)
226
- return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
226
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
227
227
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
228
228
  const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
229
- const s = hub.create({ cwd, provider, providerId: deps.providerId, model: deps.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
229
+ const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
230
230
  return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
231
231
  }
232
232
  case "session.resume": {
@@ -234,7 +234,7 @@ export async function startServe(opts, deps) {
234
234
  return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
235
235
  const provider = await deps.buildSessionProvider();
236
236
  if (!provider)
237
- return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
237
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
238
238
  const r = hub.resume(p.sessionId, { provider, approval: deps.approval, projectContext: undefined });
239
239
  if ("missing" in r)
240
240
  return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
@@ -305,8 +305,8 @@ export async function startServe(opts, deps) {
305
305
  return reply(rpcError(id, ERR.PARAMS, "sessionId required"));
306
306
  const provider = await deps.buildSessionProvider();
307
307
  if (!provider)
308
- return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — run `hara setup`"));
309
- const r = hub.fork(p.sessionId, { provider, providerId: deps.providerId, approval: deps.approval, projectContext: undefined });
308
+ return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
309
+ const r = hub.fork(p.sessionId, { provider, providerId: provider.id, approval: deps.approval, projectContext: undefined });
310
310
  if ("missing" in r)
311
311
  return reply(rpcError(id, ERR.NO_SESSION, `no session ${p.sessionId}`));
312
312
  r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
@@ -42,6 +42,10 @@ export class SessionHub {
42
42
  if (!lock.ok)
43
43
  return { lockedBy: lock.pid ?? 0 };
44
44
  const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, abort: null };
45
+ // A rotated config may change the provider/model between runs. The resumed transcript is preserved,
46
+ // while new turns and subsequent persistence accurately reflect the live route.
47
+ s.meta.provider = o.provider.id;
48
+ s.meta.model = o.provider.model;
45
49
  this.sessions.set(id, s);
46
50
  return { session: s };
47
51
  }
@@ -101,7 +105,7 @@ export class SessionHub {
101
105
  id: newSessionId(),
102
106
  cwd: src.meta.cwd,
103
107
  provider: o.providerId,
104
- model: src.meta.model,
108
+ model: o.provider.model,
105
109
  title: src.meta.title ? `${src.meta.title} ⑂` : "",
106
110
  createdAt: new Date().toISOString(),
107
111
  updatedAt: "",
@@ -1,8 +1,9 @@
1
1
  // Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { redactSensitiveValue } from "../security/secrets.js";
6
7
  /** Derive the session source from the spawn environment — the gateway subprocess runs with
7
8
  * HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
8
9
  export function sessionSourceFromEnv() {
@@ -20,7 +21,8 @@ export function automatedTitle(source, sourceName, at = new Date()) {
20
21
  }
21
22
  function sessionsDir() {
22
23
  const d = join(homedir(), ".hara", "sessions");
23
- mkdirSync(d, { recursive: true });
24
+ mkdirSync(d, { recursive: true, mode: 0o700 });
25
+ chmodSync(d, 0o700); // tighten legacy installs too; session transcripts are private
24
26
  return d;
25
27
  }
26
28
  const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
@@ -157,39 +159,79 @@ export function slugify(text, max = 40) {
157
159
  export function saveSession(meta, history) {
158
160
  meta.updatedAt = new Date().toISOString();
159
161
  const data = { meta, history };
160
- writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
162
+ // Redact a deep COPY: the live turn may still need a credential the user supplied, but the durable
163
+ // transcript never should. Tool inputs/results are included, not just user message content.
164
+ const safe = redactSensitiveValue(data).value;
165
+ // Keep structural routing/identity fields stable even if a path/model happens to resemble a secret.
166
+ safe.meta.id = meta.id;
167
+ safe.meta.cwd = meta.cwd;
168
+ safe.meta.provider = meta.provider;
169
+ safe.meta.model = meta.model;
170
+ safe.meta.createdAt = meta.createdAt;
171
+ safe.meta.updatedAt = meta.updatedAt;
172
+ const target = sessionFile(meta.id);
173
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
174
+ let fd;
175
+ try {
176
+ fd = openSync(tmp, "wx", 0o600);
177
+ writeFileSync(fd, JSON.stringify(safe, null, 2), "utf8");
178
+ fsyncSync(fd);
179
+ closeSync(fd);
180
+ fd = undefined;
181
+ renameSync(tmp, target); // same filesystem: readers see the complete old or complete new JSON
182
+ chmodSync(target, 0o600);
183
+ }
184
+ catch (error) {
185
+ if (fd !== undefined) {
186
+ try {
187
+ closeSync(fd);
188
+ }
189
+ catch {
190
+ /* preserve the original error */
191
+ }
192
+ }
193
+ try {
194
+ rmSync(tmp, { force: true });
195
+ }
196
+ catch {
197
+ /* preserve the original error */
198
+ }
199
+ throw error;
200
+ }
161
201
  }
162
202
  /** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
163
203
  function isSessionData(d) {
164
204
  const o = d;
165
205
  return !!o && typeof o === "object" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
166
206
  }
167
- export function loadSession(id) {
168
- const p = sessionFile(id);
169
- if (!existsSync(p))
170
- return null;
207
+ /** Parse a legacy session and redact the in-memory copy. Reads stay read-only (no unlocked migration race);
208
+ * the next explicit save atomically replaces the old file with its private, redacted form. */
209
+ function readSessionFile(p) {
171
210
  try {
172
211
  const d = JSON.parse(readFileSync(p, "utf8"));
173
- return isSessionData(d) ? d : null; // a corrupt / hand-edited file resumes as "no session" instead of crashing
212
+ if (!isSessionData(d))
213
+ return null;
214
+ return redactSensitiveValue(d).value;
174
215
  }
175
216
  catch {
176
217
  return null;
177
218
  }
178
219
  }
220
+ export function loadSession(id) {
221
+ const p = sessionFile(id);
222
+ if (!existsSync(p))
223
+ return null;
224
+ return readSessionFile(p); // a corrupt / hand-edited file resumes as "no session" instead of crashing
225
+ }
179
226
  /** Session metas, newest first; optionally filtered to a cwd. */
180
227
  export function listSessions(cwd) {
181
228
  let metas = [];
182
229
  for (const f of readdirSync(sessionsDir())) {
183
230
  if (!f.endsWith(".json"))
184
231
  continue;
185
- try {
186
- const d = JSON.parse(readFileSync(join(sessionsDir(), f), "utf8"));
187
- if (d?.meta && typeof d.meta === "object" && d.meta.id && d.meta.updatedAt)
188
- metas.push(d.meta); // skip metaless/corrupt
189
- }
190
- catch {
191
- /* skip corrupt */
192
- }
232
+ const d = readSessionFile(join(sessionsDir(), f));
233
+ if (d?.meta.id && d.meta.updatedAt)
234
+ metas.push(d.meta); // skip metaless/corrupt; never mutate while listing
193
235
  }
194
236
  if (cwd)
195
237
  metas = metas.filter((m) => m.cwd === cwd);
@@ -1,4 +1,6 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
2
4
  import { resolve, isAbsolute } from "node:path";
3
5
  import { stdout as procOut } from "node:process";
4
6
  import { registerTool } from "./registry.js";
@@ -12,6 +14,35 @@ import { BinaryFileError, streamFileSlice } from "../fs-read.js";
12
14
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
13
15
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
14
16
  const MAX = 100_000;
17
+ /** Package installs are network-bound and routinely exceed the foreground cap. When the model omits an
18
+ * explicit foreground/background choice, detach these commands into the existing job system so the UI
19
+ * remains responsive and the agent can poll output instead of waiting five minutes for a hard kill. */
20
+ export function isPackageInstallCommand(command) {
21
+ return /(?:^|[;&|]\s*)(?:npm\s+(?:i|install|ci)\b|pnpm\s+(?:i|install|add)\b|yarn(?:\s+(?:install|add))?(?:\s|$)|bun\s+(?:i|install|add)\b)/i.test(command.trim());
22
+ }
23
+ export function isNgrokTunnelCommand(command) {
24
+ return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
25
+ }
26
+ /** Read only the presence of ngrok auth — never return or print its value. */
27
+ export function ngrokAuthConfigured(env = process.env, home = homedir()) {
28
+ if (env.NGROK_AUTHTOKEN || env.NGROK_API_KEY)
29
+ return true;
30
+ const files = [
31
+ resolve(home, ".config/ngrok/ngrok.yml"),
32
+ resolve(home, "Library/Application Support/ngrok/ngrok.yml"),
33
+ resolve(home, ".ngrok2/ngrok.yml"),
34
+ ];
35
+ for (const file of files) {
36
+ try {
37
+ if (existsSync(file) && /^\s*(?:authtoken|api_key)\s*:\s*\S+/im.test(readFileSync(file, "utf8")))
38
+ return true;
39
+ }
40
+ catch {
41
+ /* unreadable config counts as unconfigured */
42
+ }
43
+ }
44
+ return false;
45
+ }
15
46
  /** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
16
47
  * repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
17
48
  * called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
@@ -150,16 +181,21 @@ registerTool({
150
181
  type: "object",
151
182
  properties: {
152
183
  command: { type: "string" },
153
- timeout_ms: { type: "number", description: "default 300000 (5 min); raise for a long build/transform, or use background:true for a server" },
154
- background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); returns a job id immediately tail/kill it with the `job` tool" },
184
+ timeout_ms: { type: "number", description: "default 300000 (5 min); set explicitly for a foreground long build/transform" },
185
+ background: { type: "boolean", description: "run as a background job (dev server, watcher, long task). Package installs auto-background when this field and timeout_ms are omitted. Poll with the `job` tool before depending on completion." },
155
186
  },
156
187
  required: ["command"],
157
188
  },
158
189
  kind: "exec",
159
190
  async run(input, ctx) {
160
- if (input.background) {
191
+ if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
192
+ return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
193
+ "Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
194
+ }
195
+ const autoPackageJob = input.background === undefined && input.timeout_ms === undefined && isPackageInstallCommand(input.command);
196
+ if (input.background || autoPackageJob) {
161
197
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
162
- return `Started background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all.`;
198
+ return `${autoPackageJob ? "Package install auto-started" : "Started"} background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on installed packages.`;
163
199
  }
164
200
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
165
201
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
package/dist/tools/web.js CHANGED
@@ -7,6 +7,8 @@ import { lookup } from "node:dns/promises";
7
7
  import { isIP } from "node:net";
8
8
  import { wrapUntrusted } from "../security/external-content.js";
9
9
  const MAX = 60_000;
10
+ const SEARCH_ATTEMPT_MS = 8_000;
11
+ const SEARCH_UA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 Chrome/124.0 Safari/537.36";
10
12
  /** True for loopback / private / link-local / ULA / CGNAT addresses we must not let web_fetch reach. */
11
13
  export function isPrivateIp(ip) {
12
14
  const host = ip.replace(/^\[|\]$/g, "");
@@ -115,6 +117,146 @@ export function parseSearchResults(html, limit) {
115
117
  }
116
118
  return out;
117
119
  }
120
+ /** Parse Baidu's server-rendered result headings. Baidu result links are redirects, which is fine:
121
+ * web_fetch follows redirects while re-running its SSRF check on every hop. This gives mainland users a
122
+ * keyless search path that does not depend on an overseas API being reachable. */
123
+ export function parseBaiduSearchResults(html, limit) {
124
+ const strip = (s) => s
125
+ .replace(/<script[\s\S]*?<\/script>/gi, " ")
126
+ .replace(/<style[\s\S]*?<\/style>/gi, " ")
127
+ .replace(/<[^>]+>/g, " ")
128
+ .replace(/&nbsp;|&#160;/gi, " ")
129
+ .replace(/&amp;/gi, "&")
130
+ .replace(/&lt;/gi, "<")
131
+ .replace(/&gt;/gi, ">")
132
+ .replace(/&quot;/gi, '"')
133
+ .replace(/&#39;|&#x27;/gi, "'")
134
+ .replace(/\s+/g, " ")
135
+ .trim();
136
+ const headings = [];
137
+ const re = /<h3\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h3>/gi;
138
+ let m;
139
+ while ((m = re.exec(html)) && headings.length < limit) {
140
+ const title = strip(m[3]);
141
+ const url = (m[1] ?? m[2] ?? "").replace(/&amp;/g, "&");
142
+ if (title && /^https?:\/\//i.test(url))
143
+ headings.push({ at: m.index, end: re.lastIndex, title, url });
144
+ }
145
+ return headings.map((h, i) => {
146
+ // The abstract normally sits between this heading and the next result heading. Strip that bounded
147
+ // slice and cap it; if Baidu changes class names the title/link still survive.
148
+ const boundary = headings[i + 1]?.at ?? Math.min(html.length, h.end + 1800);
149
+ const snippet = strip(html.slice(h.end, boundary)).slice(0, 240);
150
+ return { title: h.title, url: h.url, snippet };
151
+ });
152
+ }
153
+ /** Parse Bing's stable server-rendered result list (`cn.bing.com` is reachable in the mainland network
154
+ * where overseas agent-search APIs commonly fail). */
155
+ export function parseBingSearchResults(html, limit) {
156
+ const strip = (s) => s
157
+ .replace(/<[^>]+>/g, " ")
158
+ .replace(/&nbsp;|&#160;/gi, " ")
159
+ .replace(/&ensp;|&#8194;/gi, " ")
160
+ .replace(/&emsp;|&#8195;/gi, " ")
161
+ .replace(/&amp;/gi, "&")
162
+ .replace(/&lt;/gi, "<")
163
+ .replace(/&gt;/gi, ">")
164
+ .replace(/&quot;/gi, '"')
165
+ .replace(/&#39;|&#x27;/gi, "'")
166
+ .replace(/\s+/g, " ")
167
+ .trim();
168
+ const out = [];
169
+ const blockRe = /<li\b[^>]*class=(?:"[^"]*\bb_algo\b[^"]*"|'[^']*\bb_algo\b[^']*')[^>]*>([\s\S]*?)<\/li>/gi;
170
+ let block;
171
+ while ((block = blockRe.exec(html)) && out.length < limit) {
172
+ const link = /<h2\b[^>]*>[\s\S]*?<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>([\s\S]*?)<\/a>[\s\S]*?<\/h2>/i.exec(block[1]);
173
+ if (!link)
174
+ continue;
175
+ const url = (link[1] ?? link[2] ?? "").replace(/&amp;/g, "&");
176
+ const title = strip(link[3]);
177
+ if (!title || !/^https?:\/\//i.test(url))
178
+ continue;
179
+ const paragraph = /<p\b[^>]*>([\s\S]*?)<\/p>/i.exec(block[1]);
180
+ out.push({ title, url, snippet: strip(paragraph?.[1] ?? "").slice(0, 240) });
181
+ }
182
+ return out;
183
+ }
184
+ /** Best-effort parser for Google's classic HTML result shape. Google often serves a redirect/challenge
185
+ * shell in mainland environments, so this is a fallback, not the sole search path. */
186
+ export function parseGoogleSearchResults(html, limit) {
187
+ const strip = (s) => s
188
+ .replace(/<[^>]+>/g, " ")
189
+ .replace(/&amp;/gi, "&")
190
+ .replace(/&lt;/gi, "<")
191
+ .replace(/&gt;/gi, ">")
192
+ .replace(/&quot;/gi, '"')
193
+ .replace(/&#39;|&#x27;/gi, "'")
194
+ .replace(/\s+/g, " ")
195
+ .trim();
196
+ const out = [];
197
+ const linkRe = /<a\b[^>]*href=(?:"([^"]+)"|'([^']+)')[^>]*>[\s\S]{0,1600}?<h3\b[^>]*>([\s\S]*?)<\/h3>[\s\S]*?<\/a>/gi;
198
+ let m;
199
+ while ((m = linkRe.exec(html)) && out.length < limit) {
200
+ let url = (m[1] ?? m[2] ?? "").replace(/&amp;/g, "&");
201
+ if (url.startsWith("/url?")) {
202
+ try {
203
+ url = new URL(url, "https://www.google.com").searchParams.get("q") ?? "";
204
+ }
205
+ catch {
206
+ url = "";
207
+ }
208
+ }
209
+ const title = strip(m[3]);
210
+ if (!title || !/^https?:\/\//i.test(url) || /(?:^|\.)google\.[^/]+\//i.test(url))
211
+ continue;
212
+ out.push({ title, url, snippet: "" });
213
+ }
214
+ return out;
215
+ }
216
+ /** Detect the common HTML shell returned by SPAs (root element + scripts/loading text, no readable body).
217
+ * web_fetch intentionally does not execute arbitrary page JavaScript, so surfacing the limitation is
218
+ * better than returning a misleading successful "(empty body)". */
219
+ export function looksLikeJsRenderedShell(html, readable) {
220
+ if (readable.trim().length >= 180)
221
+ return false;
222
+ const hasRoot = /<(?:div|main)\b[^>]*(?:id=["'](?:root|app|__next)["']|data-reactroot)/i.test(html);
223
+ const scripts = (html.match(/<script\b/gi) ?? []).length;
224
+ const shellText = /(?:enable javascript|javascript is required|loading[.…]*|正在加载|请启用\s*javascript)/i.test(readable || html);
225
+ return (hasRoot && scripts > 0) || (scripts >= 2 && readable.trim().length < 40) || shellText;
226
+ }
227
+ async function searchFetch(url, init) {
228
+ return fetch(url, { ...init, signal: AbortSignal.timeout(SEARCH_ATTEMPT_MS) });
229
+ }
230
+ async function firstSuccessfulSearch(attempts, failures) {
231
+ if (!attempts.length)
232
+ return null;
233
+ return new Promise((resolve) => {
234
+ let remaining = attempts.length;
235
+ let settled = false;
236
+ for (const attempt of attempts) {
237
+ void attempt
238
+ .run()
239
+ .then((results) => {
240
+ if (results.length && !settled) {
241
+ settled = true;
242
+ resolve(results);
243
+ }
244
+ else if (!results.length) {
245
+ failures.push(`${attempt.name} no results`);
246
+ }
247
+ })
248
+ .catch((e) => {
249
+ const reason = e?.name === "TimeoutError" || e?.name === "AbortError" ? "timeout" : (e?.message ?? String(e));
250
+ failures.push(`${attempt.name} ${reason}`);
251
+ })
252
+ .finally(() => {
253
+ remaining--;
254
+ if (remaining === 0 && !settled)
255
+ resolve(null);
256
+ });
257
+ }
258
+ });
259
+ }
118
260
  registerTool({
119
261
  name: "web_search",
120
262
  description: "Search the web and return the top results (title, URL, snippet). Use it to FIND information or pages you " +
@@ -135,51 +277,90 @@ registerTool({
135
277
  return "(empty query)";
136
278
  const limit = Math.min(Math.max(1, Number(input.limit) || 6), 10);
137
279
  const fmt = (rs) => rs.map((r, n) => `${n + 1}. ${r.title}\n ${r.url}${r.snippet ? `\n ${r.snippet}` : ""}`).join("\n\n");
138
- const ctrl = new AbortController();
139
- const timer = setTimeout(() => ctrl.abort(), 20_000);
140
- try {
141
- // Reliable path: Tavily (designed for agents, free tier) when a key is configured.
142
- const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
143
- if (key) {
144
- const res = await fetch("https://api.tavily.com/search", {
145
- method: "POST",
146
- signal: ctrl.signal,
147
- headers: { "content-type": "application/json" },
148
- body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
149
- });
150
- if (res.ok) {
280
+ const failures = [];
281
+ // Race the configured agent-search API against a mainland-accessible HTML source. A blocked Tavily
282
+ // endpoint no longer adds an 8-second penalty before Hara starts the domestic fallback.
283
+ const key = process.env.HARA_SEARCH_API_KEY || process.env.TAVILY_API_KEY;
284
+ const primaryAttempts = [];
285
+ if (key) {
286
+ primaryAttempts.push({
287
+ name: "Tavily",
288
+ run: async () => {
289
+ const res = await searchFetch("https://api.tavily.com/search", {
290
+ method: "POST",
291
+ headers: { "content-type": "application/json" },
292
+ body: JSON.stringify({ api_key: key, query: q, max_results: limit }),
293
+ });
294
+ if (!res.ok)
295
+ throw new Error(`HTTP ${res.status}`);
151
296
  const j = (await res.json());
152
- const rs = (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
153
- if (rs.length)
154
- return wrapUntrusted(fmt(rs), `web_search: ${q}`);
155
- }
156
- // Tavily failed → fall through to the keyless best-effort path.
157
- }
158
- // Keyless fallback: DuckDuckGo HTML (POST — GET returns a 202 challenge). May be rate-limited.
159
- const res = await fetch("https://html.duckduckgo.com/html/", {
160
- method: "POST",
161
- signal: ctrl.signal,
162
- redirect: "follow",
163
- headers: {
164
- "user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
165
- "content-type": "application/x-www-form-urlencoded",
166
- accept: "text/html",
297
+ return (j.results ?? []).map((x) => ({ title: String(x.title ?? x.url ?? ""), url: String(x.url ?? ""), snippet: String(x.content ?? "").slice(0, 200) }));
167
298
  },
168
- body: `q=${encodeURIComponent(q)}`,
169
299
  });
170
- if (!res.ok)
171
- return `Search failed: HTTP ${res.status}. Keyless search is rate-limited — set HARA_SEARCH_API_KEY (Tavily) for reliable search, or web_fetch a known URL.`;
172
- const results = parseSearchResults(await res.text(), limit);
173
- if (!results.length)
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.)";
175
- return wrapUntrusted(fmt(results), `web_search: ${q}`);
176
- }
177
- catch (e) {
178
- return `Search failed: ${e?.name === "AbortError" ? "timed out (20s)" : (e?.message ?? e)}`;
179
- }
180
- finally {
181
- clearTimeout(timer);
182
300
  }
301
+ primaryAttempts.push({
302
+ name: "Bing CN",
303
+ run: async () => {
304
+ const res = await searchFetch(`https://cn.bing.com/search?q=${encodeURIComponent(q)}&count=${limit}&setlang=zh-Hans`, {
305
+ method: "GET",
306
+ redirect: "follow",
307
+ headers: { "user-agent": SEARCH_UA, accept: "text/html" },
308
+ });
309
+ if (!res.ok)
310
+ throw new Error(`HTTP ${res.status}`);
311
+ return parseBingSearchResults(await res.text(), limit);
312
+ },
313
+ });
314
+ const primary = await firstSuccessfulSearch(primaryAttempts, failures);
315
+ if (primary)
316
+ return wrapUntrusted(fmt(primary), `web_search: ${q}`);
317
+ // Secondary sources run concurrently, keeping total failure latency bounded. Google is included as a
318
+ // user-friendly fallback where it is reachable, but is never the sole path (mainland often gets a shell).
319
+ const secondary = await firstSuccessfulSearch([
320
+ {
321
+ name: "Baidu",
322
+ run: async () => {
323
+ const res = await searchFetch(`https://www.baidu.com/s?wd=${encodeURIComponent(q)}&rn=${limit}`, {
324
+ method: "GET",
325
+ redirect: "follow",
326
+ headers: { "user-agent": SEARCH_UA, accept: "text/html" },
327
+ });
328
+ if (!res.ok)
329
+ throw new Error(`HTTP ${res.status}`);
330
+ return parseBaiduSearchResults(await res.text(), limit);
331
+ },
332
+ },
333
+ {
334
+ name: "Google",
335
+ run: async () => {
336
+ const res = await searchFetch(`https://www.google.com/search?q=${encodeURIComponent(q)}&num=${limit}&hl=zh-CN`, {
337
+ method: "GET",
338
+ redirect: "follow",
339
+ headers: { "user-agent": SEARCH_UA, accept: "text/html" },
340
+ });
341
+ if (!res.ok)
342
+ throw new Error(`HTTP ${res.status}`);
343
+ return parseGoogleSearchResults(await res.text(), limit);
344
+ },
345
+ },
346
+ {
347
+ name: "DuckDuckGo",
348
+ run: async () => {
349
+ const res = await searchFetch("https://html.duckduckgo.com/html/", {
350
+ method: "POST",
351
+ redirect: "follow",
352
+ headers: { "user-agent": SEARCH_UA, "content-type": "application/x-www-form-urlencoded", accept: "text/html" },
353
+ body: `q=${encodeURIComponent(q)}`,
354
+ });
355
+ if (!res.ok)
356
+ throw new Error(`HTTP ${res.status}`);
357
+ return parseSearchResults(await res.text(), limit);
358
+ },
359
+ },
360
+ ], failures);
361
+ if (secondary)
362
+ return wrapUntrusted(fmt(secondary), `web_search: ${q}`);
363
+ return `Search failed across available providers (${failures.join("; ")}). Check connectivity/proxy, configure HARA_SEARCH_API_KEY, or web_fetch a known URL.`;
183
364
  },
184
365
  });
185
366
  registerTool({
@@ -232,6 +413,11 @@ registerTool({
232
413
  let text = /html/i.test(ct) ? htmlToText(raw) : raw;
233
414
  if (text.length > cap)
234
415
  text = text.slice(0, cap) + `\n…[truncated ${text.length - cap} chars]`;
416
+ if (/html/i.test(ct) && looksLikeJsRenderedShell(raw, text)) {
417
+ const hint = "This page appears to be JavaScript-rendered; web_fetch received only the SPA shell and does not execute page scripts. " +
418
+ "Use an available browser/web skill for the rendered page, or the site's authenticated API/connector (for example the Feishu Docs API).";
419
+ return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(`${text || "(empty shell)"}\n\n${hint}`, current.href)}`;
420
+ }
235
421
  return `# ${current.href} (HTTP ${res.status})\n\n${wrapUntrusted(text || "(empty body)", current.href)}`;
236
422
  }
237
423
  catch (e) {
package/dist/tui/run.js CHANGED
@@ -4,36 +4,39 @@
4
4
  import { render, Box, Text, useApp, useInput } from "ink";
5
5
  import { createElement } from "react";
6
6
  import { App } from "./App.js";
7
- export async function runTui(props) {
8
- const instance = render(createElement(App, props));
9
- // Resize repaint fix. ink 6.8's own resize handler only clears the screen when the terminal gets
10
- // NARROWER; on a WIDEN (or a resize it doesn't classify as narrowing) it just re-renders, so the old
11
- // frame reflowed at the new width is never erased, and the ~125ms spinner tick stacks a fresh copy
12
- // each time (the "moved the window and the UI stacked up" garble). We complement it: on ANY resize,
13
- // clear ink's tracked output so the next render starts clean. Debounced by a microtask so a burst of
14
- // resize events during a window drag collapses to one clear.
15
- const out = process.stdout;
16
- let pending = false;
7
+ /** Ink 6.8 clears before repainting when a terminal gets narrower, but not when it gets wider. Install a
8
+ * complementary WIDTH-only clear ahead of Ink's own resize listener so Ink's normal layout pass redraws
9
+ * the frame immediately afterwards. Never clear on a rows-only resize: `instance.clear()` erases the
10
+ * dynamic region without scheduling a React render, which made an idle input box disappear when a user
11
+ * dragged only the top/bottom edge of the terminal window. Exported for a small ordering regression test. */
12
+ export function installResizeRepaint(out, instance) {
13
+ let lastColumns = out.columns;
17
14
  const onResize = () => {
18
- if (pending)
15
+ const columns = out.columns;
16
+ if (!columns || columns === lastColumns)
19
17
  return;
20
- pending = true;
21
- queueMicrotask(() => {
22
- pending = false;
23
- try {
24
- instance.clear();
25
- }
26
- catch {
27
- /* best-effort — never let a repaint fix crash the session */
28
- }
29
- });
18
+ lastColumns = columns;
19
+ try {
20
+ instance.clear();
21
+ }
22
+ catch {
23
+ /* best-effort — never let a repaint fix crash the session */
24
+ }
30
25
  };
31
- out.on("resize", onResize);
26
+ // Ink registered its listener inside render() already. Prepending is essential: clearing AFTER Ink's
27
+ // repaint is precisely what leaves the prompt blank until some unrelated state update happens.
28
+ out.prependListener("resize", onResize);
29
+ return () => out.off("resize", onResize);
30
+ }
31
+ export async function runTui(props) {
32
+ const instance = render(createElement(App, props));
33
+ const out = process.stdout;
34
+ const removeResizeRepaint = installResizeRepaint(out, instance);
32
35
  try {
33
36
  await instance.waitUntilExit();
34
37
  }
35
38
  finally {
36
- out.off("resize", onResize);
39
+ removeResizeRepaint();
37
40
  }
38
41
  }
39
42
  // A tiny ink yes/no prompt for pre-TUI confirms (e.g. the first-run "create AGENTS.md?" offer).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanhara/hara",
3
- "version": "0.121.0",
3
+ "version": "0.121.1",
4
4
  "description": "hara — a coding agent CLI that runs like an engineering org.",
5
5
  "bin": {
6
6
  "hara": "dist/index.js"