@nanhara/hara 0.120.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/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,
@@ -2036,6 +2048,94 @@ program
2036
2048
  out(`\n(gh CLI unavailable or filing failed — copy everything below into ${NEW_ISSUE_URL})\n\n# ${issueTitle(desc)}\n\n${body}\n`);
2037
2049
  }
2038
2050
  });
2051
+ // `hara desk` — connect to a hara-desk coordination server (identity registry + task board).
2052
+ // The desk is the closed-source enterprise piece; this is the open-source client side.
2053
+ const deskCmd = program.command("desk").description("coordinate with a hara-desk server (register · post · board · claim · complete)");
2054
+ deskCmd
2055
+ .command("register")
2056
+ .description("register this agent with a desk and save credentials to ~/.hara/desk.json")
2057
+ .requiredOption("--url <url>", "desk base URL (e.g. http://127.0.0.1:4200)")
2058
+ .requiredOption("--key <enrollKey>", "the desk's enroll key")
2059
+ .option("--name <name>", "agent name shown in the registry", "hara-cli")
2060
+ .option("--owner <owner>", "the human this agent belongs to", "me")
2061
+ .action(async (o) => {
2062
+ const { registerAgent } = await import("./desk.js");
2063
+ try {
2064
+ const creds = await registerAgent(o.url, o.key, o.name, o.owner);
2065
+ out(c.green("✓ registered ") + `${creds.agentId} (owner ${creds.owner}) → ${creds.url}\n`);
2066
+ }
2067
+ catch (e) {
2068
+ out(c.red(`register failed: ${e.message}\n`));
2069
+ }
2070
+ });
2071
+ deskCmd
2072
+ .command("post [title...]")
2073
+ .description("post a task or feedback report to the board")
2074
+ .option("--dispatch", "a dispatch task (someone does work) instead of a feedback report")
2075
+ .option("--high", "high-risk dispatch (needs an owner ack before it can complete)")
2076
+ .option("--body <text>", "task body / details", "")
2077
+ .action(async (parts, o) => {
2078
+ const { loadCreds, deskCall } = await import("./desk.js");
2079
+ const creds = loadCreds();
2080
+ if (!creds)
2081
+ return void out(c.red("not registered — run `hara desk register --url … --key …` first\n"));
2082
+ const title = (parts ?? []).join(" ").trim();
2083
+ if (!title)
2084
+ return void out("Usage: hara desk post <title> [--dispatch] [--high] [--body …]\n");
2085
+ try {
2086
+ const r = await deskCall(creds.url, "POST", "/tasks", { token: creds.token, body: { kind: o.dispatch ? "dispatch" : "feedback", risk: o.high ? "high" : "low", title, body: o.body } });
2087
+ out(c.green("✓ posted ") + `${r.task.id} (${r.task.kind}/${r.task.state})\n`);
2088
+ }
2089
+ catch (e) {
2090
+ out(c.red(`post failed: ${e.message}\n`));
2091
+ }
2092
+ });
2093
+ deskCmd
2094
+ .command("board")
2095
+ .description("list the board (default: open tasks)")
2096
+ .option("--state <state>", "open | claimed | done | cancelled", "open")
2097
+ .option("--kind <kind>", "feedback | dispatch")
2098
+ .action(async (o) => {
2099
+ const { loadCreds, deskCall } = await import("./desk.js");
2100
+ const creds = loadCreds();
2101
+ if (!creds)
2102
+ return void out(c.red("not registered — run `hara desk register` first\n"));
2103
+ try {
2104
+ const q = `/tasks?state=${encodeURIComponent(o.state)}${o.kind ? `&kind=${encodeURIComponent(o.kind)}` : ""}`;
2105
+ const r = await deskCall(creds.url, "GET", q, { token: creds.token });
2106
+ if (!r.tasks.length)
2107
+ return void out(c.dim("(board empty)\n"));
2108
+ for (const t of r.tasks)
2109
+ out(`${t.id} ${c.bold(t.kind)}${t.risk === "high" ? c.red("!") : " "} ${t.state.padEnd(8)} ${t.title}\n`);
2110
+ }
2111
+ catch (e) {
2112
+ out(c.red(`board failed: ${e.message}\n`));
2113
+ }
2114
+ });
2115
+ for (const [verb, path, ok] of [
2116
+ ["claim", "claim", "claimed"],
2117
+ ["complete", "complete", "completed"],
2118
+ ["ack", "ack", "acked"],
2119
+ ["cancel", "cancel", "cancelled"],
2120
+ ]) {
2121
+ deskCmd
2122
+ .command(`${verb} <taskId>`)
2123
+ .description(`${verb} a task`)
2124
+ .option("--detail <text>", "note (complete)", "")
2125
+ .action(async (taskId, o) => {
2126
+ const { loadCreds, deskCall } = await import("./desk.js");
2127
+ const creds = loadCreds();
2128
+ if (!creds)
2129
+ return void out(c.red("not registered — run `hara desk register` first\n"));
2130
+ try {
2131
+ const r = await deskCall(creds.url, "POST", `/tasks/${taskId}/${path}`, { token: creds.token, body: verb === "complete" ? { detail: o.detail } : {} });
2132
+ out(c.green(`✓ ${ok} `) + `${r.task?.id ?? taskId}${r.task ? ` (${r.task.state})` : ""}\n`);
2133
+ }
2134
+ catch (e) {
2135
+ out(c.red(`${verb} failed: ${e.message}\n`));
2136
+ }
2137
+ });
2138
+ }
2039
2139
  const skillsCmd = program.command("skills").description("manage skills (.hara/skills/<name>/SKILL.md)");
