@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/CHANGELOG.md +39 -0
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +24 -2
- package/dist/config.js +31 -5
- package/dist/context/mentions.js +17 -7
- package/dist/desk.js +64 -0
- package/dist/feedback.js +2 -7
- package/dist/fs-read.js +157 -0
- package/dist/fs-write.js +105 -0
- package/dist/index.js +103 -3
- package/dist/security/secrets.js +75 -0
- package/dist/serve/server.js +5 -5
- package/dist/serve/sessions.js +5 -1
- package/dist/session/store.js +58 -16
- package/dist/tools/builtin.js +74 -15
- package/dist/tools/edit.js +10 -2
- package/dist/tools/patch.js +45 -15
- package/dist/tools/registry.js +20 -6
- package/dist/tools/result-limit.js +37 -0
- package/dist/tools/web.js +227 -41
- package/dist/tui/App.js +1 -1
- package/dist/tui/InputBox.js +62 -4
- package/dist/tui/input-history.js +167 -0
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +6 -4
- package/package.json +6 -1
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
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
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
|
+
}
|
package/dist/serve/server.js
CHANGED
|
@@ -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 —
|
|
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:
|
|
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 —
|
|
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 —
|
|
309
|
-
const r = hub.fork(p.sessionId, { provider, providerId:
|
|
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;
|
package/dist/serve/sessions.js
CHANGED
|
@@ -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:
|
|
108
|
+
model: o.provider.model,
|
|
105
109
|
title: src.meta.title ? `${src.meta.title} ⑂` : "",
|
|
106
110
|
createdAt: new Date().toISOString(),
|
|
107
111
|
updatedAt: "",
|
package/dist/session/store.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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);
|
package/dist/tools/builtin.js
CHANGED
|
@@ -1,14 +1,48 @@
|
|
|
1
|
-
import { readFile,
|
|
2
|
-
import {
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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);
|
|
131
|
-
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task)
|
|
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.
|
|
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
|
|
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
|
package/dist/tools/edit.js
CHANGED
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { readFile
|
|
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
|
-
|
|
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}.`;
|