@nanhara/hara 0.121.0 → 0.122.0
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 +85 -0
- package/README.md +40 -6
- package/dist/agent/failover.js +1 -1
- package/dist/agent/loop.js +158 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +62 -26
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +5 -9
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +640 -219
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +150 -0
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +112 -28
- package/dist/session/store.js +337 -44
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +61 -23
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +364 -64
- package/dist/tui/run.js +26 -23
- package/dist/undo.js +83 -7
- package/package.json +1 -1
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,11 +21,92 @@ 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
|
+
// `mode` is ignored when the directory already exists. Tighten legacy installs too: a session holds
|
|
26
|
+
// private conversation history, so inheriting a permissive umask (typically 0755) is not acceptable.
|
|
27
|
+
chmodSync(d, 0o700);
|
|
24
28
|
return d;
|
|
25
29
|
}
|
|
26
|
-
|
|
27
|
-
|
|
30
|
+
/** Session ids become filenames. Gateway/platform ids are not always UUIDs, so allow printable filename
|
|
31
|
+
* characters broadly while rejecting separators, traversal sentinels, NULs, and unbounded names. */
|
|
32
|
+
export function validSessionId(id) {
|
|
33
|
+
return typeof id === "string" && id.length > 0 && id.length <= 220 && id !== "." && id !== ".." && !/[\\/\0]/.test(id);
|
|
34
|
+
}
|
|
35
|
+
function checkedSessionId(id) {
|
|
36
|
+
if (!validSessionId(id))
|
|
37
|
+
throw new Error("invalid session id");
|
|
38
|
+
return id;
|
|
39
|
+
}
|
|
40
|
+
const sessionFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.json`);
|
|
41
|
+
const lockFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.lock`);
|
|
42
|
+
const reclaimFile = (id) => join(sessionsDir(), `${checkedSessionId(id)}.lock.reclaim`);
|
|
43
|
+
/** Distinguish a missing session from an existing but unreadable/corrupt one without exposing its path. */
|
|
44
|
+
export function sessionFileExists(id) {
|
|
45
|
+
if (!validSessionId(id))
|
|
46
|
+
return false;
|
|
47
|
+
return existsSync(sessionFile(id));
|
|
48
|
+
}
|
|
49
|
+
// A pid alone is not ownership: pids are reused, and another writer could replace a lock between read and
|
|
50
|
+
// release. Keep the random token for every lock this module actually created and require both on release.
|
|
51
|
+
const ownedLocks = new Map();
|
|
52
|
+
function readLockRecord(path) {
|
|
53
|
+
try {
|
|
54
|
+
const parsed = JSON.parse(readFileSync(path, "utf8"));
|
|
55
|
+
if (!Number.isInteger(parsed.pid) ||
|
|
56
|
+
Number(parsed.pid) <= 0 ||
|
|
57
|
+
typeof parsed.startedAt !== "number" ||
|
|
58
|
+
(parsed.token !== undefined && (typeof parsed.token !== "string" || !parsed.token))) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
return { pid: Number(parsed.pid), startedAt: parsed.startedAt, ...(parsed.token ? { token: parsed.token } : {}) };
|
|
62
|
+
}
|
|
63
|
+
catch {
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/** Create one private file without ever replacing an existing path. On a partial write, remove only the
|
|
68
|
+
* inode we just created. Callers use this for both the primary lock and the stale-lock reclaimer. */
|
|
69
|
+
function writeExclusive(path, record) {
|
|
70
|
+
let fd;
|
|
71
|
+
try {
|
|
72
|
+
fd = openSync(path, "wx", 0o600);
|
|
73
|
+
writeFileSync(fd, JSON.stringify(record), "utf8");
|
|
74
|
+
fsyncSync(fd);
|
|
75
|
+
closeSync(fd);
|
|
76
|
+
fd = undefined;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
if (fd !== undefined) {
|
|
80
|
+
try {
|
|
81
|
+
closeSync(fd);
|
|
82
|
+
fd = undefined;
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
/* continue with best-effort removal */
|
|
86
|
+
}
|
|
87
|
+
try {
|
|
88
|
+
rmSync(path, { force: true });
|
|
89
|
+
}
|
|
90
|
+
catch {
|
|
91
|
+
/* the original error is more useful */
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
finally {
|
|
97
|
+
if (fd !== undefined) {
|
|
98
|
+
try {
|
|
99
|
+
closeSync(fd);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
/* the acquisition will fail closed */
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
function newLockRecord() {
|
|
108
|
+
return { pid: process.pid, startedAt: Date.now(), token: randomUUID() };
|
|
109
|
+
}
|
|
28
110
|
/** Is a process with this pid alive? `process.kill(pid, 0)` sends no signal — it just probes: throws
|
|
29
111
|
* ESRCH if dead, EPERM if alive-but-not-ours (still alive). Best-effort across platforms. */
|
|
30
112
|
function pidAlive(pid) {
|
|
@@ -36,60 +118,145 @@ function pidAlive(pid) {
|
|
|
36
118
|
return e?.code === "EPERM";
|
|
37
119
|
}
|
|
38
120
|
}
|
|
39
|
-
/** Take an
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
* filesystem hiccup resolves to `ok:true` so a lock problem never blocks the user. Pair with
|
|
43
|
-
* `releaseSessionLock` on exit. */
|
|
121
|
+
/** Take an O_EXCL lock on a session so two hara processes cannot both pass a check-then-write race.
|
|
122
|
+
* Filesystem/malformed-lock errors fail CLOSED. A well-formed dead holder can be reclaimed under a second
|
|
123
|
+
* O_EXCL guard; a corrupt lock is deliberately left for explicit operator inspection/removal. */
|
|
44
124
|
export function acquireSessionLock(id) {
|
|
45
|
-
|
|
125
|
+
let f;
|
|
126
|
+
let reclaim;
|
|
46
127
|
try {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
128
|
+
f = lockFile(id);
|
|
129
|
+
reclaim = reclaimFile(id);
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
return { ok: false };
|
|
133
|
+
}
|
|
134
|
+
// A stale-lock recovery is changing the primary lock. New contenders wait/fail instead of creating a
|
|
135
|
+
// primary lock in the short remove→create window.
|
|
136
|
+
if (existsSync(reclaim)) {
|
|
137
|
+
const stale = readLockRecord(reclaim);
|
|
138
|
+
if (!stale?.token || !Number.isFinite(stale.startedAt) || stale.startedAt <= 0 || pidAlive(stale.pid)) {
|
|
139
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
140
|
+
}
|
|
141
|
+
const current = readLockRecord(reclaim);
|
|
142
|
+
if (!current?.token ||
|
|
143
|
+
current.pid !== stale.pid ||
|
|
144
|
+
current.token !== stale.token ||
|
|
145
|
+
current.startedAt !== stale.startedAt ||
|
|
146
|
+
pidAlive(current.pid)) {
|
|
147
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
148
|
+
}
|
|
149
|
+
try {
|
|
150
|
+
rmSync(reclaim);
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
return { ok: false, pid: readLockRecord(f)?.pid };
|
|
53
154
|
}
|
|
54
|
-
|
|
155
|
+
}
|
|
156
|
+
const claim = newLockRecord();
|
|
157
|
+
try {
|
|
158
|
+
writeExclusive(f, claim);
|
|
159
|
+
ownedLocks.set(id, claim.token);
|
|
55
160
|
return { ok: true };
|
|
56
161
|
}
|
|
162
|
+
catch (error) {
|
|
163
|
+
if (error?.code !== "EEXIST")
|
|
164
|
+
return { ok: false };
|
|
165
|
+
}
|
|
166
|
+
const held = readLockRecord(f);
|
|
167
|
+
if (!held)
|
|
168
|
+
return { ok: false }; // malformed/unreadable is not proof that the owner is dead
|
|
169
|
+
if (held.pid === process.pid && !!held.token && ownedLocks.get(id) === held.token)
|
|
170
|
+
return { ok: true }; // re-entrant
|
|
171
|
+
if (pidAlive(held.pid))
|
|
172
|
+
return { ok: false, pid: held.pid };
|
|
173
|
+
// Serialize stale takeover. All participants check this guard before attempting the primary O_EXCL create,
|
|
174
|
+
// so no second contender can steal the freshly-created primary lock during reclamation.
|
|
175
|
+
const reclaimClaim = newLockRecord();
|
|
176
|
+
try {
|
|
177
|
+
writeExclusive(reclaim, reclaimClaim);
|
|
178
|
+
}
|
|
57
179
|
catch {
|
|
180
|
+
return { ok: false, pid: held.pid };
|
|
181
|
+
}
|
|
182
|
+
try {
|
|
183
|
+
const current = readLockRecord(f);
|
|
184
|
+
if (!current)
|
|
185
|
+
return { ok: false }; // disappeared/corrupted unexpectedly: fail closed
|
|
186
|
+
if (current.pid === process.pid && !!current.token && ownedLocks.get(id) === current.token)
|
|
187
|
+
return { ok: true };
|
|
188
|
+
if (pidAlive(current.pid))
|
|
189
|
+
return { ok: false, pid: current.pid };
|
|
190
|
+
rmSync(f); // known-dead, well-formed owner; protected by the reclaim guard
|
|
191
|
+
const replacement = newLockRecord();
|
|
192
|
+
writeExclusive(f, replacement);
|
|
193
|
+
ownedLocks.set(id, replacement.token);
|
|
58
194
|
return { ok: true };
|
|
59
195
|
}
|
|
196
|
+
catch {
|
|
197
|
+
return { ok: false };
|
|
198
|
+
}
|
|
199
|
+
finally {
|
|
200
|
+
// Only remove our own reclaimer. A replacement would indicate outside interference and must survive.
|
|
201
|
+
const currentReclaim = readLockRecord(reclaim);
|
|
202
|
+
if (currentReclaim?.pid === process.pid && currentReclaim.token === reclaimClaim.token) {
|
|
203
|
+
try {
|
|
204
|
+
rmSync(reclaim);
|
|
205
|
+
}
|
|
206
|
+
catch {
|
|
207
|
+
/* fail closed on the next acquisition until this guard can be inspected/removed */
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
}
|
|
60
211
|
}
|
|
61
212
|
/** Release a session lock we hold (only removes it if the pid matches ours — never steals another's). */
|
|
62
213
|
export function releaseSessionLock(id) {
|
|
214
|
+
const token = ownedLocks.get(id);
|
|
215
|
+
if (!token)
|
|
216
|
+
return;
|
|
63
217
|
try {
|
|
64
218
|
const f = lockFile(id);
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
const held = JSON.parse(readFileSync(f, "utf8"));
|
|
68
|
-
if (held?.pid === process.pid)
|
|
219
|
+
const held = readLockRecord(f);
|
|
220
|
+
if (held?.pid === process.pid && held.token === token)
|
|
69
221
|
rmSync(f);
|
|
222
|
+
ownedLocks.delete(id);
|
|
70
223
|
}
|
|
71
224
|
catch {
|
|
72
|
-
|
|
225
|
+
// Keep the ownership token so a later cleanup attempt can still prove ownership. Never unlink blindly.
|
|
73
226
|
}
|
|
74
227
|
}
|
|
75
228
|
/** Permanently delete a session from disk (codex thread/delete — unlike archive, irreversible).
|
|
76
229
|
* Refuses when a LIVE other process holds the lock; removes the session file and any lock we may hold.
|
|
77
230
|
* Returns false when the session doesn't exist or is held elsewhere. */
|
|
78
231
|
export function deleteSession(id) {
|
|
79
|
-
|
|
232
|
+
let f;
|
|
233
|
+
try {
|
|
234
|
+
f = sessionFile(id);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
80
239
|
if (!existsSync(f))
|
|
81
240
|
return false;
|
|
241
|
+
const ownedBefore = ownedLocks.has(id);
|
|
82
242
|
const lock = acquireSessionLock(id);
|
|
83
243
|
if (!lock.ok)
|
|
84
244
|
return false;
|
|
245
|
+
let deleted = false;
|
|
85
246
|
try {
|
|
86
247
|
rmSync(f);
|
|
87
|
-
|
|
248
|
+
deleted = true;
|
|
88
249
|
return true;
|
|
89
250
|
}
|
|
90
251
|
catch {
|
|
91
252
|
return false;
|
|
92
253
|
}
|
|
254
|
+
finally {
|
|
255
|
+
// A successful delete ends the session. On failure, release only a lock acquired by this call; a live
|
|
256
|
+
// SessionHub that already owned the lock must retain it and remain protected.
|
|
257
|
+
if (deleted || !ownedBefore)
|
|
258
|
+
releaseSessionLock(id);
|
|
259
|
+
}
|
|
93
260
|
}
|
|
94
261
|
/** A full UUID per session (the stable identity). */
|
|
95
262
|
export const newSessionId = () => randomUUID();
|
|
@@ -97,10 +264,12 @@ export const newSessionId = () => randomUUID();
|
|
|
97
264
|
export const shortId = (id) => id.slice(0, 8);
|
|
98
265
|
/** Resolve a full id OR a unique prefix (e.g. the short id) to a session id, for `--resume`. */
|
|
99
266
|
export function resolveSessionId(idOrPrefix) {
|
|
267
|
+
if (!validSessionId(idOrPrefix))
|
|
268
|
+
return null;
|
|
100
269
|
if (existsSync(sessionFile(idOrPrefix)))
|
|
101
270
|
return idOrPrefix;
|
|
102
|
-
const
|
|
103
|
-
return
|
|
271
|
+
const hits = listSessions().filter((m) => m.id.startsWith(idOrPrefix));
|
|
272
|
+
return hits.length === 1 ? hits[0].id : null;
|
|
104
273
|
}
|
|
105
274
|
const STOP = new Set("the a an to of for and or with in on at my our your this that it is please can could you help me we add fix make do run create update change implement".split(" "));
|
|
106
275
|
const WORDS = "amber basalt cedar delta ember flint grove harbor indigo jade kelp larch maple onyx quartz river slate terra umber vale willow zephyr".split(" ");
|
|
@@ -154,46 +323,170 @@ export function slugify(text, max = 40) {
|
|
|
154
323
|
.slice(0, max)
|
|
155
324
|
.replace(/-+$/, "");
|
|
156
325
|
}
|
|
326
|
+
/** Redact a deep in-memory copy while retaining fields that define session identity/routing. */
|
|
327
|
+
function redactedSessionCopy(data) {
|
|
328
|
+
// Redact a deep COPY: the live turn may still need a credential the user supplied, but the durable
|
|
329
|
+
// transcript never should. Tool inputs/results are included, not just user message content.
|
|
330
|
+
const safe = redactSensitiveValue(data).value;
|
|
331
|
+
// Structural routing/identity fields must remain byte-for-byte stable even if a path happens to contain
|
|
332
|
+
// credential-looking text. Free-form meta (title, workingSet, todos, sourceName) and ALL history remain
|
|
333
|
+
// deeply redacted. The live objects are not modified by the redaction walk.
|
|
334
|
+
safe.meta.id = data.meta.id;
|
|
335
|
+
safe.meta.cwd = data.meta.cwd;
|
|
336
|
+
safe.meta.provider = data.meta.provider;
|
|
337
|
+
safe.meta.model = data.meta.model;
|
|
338
|
+
safe.meta.createdAt = data.meta.createdAt;
|
|
339
|
+
safe.meta.updatedAt = data.meta.updatedAt;
|
|
340
|
+
if (data.meta.source !== undefined)
|
|
341
|
+
safe.meta.source = data.meta.source;
|
|
342
|
+
if (data.meta.effort !== undefined)
|
|
343
|
+
safe.meta.effort = data.meta.effort;
|
|
344
|
+
if (data.meta.archived !== undefined)
|
|
345
|
+
safe.meta.archived = data.meta.archived;
|
|
346
|
+
if (data.meta.gatewayOwner !== undefined)
|
|
347
|
+
safe.meta.gatewayOwner = data.meta.gatewayOwner;
|
|
348
|
+
return safe;
|
|
349
|
+
}
|
|
157
350
|
export function saveSession(meta, history) {
|
|
351
|
+
checkedSessionId(meta.id);
|
|
158
352
|
meta.updatedAt = new Date().toISOString();
|
|
159
|
-
const
|
|
160
|
-
|
|
353
|
+
const safe = redactedSessionCopy({ meta, history });
|
|
354
|
+
const target = sessionFile(meta.id);
|
|
355
|
+
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
356
|
+
let fd;
|
|
357
|
+
try {
|
|
358
|
+
fd = openSync(tmp, "wx", 0o600);
|
|
359
|
+
writeFileSync(fd, JSON.stringify(safe, null, 2), "utf8");
|
|
360
|
+
fsyncSync(fd);
|
|
361
|
+
closeSync(fd);
|
|
362
|
+
fd = undefined;
|
|
363
|
+
// Same directory/filesystem: readers observe the complete old JSON or complete new JSON, never a prefix.
|
|
364
|
+
renameSync(tmp, target);
|
|
365
|
+
chmodSync(target, 0o600);
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (fd !== undefined) {
|
|
369
|
+
try {
|
|
370
|
+
closeSync(fd);
|
|
371
|
+
}
|
|
372
|
+
catch {
|
|
373
|
+
/* preserve the original error */
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
try {
|
|
377
|
+
rmSync(tmp, { force: true });
|
|
378
|
+
}
|
|
379
|
+
catch {
|
|
380
|
+
/* preserve the original error */
|
|
381
|
+
}
|
|
382
|
+
throw error;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function isTimestamp(value) {
|
|
386
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
161
387
|
}
|
|
162
|
-
|
|
388
|
+
function isStringArray(value) {
|
|
389
|
+
return Array.isArray(value) && value.every((item) => typeof item === "string");
|
|
390
|
+
}
|
|
391
|
+
function isPersistedTodo(value) {
|
|
392
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
393
|
+
return false;
|
|
394
|
+
const todo = value;
|
|
395
|
+
return (typeof todo.text === "string" &&
|
|
396
|
+
(todo.status === "pending" || todo.status === "in_progress" || todo.status === "done") &&
|
|
397
|
+
(todo.activeForm === undefined || typeof todo.activeForm === "string") &&
|
|
398
|
+
(todo.blockedBy === undefined || isStringArray(todo.blockedBy)) &&
|
|
399
|
+
(todo.owner === undefined || typeof todo.owner === "string"));
|
|
400
|
+
}
|
|
401
|
+
function isNeutralMessage(value) {
|
|
402
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
403
|
+
return false;
|
|
404
|
+
const message = value;
|
|
405
|
+
if (message.role === "user") {
|
|
406
|
+
return typeof message.content === "string" && (message.images === undefined || (Array.isArray(message.images) && message.images.every((image) => {
|
|
407
|
+
if (!image || typeof image !== "object" || Array.isArray(image))
|
|
408
|
+
return false;
|
|
409
|
+
const attachment = image;
|
|
410
|
+
return typeof attachment.path === "string" && typeof attachment.mediaType === "string";
|
|
411
|
+
})));
|
|
412
|
+
}
|
|
413
|
+
if (message.role === "assistant") {
|
|
414
|
+
return typeof message.text === "string" && Array.isArray(message.toolUses) && message.toolUses.every((use) => {
|
|
415
|
+
if (!use || typeof use !== "object" || Array.isArray(use))
|
|
416
|
+
return false;
|
|
417
|
+
const tool = use;
|
|
418
|
+
return typeof tool.id === "string" && typeof tool.name === "string" && Object.hasOwn(tool, "input");
|
|
419
|
+
});
|
|
420
|
+
}
|
|
421
|
+
if (message.role === "tool") {
|
|
422
|
+
return Array.isArray(message.results) && message.results.every((result) => {
|
|
423
|
+
if (!result || typeof result !== "object" || Array.isArray(result))
|
|
424
|
+
return false;
|
|
425
|
+
const tool = result;
|
|
426
|
+
return typeof tool.id === "string" && typeof tool.name === "string" && typeof tool.content === "string" &&
|
|
427
|
+
(tool.isError === undefined || typeof tool.isError === "boolean");
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
return false;
|
|
431
|
+
}
|
|
432
|
+
function isSessionMeta(value) {
|
|
433
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
434
|
+
return false;
|
|
435
|
+
const meta = value;
|
|
436
|
+
return (validSessionId(meta.id) &&
|
|
437
|
+
typeof meta.cwd === "string" &&
|
|
438
|
+
typeof meta.provider === "string" &&
|
|
439
|
+
typeof meta.model === "string" &&
|
|
440
|
+
typeof meta.title === "string" &&
|
|
441
|
+
isTimestamp(meta.createdAt) &&
|
|
442
|
+
isTimestamp(meta.updatedAt) &&
|
|
443
|
+
(meta.workingSet === undefined || isStringArray(meta.workingSet)) &&
|
|
444
|
+
(meta.todos === undefined || (Array.isArray(meta.todos) && meta.todos.every(isPersistedTodo))) &&
|
|
445
|
+
(meta.source === undefined || meta.source === "interactive" || meta.source === "gateway" || meta.source === "cron") &&
|
|
446
|
+
(meta.sourceName === undefined || typeof meta.sourceName === "string") &&
|
|
447
|
+
(meta.effort === undefined || typeof meta.effort === "string") &&
|
|
448
|
+
(meta.archived === undefined || typeof meta.archived === "boolean") &&
|
|
449
|
+
(meta.gatewayOwner === undefined || typeof meta.gatewayOwner === "string"));
|
|
450
|
+
}
|
|
451
|
+
/** True if a parsed object has the SessionData shape we can safely use. */
|
|
163
452
|
function isSessionData(d) {
|
|
164
453
|
const o = d;
|
|
165
|
-
return !!o && typeof o === "object" &&
|
|
454
|
+
return !!o && typeof o === "object" && !Array.isArray(o) && isSessionMeta(o.meta) &&
|
|
455
|
+
Array.isArray(o.history) && o.history.every(isNeutralMessage);
|
|
166
456
|
}
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
return null;
|
|
457
|
+
/** Read only. Legacy plaintext is redacted in the returned in-memory copy but intentionally not migrated
|
|
458
|
+
* here: listing/resuming must not perform an unlocked write. The next explicit save atomically migrates it. */
|
|
459
|
+
function readSessionFile(p) {
|
|
171
460
|
try {
|
|
172
461
|
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
173
|
-
return isSessionData(d) ? d : null;
|
|
462
|
+
return isSessionData(d) ? redactedSessionCopy(d) : null;
|
|
174
463
|
}
|
|
175
464
|
catch {
|
|
176
465
|
return null;
|
|
177
466
|
}
|
|
178
467
|
}
|
|
468
|
+
export function loadSession(id) {
|
|
469
|
+
if (!validSessionId(id))
|
|
470
|
+
return null;
|
|
471
|
+
const p = sessionFile(id);
|
|
472
|
+
if (!existsSync(p))
|
|
473
|
+
return null;
|
|
474
|
+
const data = readSessionFile(p);
|
|
475
|
+
return data?.meta.id === id ? data : null; // a corrupt, spoofed, or hand-edited file resumes as "no session"
|
|
476
|
+
}
|
|
179
477
|
/** Session metas, newest first; optionally filtered to a cwd. */
|
|
180
478
|
export function listSessions(cwd) {
|
|
181
479
|
let metas = [];
|
|
182
480
|
for (const f of readdirSync(sessionsDir())) {
|
|
183
481
|
if (!f.endsWith(".json"))
|
|
184
482
|
continue;
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
metas.push(d.meta); // skip metaless/corrupt
|
|
189
|
-
}
|
|
190
|
-
catch {
|
|
191
|
-
/* skip corrupt */
|
|
192
|
-
}
|
|
483
|
+
const d = readSessionFile(join(sessionsDir(), f));
|
|
484
|
+
if (d?.meta.id && validSessionId(d.meta.id) && f === `${d.meta.id}.json` && d.meta.updatedAt)
|
|
485
|
+
metas.push(d.meta); // skip spoofed/metalless/corrupt; never mutate while listing
|
|
193
486
|
}
|
|
194
487
|
if (cwd)
|
|
195
488
|
metas = metas.filter((m) => m.cwd === cwd);
|
|
196
|
-
return metas.sort((a, b) => b.updatedAt.
|
|
489
|
+
return metas.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
197
490
|
}
|
|
198
491
|
export function latestForCwd(cwd) {
|
|
199
492
|
const [m] = listSessions(cwd);
|
package/dist/tools/all.js
CHANGED
|
@@ -12,6 +12,7 @@ import "./memory.js"; // memory_search/get/write/forget + skill_create
|
|
|
12
12
|
import "./skill.js"; // skill loader
|
|
13
13
|
import "./codebase.js"; // codebase_search
|
|
14
14
|
import "./todo.js"; // todo_write
|
|
15
|
+
import "./task.js"; // task (project-level persistent task pool)
|
|
15
16
|
import "./send.js"; // send_file (self-gates on HARA_GATEWAY)
|
|
16
17
|
import "./external_agent.js"; // external_agent (claude-code / codex delegation)
|
|
17
18
|
import "./ask_user.js"; // ask_user
|
package/dist/tools/builtin.js
CHANGED
|
@@ -1,17 +1,53 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
2
3
|
import { resolve, isAbsolute } from "node:path";
|
|
3
4
|
import { stdout as procOut } from "node:process";
|
|
4
5
|
import { registerTool } from "./registry.js";
|
|
5
6
|
import { runShell } from "../sandbox.js";
|
|
6
|
-
import {
|
|
7
|
+
import { nearestPaths } from "../fs-walk.js";
|
|
7
8
|
import { emitDiff } from "../diff.js";
|
|
8
9
|
import { recordEdit } from "../undo.js";
|
|
9
10
|
import { atomicWriteText } from "../fs-write.js";
|
|
10
11
|
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
11
|
-
import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
12
|
+
import { BinaryFileError, readRegularFileSnapshot, streamFileSlice } from "../fs-read.js";
|
|
12
13
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
13
14
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
14
15
|
const MAX = 100_000;
|
|
16
|
+
/** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
|
|
17
|
+
export function isPackageInstallCommand(command) {
|
|
18
|
+
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());
|
|
19
|
+
}
|
|
20
|
+
/** Resolve a bounded foreground timeout. Installs get a longer default, but remain attached so a headless
|
|
21
|
+
* run cannot exit, kill its background child, and leave node_modules half-written. */
|
|
22
|
+
export function shellTimeoutMs(command, requested) {
|
|
23
|
+
if (Number.isFinite(requested) && requested > 0) {
|
|
24
|
+
return Math.min(Math.max(Math.trunc(requested), 1_000), 3_600_000);
|
|
25
|
+
}
|
|
26
|
+
return isPackageInstallCommand(command) ? 900_000 : 300_000;
|
|
27
|
+
}
|
|
28
|
+
export function isNgrokTunnelCommand(command) {
|
|
29
|
+
return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
|
|
30
|
+
}
|
|
31
|
+
/** Read only the presence of ngrok auth — never return or print its value. */
|
|
32
|
+
export function ngrokAuthConfigured(env = process.env, home = homedir()) {
|
|
33
|
+
if (env.NGROK_AUTHTOKEN || env.NGROK_API_KEY)
|
|
34
|
+
return true;
|
|
35
|
+
const files = [
|
|
36
|
+
resolve(home, ".config/ngrok/ngrok.yml"),
|
|
37
|
+
resolve(home, "Library/Application Support/ngrok/ngrok.yml"),
|
|
38
|
+
resolve(home, ".ngrok2/ngrok.yml"),
|
|
39
|
+
];
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
try {
|
|
42
|
+
if (existsSync(file) && /^\s*(?:authtoken|api_key)\s*:\s*\S+/im.test(readFileSync(file, "utf8")))
|
|
43
|
+
return true;
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
/* unreadable config counts as unconfigured */
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
15
51
|
/** Resolve the remote HOST a bare `git pull/fetch/push` targets (no URL in the command → host lives in the
|
|
16
52
|
* repo's remote config). Local + fast (no network); best-effort — returns "" on any hiccup. Only ever
|
|
17
53
|
* called after a host has already been marked unreachable, so it adds zero overhead on the happy path. */
|
|
@@ -48,7 +84,6 @@ export function capHeadTail(s, max = MAX) {
|
|
|
48
84
|
}
|
|
49
85
|
const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
|
|
50
86
|
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
51
|
-
const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
|
|
52
87
|
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
53
88
|
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
54
89
|
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
@@ -89,19 +124,15 @@ registerTool({
|
|
|
89
124
|
async run(input, ctx) {
|
|
90
125
|
const p = abs(input.path, ctx.cwd);
|
|
91
126
|
try {
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
const buf = await readFile(p);
|
|
96
|
-
if (isProbablyBinary(buf))
|
|
97
|
-
throw new BinaryFileError(p);
|
|
98
|
-
return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
|
|
127
|
+
// streamFileSlice opens O_NONBLOCK, validates the same fd as a regular file, and stops after the
|
|
128
|
+
// requested window. Using it for every size removes the path-level stat→read race entirely.
|
|
129
|
+
return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
|
|
99
130
|
}
|
|
100
131
|
catch (e) {
|
|
101
132
|
if (e instanceof BinaryFileError)
|
|
102
133
|
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
103
134
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
104
|
-
return `Error: cannot read ${input.path}: ${e.
|
|
135
|
+
return `Error: cannot read ${input.path}: ${e.message ?? e.code}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
|
|
105
136
|
}
|
|
106
137
|
},
|
|
107
138
|
});
|
|
@@ -121,26 +152,28 @@ registerTool({
|
|
|
121
152
|
const p = abs(input.path, ctx.cwd);
|
|
122
153
|
if (typeof input.content !== "string")
|
|
123
154
|
return "Error: write_file `content` must be a string. No changes written.";
|
|
124
|
-
let
|
|
155
|
+
let prevSnapshot = null;
|
|
125
156
|
try {
|
|
126
|
-
|
|
157
|
+
prevSnapshot = await readRegularFileSnapshot(p);
|
|
127
158
|
}
|
|
128
159
|
catch (error) {
|
|
129
160
|
if (error?.code !== "ENOENT")
|
|
130
|
-
return `Error: cannot inspect ${input.path}: ${error?.
|
|
161
|
+
return `Error: cannot inspect ${input.path}: ${error?.message ?? error?.code}. No changes written.`;
|
|
131
162
|
}
|
|
163
|
+
const prev = prevSnapshot?.text ?? null;
|
|
132
164
|
if (prev === input.content)
|
|
133
165
|
return `Unchanged ${p} (${input.content.length} chars already match).`;
|
|
166
|
+
let committed;
|
|
134
167
|
try {
|
|
135
|
-
await atomicWriteText(p, input.content, { expected: prev });
|
|
168
|
+
committed = await atomicWriteText(p, input.content, { expected: prev, expectedIdentity: prevSnapshot ?? undefined });
|
|
136
169
|
}
|
|
137
170
|
catch (error) {
|
|
138
171
|
return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
139
172
|
}
|
|
140
173
|
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
141
|
-
recordEdit([{ path: input.path, absPath: p, before: prev }]);
|
|
174
|
+
recordEdit([{ path: input.path, absPath: p, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
|
|
142
175
|
invalidateFileCandidates(ctx.cwd);
|
|
143
|
-
return `Wrote ${String(input.content).length} chars to ${p}
|
|
176
|
+
return `Wrote ${String(input.content).length} chars to ${p}` + (committed.warnings?.length ? ` Warning: ${committed.warnings.join("; ")}` : "");
|
|
144
177
|
},
|
|
145
178
|
});
|
|
146
179
|
registerTool({
|
|
@@ -150,16 +183,20 @@ registerTool({
|
|
|
150
183
|
type: "object",
|
|
151
184
|
properties: {
|
|
152
185
|
command: { type: "string" },
|
|
153
|
-
timeout_ms: { type: "number", description: "default 300000 (5 min)
|
|
154
|
-
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task);
|
|
186
|
+
timeout_ms: { type: "number", description: "default 300000 (5 min), or 900000 (15 min) for package installs; bounded to 1s..1h" },
|
|
187
|
+
background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); package installs stay foreground unless explicitly requested" },
|
|
155
188
|
},
|
|
156
189
|
required: ["command"],
|
|
157
190
|
},
|
|
158
191
|
kind: "exec",
|
|
159
192
|
async run(input, ctx) {
|
|
193
|
+
if (isNgrokTunnelCommand(input.command) && !ngrokAuthConfigured()) {
|
|
194
|
+
return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
|
|
195
|
+
"Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
|
|
196
|
+
}
|
|
160
197
|
if (input.background) {
|
|
161
198
|
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.`;
|
|
199
|
+
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. Poll until it exits before running steps that depend on it.`;
|
|
163
200
|
}
|
|
164
201
|
// Network fault tolerance — short-circuit if this command targets a host already found unreachable this
|
|
165
202
|
// session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
|
|
@@ -190,9 +227,10 @@ registerTool({
|
|
|
190
227
|
: procOut.isTTY
|
|
191
228
|
? (s) => procOut.write(s) // stream output in a plain terminal
|
|
192
229
|
: undefined;
|
|
230
|
+
const timeout = shellTimeoutMs(input.command, input.timeout_ms);
|
|
193
231
|
try {
|
|
194
232
|
const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
|
|
195
|
-
timeout
|
|
233
|
+
timeout,
|
|
196
234
|
maxBuffer: 10 * 1024 * 1024,
|
|
197
235
|
onData: live,
|
|
198
236
|
});
|
|
@@ -207,7 +245,7 @@ registerTool({
|
|
|
207
245
|
// lane instead of blind-retrying into the same wall.
|
|
208
246
|
if (/timed out after \d+ms/.test(String(e.message))) {
|
|
209
247
|
base +=
|
|
210
|
-
`\n⏱ hara: the command hit its ${
|
|
248
|
+
`\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
|
|
211
249
|
`a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
|
|
212
250
|
`a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
|
|
213
251
|
}
|
package/dist/tools/codebase.js
CHANGED
|
@@ -72,7 +72,9 @@ registerTool({
|
|
|
72
72
|
// to pure lexical when no index/embedder — zero behaviour change for the default install.
|
|
73
73
|
const out = [];
|
|
74
74
|
const seen = new Set();
|
|
75
|
-
|
|
75
|
+
// Tool execution can be dispatched to a registered agent home without changing process.cwd(). Its
|
|
76
|
+
// semantic-search provider/index settings belong to the tool context, not to the launcher directory.
|
|
77
|
+
const cfg = loadConfig({ cwd: ctx.cwd });
|
|
76
78
|
const embed = getEmbedder(cfg);
|
|
77
79
|
if (embed && indexExists("repo", ctx.cwd)) {
|
|
78
80
|
try {
|