2040
2140
  skillsCmd
2041
2141
  .command("init")
@@ -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,14 +1,48 @@
1
- import { readFile, writeFile, mkdir } from "node:fs/promises";
2
- import { dirname, resolve, isAbsolute } from "node:path";
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import { existsSync, readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { resolve, isAbsolute } from "node:path";
3
5
  import { stdout as procOut } from "node:process";
4
6
  import { registerTool } from "./registry.js";
5
7
  import { runShell } from "../sandbox.js";
6
- import { nearestPaths } from "../fs-walk.js";
8
+ import { isProbablyBinary, nearestPaths } from "../fs-walk.js";
7
9
  import { emitDiff } from "../diff.js";
8
10
  import { recordEdit } from "../undo.js";
11
+ import { atomicWriteText } from "../fs-write.js";
12
+ import { invalidateFileCandidates } from "../context/mentions.js";
13
+ import { BinaryFileError, streamFileSlice } from "../fs-read.js";
9
14
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
10
15
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
11
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
+ }
12
46
  /** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
13
47
  * repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
14
48
  * called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
@@ -43,8 +77,9 @@ export function capHeadTail(s, max = MAX) {
43
77
  const head = Math.floor(max * 0.6);
44
78
  return s.slice(0, head) + `\n…[${s.length - max} chars truncated]…\n` + s.slice(s.length - (max - head));
45
79
  }
46
- const READ_LINES = 2000; // default lines per read_file call (a long file is read in slices, not dumped whole)
80
+ const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
47
81
  const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
82
+ const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
48
83
  /** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
49
84
  * tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
50
85
  * re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
@@ -71,22 +106,31 @@ export function renderFileSlice(text, offset, limit) {
71
106
  }
72
107
  registerTool({
73
108
  name: "read_file",
74
- description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 2000 lines by default — for a longer file pass offset/limit to read the next slice (the output header tells you the total and where to continue). Prefer grep to locate, then read just that region.",
109
+ description: "Read a UTF-8 text file; returns cat -n style numbered lines. Reads up to 300 lines by default — for a longer file pass offset/limit to read the next slice (the header tells you where to continue). Large files are streamed instead of loaded whole. Prefer grep to locate, then read just that region.",
75
110
  input_schema: {
76
111
  type: "object",
77
112
  properties: {
78
113
  path: { type: "string", description: "File path, relative to cwd or absolute" },
79
114
  offset: { type: "number", description: "1-based line number to start from (for long files)" },
80
- limit: { type: "number", description: "max lines to return (default 2000)" },
115
+ limit: { type: "number", description: "max lines to return (default 300)" },
81
116
  },
82
117
  required: ["path"],
83
118
  },
84
119
  kind: "read",
85
120
  async run(input, ctx) {
121
+ const p = abs(input.path, ctx.cwd);
86
122
  try {
87
- return cap(renderFileSlice(await readFile(abs(input.path, ctx.cwd), "utf8"), input.offset, input.limit));
123
+ const info = await stat(p);
124
+ if (info.size > BUFFERED_READ_BYTES)
125
+ return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
126
+ const buf = await readFile(p);
127
+ if (isProbablyBinary(buf))
128
+ throw new BinaryFileError(p);
129
+ return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
88
130
  }
89
131
  catch (e) {
132
+ if (e instanceof BinaryFileError)
133
+ return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
90
134
  const near = nearestPaths(ctx.cwd, input.path);
91
135
  return `Error: cannot read ${input.path}: ${e.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
92
136
  }
@@ -106,17 +150,27 @@ registerTool({
106
150
  kind: "edit",
107
151
  async run(input, ctx) {
108
152
  const p = abs(input.path, ctx.cwd);
153
+ if (typeof input.content !== "string")
154
+ return "Error: write_file `content` must be a string. No changes written.";
109
155
  let prev = null;
110
156
  try {
111
157
  prev = await readFile(p, "utf8");
112
158
  }
113
- catch {
114
- /* new file */
159
+ catch (error) {
160
+ if (error?.code !== "ENOENT")
161
+ return `Error: cannot inspect ${input.path}: ${error?.code ?? error?.message}. No changes written.`;
162
+ }
163
+ if (prev === input.content)
164
+ return `Unchanged ${p} (${input.content.length} chars already match).`;
165
+ try {
166
+ await atomicWriteText(p, input.content, { expected: prev });
167
+ }
168
+ catch (error) {
169
+ return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
115
170
  }
116
- await mkdir(dirname(p), { recursive: true });
117
- await writeFile(p, input.content, "utf8");
118
171
  emitDiff(input.path, prev ?? "", input.content, ctx.ui);
119
172
  recordEdit([{ path: input.path, absPath: p, before: prev }]);
173
+ invalidateFileCandidates(ctx.cwd);
120
174
  return `Wrote ${String(input.content).length} chars to ${p}`;
121
175
  },
122
176
  });
@@ -127,16 +181,21 @@ registerTool({
127
181
  type: "object",
128
182
  properties: {
129
183
  command: { type: "string" },
130
- timeout_ms: { type: "number", description: "default 300000 (5 min); raise for a long build/transform, or use background:true for a server" },
131
- 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." },
132
186
  },
133
187
  required: ["command"],
134
188
  },
135
189
  kind: "exec",
136
190
  async run(input, ctx) {
137
- 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) {
138
197
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
139
- 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.`;
140
199
  }
141
200
  // Network fault tolerance — short-circuit if this command targets a host already found unreachable this
142
201
  // session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
@@ -1,10 +1,12 @@
1
- import { readFile, writeFile } from "node:fs/promises";
1
+ import { readFile } from "node:fs/promises";
2
2
  import { isAbsolute, resolve } from "node:path";
3
3
  import { registerTool } from "./registry.js";
4
4
  import { nearestPaths } from "../fs-walk.js";
5
5
  import { emitDiff } from "../diff.js";
6
6
  import { applyEdits } from "./apply-core.js";
7
7
  import { recordEdit } from "../undo.js";
8
+ import { atomicWriteText } from "../fs-write.js";
9
+ import { invalidateFileCandidates } from "../context/mentions.js";
8
10
  registerTool({
9
11
  name: "edit_file",
10
12
  description: "Edit an existing file by replacing exact strings. Provide a single `old_string`/`new_string`, " +
@@ -53,9 +55,15 @@ registerTool({
53
55
  const res = applyEdits(text, edits);
54
56
  if ("error" in res)
55
57
  return `Error: ${res.error} in ${input.path}. No changes written.`;
56
- await writeFile(p, res.text, "utf8");
58
+ try {
59
+ await atomicWriteText(p, res.text, { expected: text });
60
+ }
61
+ catch (error) {
62
+ return `Error: cannot edit ${input.path}: ${error?.message ?? String(error)} No changes written.`;
63
+ }
57
64
  emitDiff(input.path, text, res.text, ctx.ui);
58
65
  recordEdit([{ path: input.path, absPath: p, before: text }]);
66
+ invalidateFileCandidates(ctx.cwd);
59
67
  const note = res.fuzzy ? " (quote-normalized)" : "";
60
68
  const plural = (n, w) => `${n} ${w}${n === 1 ? "" : "s"}`;
61
69
  return `Edited ${input.path}: ${plural(edits.length, "edit")}, ${plural(res.total, "replacement")}${note}.`;