@nanhara/hara 0.121.1 → 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 +66 -0
- package/README.md +40 -6
- package/dist/agent/loop.js +136 -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 +33 -23
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +3 -2
- 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 +631 -222
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +84 -9
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +30 -28
- 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 +168 -54
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/dist/session/store.js
CHANGED
|
@@ -22,11 +22,91 @@ export function automatedTitle(source, sourceName, at = new Date()) {
|
|
|
22
22
|
function sessionsDir() {
|
|
23
23
|
const d = join(homedir(), ".hara", "sessions");
|
|
24
24
|
mkdirSync(d, { recursive: true, mode: 0o700 });
|
|
25
|
-
|
|
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);
|
|
26
28
|
return d;
|
|
27
29
|
}
|
|
28
|
-
|
|
29
|
-
|
|
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
|
+
}
|
|
30
110
|
/** Is a process with this pid alive? `process.kill(pid, 0)` sends no signal — it just probes: throws
|
|
31
111
|
* ESRCH if dead, EPERM if alive-but-not-ours (still alive). Best-effort across platforms. */
|
|
32
112
|
function pidAlive(pid) {
|
|
@@ -38,60 +118,145 @@ function pidAlive(pid) {
|
|
|
38
118
|
return e?.code === "EPERM";
|
|
39
119
|
}
|
|
40
120
|
}
|
|
41
|
-
/** Take an
|
|
42
|
-
*
|
|
43
|
-
*
|
|
44
|
-
* filesystem hiccup resolves to `ok:true` so a lock problem never blocks the user. Pair with
|
|
45
|
-
* `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. */
|
|
46
124
|
export function acquireSessionLock(id) {
|
|
47
|
-
|
|
125
|
+
let f;
|
|
126
|
+
let reclaim;
|
|
48
127
|
try {
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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 };
|
|
55
140
|
}
|
|
56
|
-
|
|
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 };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
const claim = newLockRecord();
|
|
157
|
+
try {
|
|
158
|
+
writeExclusive(f, claim);
|
|
159
|
+
ownedLocks.set(id, claim.token);
|
|
57
160
|
return { ok: true };
|
|
58
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
|
+
}
|
|
59
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);
|
|
60
194
|
return { ok: true };
|
|
61
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
|
+
}
|
|
62
211
|
}
|
|
63
212
|
/** Release a session lock we hold (only removes it if the pid matches ours — never steals another's). */
|
|
64
213
|
export function releaseSessionLock(id) {
|
|
214
|
+
const token = ownedLocks.get(id);
|
|
215
|
+
if (!token)
|
|
216
|
+
return;
|
|
65
217
|
try {
|
|
66
218
|
const f = lockFile(id);
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
const held = JSON.parse(readFileSync(f, "utf8"));
|
|
70
|
-
if (held?.pid === process.pid)
|
|
219
|
+
const held = readLockRecord(f);
|
|
220
|
+
if (held?.pid === process.pid && held.token === token)
|
|
71
221
|
rmSync(f);
|
|
222
|
+
ownedLocks.delete(id);
|
|
72
223
|
}
|
|
73
224
|
catch {
|
|
74
|
-
|
|
225
|
+
// Keep the ownership token so a later cleanup attempt can still prove ownership. Never unlink blindly.
|
|
75
226
|
}
|
|
76
227
|
}
|
|
77
228
|
/** Permanently delete a session from disk (codex thread/delete — unlike archive, irreversible).
|
|
78
229
|
* Refuses when a LIVE other process holds the lock; removes the session file and any lock we may hold.
|
|
79
230
|
* Returns false when the session doesn't exist or is held elsewhere. */
|
|
80
231
|
export function deleteSession(id) {
|
|
81
|
-
|
|
232
|
+
let f;
|
|
233
|
+
try {
|
|
234
|
+
f = sessionFile(id);
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
return false;
|
|
238
|
+
}
|
|
82
239
|
if (!existsSync(f))
|
|
83
240
|
return false;
|
|
241
|
+
const ownedBefore = ownedLocks.has(id);
|
|
84
242
|
const lock = acquireSessionLock(id);
|
|
85
243
|
if (!lock.ok)
|
|
86
244
|
return false;
|
|
245
|
+
let deleted = false;
|
|
87
246
|
try {
|
|
88
247
|
rmSync(f);
|
|
89
|
-
|
|
248
|
+
deleted = true;
|
|
90
249
|
return true;
|
|
91
250
|
}
|
|
92
251
|
catch {
|
|
93
252
|
return false;
|
|
94
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
|
+
}
|
|
95
260
|
}
|
|
96
261
|
/** A full UUID per session (the stable identity). */
|
|
97
262
|
export const newSessionId = () => randomUUID();
|
|
@@ -99,10 +264,12 @@ export const newSessionId = () => randomUUID();
|
|
|
99
264
|
export const shortId = (id) => id.slice(0, 8);
|
|
100
265
|
/** Resolve a full id OR a unique prefix (e.g. the short id) to a session id, for `--resume`. */
|
|
101
266
|
export function resolveSessionId(idOrPrefix) {
|
|
267
|
+
if (!validSessionId(idOrPrefix))
|
|
268
|
+
return null;
|
|
102
269
|
if (existsSync(sessionFile(idOrPrefix)))
|
|
103
270
|
return idOrPrefix;
|
|
104
|
-
const
|
|
105
|
-
return
|
|
271
|
+
const hits = listSessions().filter((m) => m.id.startsWith(idOrPrefix));
|
|
272
|
+
return hits.length === 1 ? hits[0].id : null;
|
|
106
273
|
}
|
|
107
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(" "));
|
|
108
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(" ");
|
|
@@ -156,19 +323,34 @@ export function slugify(text, max = 40) {
|
|
|
156
323
|
.slice(0, max)
|
|
157
324
|
.replace(/-+$/, "");
|
|
158
325
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
const data = { meta, history };
|
|
326
|
+
/** Redact a deep in-memory copy while retaining fields that define session identity/routing. */
|
|
327
|
+
function redactedSessionCopy(data) {
|
|
162
328
|
// Redact a deep COPY: the live turn may still need a credential the user supplied, but the durable
|
|
163
329
|
// transcript never should. Tool inputs/results are included, not just user message content.
|
|
164
330
|
const safe = redactSensitiveValue(data).value;
|
|
165
|
-
//
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
safe.meta.
|
|
169
|
-
safe.meta.
|
|
170
|
-
safe.meta.
|
|
171
|
-
safe.meta.
|
|
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
|
+
}
|
|
350
|
+
export function saveSession(meta, history) {
|
|
351
|
+
checkedSessionId(meta.id);
|
|
352
|
+
meta.updatedAt = new Date().toISOString();
|
|
353
|
+
const safe = redactedSessionCopy({ meta, history });
|
|
172
354
|
const target = sessionFile(meta.id);
|
|
173
355
|
const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
|
|
174
356
|
let fd;
|
|
@@ -178,7 +360,8 @@ export function saveSession(meta, history) {
|
|
|
178
360
|
fsyncSync(fd);
|
|
179
361
|
closeSync(fd);
|
|
180
362
|
fd = undefined;
|
|
181
|
-
|
|
363
|
+
// Same directory/filesystem: readers observe the complete old JSON or complete new JSON, never a prefix.
|
|
364
|
+
renameSync(tmp, target);
|
|
182
365
|
chmodSync(target, 0o600);
|
|
183
366
|
}
|
|
184
367
|
catch (error) {
|
|
@@ -199,29 +382,97 @@ export function saveSession(meta, history) {
|
|
|
199
382
|
throw error;
|
|
200
383
|
}
|
|
201
384
|
}
|
|
202
|
-
|
|
385
|
+
function isTimestamp(value) {
|
|
386
|
+
return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
|
|
387
|
+
}
|
|
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. */
|
|
203
452
|
function isSessionData(d) {
|
|
204
453
|
const o = d;
|
|
205
|
-
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);
|
|
206
456
|
}
|
|
207
|
-
/**
|
|
208
|
-
*
|
|
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. */
|
|
209
459
|
function readSessionFile(p) {
|
|
210
460
|
try {
|
|
211
461
|
const d = JSON.parse(readFileSync(p, "utf8"));
|
|
212
|
-
|
|
213
|
-
return null;
|
|
214
|
-
return redactSensitiveValue(d).value;
|
|
462
|
+
return isSessionData(d) ? redactedSessionCopy(d) : null;
|
|
215
463
|
}
|
|
216
464
|
catch {
|
|
217
465
|
return null;
|
|
218
466
|
}
|
|
219
467
|
}
|
|
220
468
|
export function loadSession(id) {
|
|
469
|
+
if (!validSessionId(id))
|
|
470
|
+
return null;
|
|
221
471
|
const p = sessionFile(id);
|
|
222
472
|
if (!existsSync(p))
|
|
223
473
|
return null;
|
|
224
|
-
|
|
474
|
+
const data = readSessionFile(p);
|
|
475
|
+
return data?.meta.id === id ? data : null; // a corrupt, spoofed, or hand-edited file resumes as "no session"
|
|
225
476
|
}
|
|
226
477
|
/** Session metas, newest first; optionally filtered to a cwd. */
|
|
227
478
|
export function listSessions(cwd) {
|
|
@@ -230,12 +481,12 @@ export function listSessions(cwd) {
|
|
|
230
481
|
if (!f.endsWith(".json"))
|
|
231
482
|
continue;
|
|
232
483
|
const d = readSessionFile(join(sessionsDir(), f));
|
|
233
|
-
if (d?.meta.id && d.meta.updatedAt)
|
|
234
|
-
metas.push(d.meta); // skip
|
|
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
|
|
235
486
|
}
|
|
236
487
|
if (cwd)
|
|
237
488
|
metas = metas.filter((m) => m.cwd === cwd);
|
|
238
|
-
return metas.sort((a, b) => b.updatedAt.
|
|
489
|
+
return metas.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt));
|
|
239
490
|
}
|
|
240
491
|
export function latestForCwd(cwd) {
|
|
241
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,25 +1,30 @@
|
|
|
1
|
-
import { readFile, stat } from "node:fs/promises";
|
|
2
1
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
2
|
import { homedir } from "node:os";
|
|
4
3
|
import { resolve, isAbsolute } from "node:path";
|
|
5
4
|
import { stdout as procOut } from "node:process";
|
|
6
5
|
import { registerTool } from "./registry.js";
|
|
7
6
|
import { runShell } from "../sandbox.js";
|
|
8
|
-
import {
|
|
7
|
+
import { nearestPaths } from "../fs-walk.js";
|
|
9
8
|
import { emitDiff } from "../diff.js";
|
|
10
9
|
import { recordEdit } from "../undo.js";
|
|
11
10
|
import { atomicWriteText } from "../fs-write.js";
|
|
12
11
|
import { invalidateFileCandidates } from "../context/mentions.js";
|
|
13
|
-
import { BinaryFileError, streamFileSlice } from "../fs-read.js";
|
|
12
|
+
import { BinaryFileError, readRegularFileSnapshot, streamFileSlice } from "../fs-read.js";
|
|
14
13
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
15
14
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
16
15
|
const MAX = 100_000;
|
|
17
|
-
/** Package installs are network-bound and routinely exceed the foreground cap.
|
|
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. */
|
|
16
|
+
/** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
|
|
20
17
|
export function isPackageInstallCommand(command) {
|
|
21
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());
|
|
22
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
|
+
}
|
|
23
28
|
export function isNgrokTunnelCommand(command) {
|
|
24
29
|
return /(?:^|[;&|]\s*)ngrok\s+(?:http|tcp|tls|start)\b/i.test(command.trim());
|
|
25
30
|
}
|
|
@@ -79,7 +84,6 @@ export function capHeadTail(s, max = MAX) {
|
|
|
79
84
|
}
|
|
80
85
|
const READ_LINES = 300; // sized to stay useful under the global 24k-char tool-result context boundary
|
|
81
86
|
const LINE_CAP = 2000; // chars per line before truncation (minified bundles / data lines)
|
|
82
|
-
const BUFFERED_READ_BYTES = 4 * 1024 * 1024;
|
|
83
87
|
/** Render a line slice of a file, cat -n style. The old read_file dumped the WHOLE file (100K-char cap,
|
|
84
88
|
* tail simply lost) — on long files that both flooded the context (~25k tokens per read, again on every
|
|
85
89
|
* re-read) and made everything past the cap unreachable. Now: line numbers (anchor for edits and for
|
|
@@ -120,19 +124,15 @@ registerTool({
|
|
|
120
124
|
async run(input, ctx) {
|
|
121
125
|
const p = abs(input.path, ctx.cwd);
|
|
122
126
|
try {
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
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));
|
|
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 }));
|
|
130
130
|
}
|
|
131
131
|
catch (e) {
|
|
132
132
|
if (e instanceof BinaryFileError)
|
|
133
133
|
return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
|
|
134
134
|
const near = nearestPaths(ctx.cwd, input.path);
|
|
135
|
-
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(", ")}?` : "");
|
|
136
136
|
}
|
|
137
137
|
},
|
|
138
138
|
});
|
|
@@ -152,26 +152,28 @@ registerTool({
|
|
|
152
152
|
const p = abs(input.path, ctx.cwd);
|
|
153
153
|
if (typeof input.content !== "string")
|
|
154
154
|
return "Error: write_file `content` must be a string. No changes written.";
|
|
155
|
-
let
|
|
155
|
+
let prevSnapshot = null;
|
|
156
156
|
try {
|
|
157
|
-
|
|
157
|
+
prevSnapshot = await readRegularFileSnapshot(p);
|
|
158
158
|
}
|
|
159
159
|
catch (error) {
|
|
160
160
|
if (error?.code !== "ENOENT")
|
|
161
|
-
return `Error: cannot inspect ${input.path}: ${error?.
|
|
161
|
+
return `Error: cannot inspect ${input.path}: ${error?.message ?? error?.code}. No changes written.`;
|
|
162
162
|
}
|
|
163
|
+
const prev = prevSnapshot?.text ?? null;
|
|
163
164
|
if (prev === input.content)
|
|
164
165
|
return `Unchanged ${p} (${input.content.length} chars already match).`;
|
|
166
|
+
let committed;
|
|
165
167
|
try {
|
|
166
|
-
await atomicWriteText(p, input.content, { expected: prev });
|
|
168
|
+
committed = await atomicWriteText(p, input.content, { expected: prev, expectedIdentity: prevSnapshot ?? undefined });
|
|
167
169
|
}
|
|
168
170
|
catch (error) {
|
|
169
171
|
return `Error: cannot write ${input.path}: ${error?.message ?? String(error)} No changes written.`;
|
|
170
172
|
}
|
|
171
173
|
emitDiff(input.path, prev ?? "", input.content, ctx.ui);
|
|
172
|
-
recordEdit([{ path: input.path, absPath: p, before: prev }]);
|
|
174
|
+
recordEdit([{ path: input.path, absPath: p, before: prev, beforeMode: prevSnapshot?.mode, committed, after: input.content }]);
|
|
173
175
|
invalidateFileCandidates(ctx.cwd);
|
|
174
|
-
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("; ")}` : "");
|
|
175
177
|
},
|
|
176
178
|
});
|
|
177
179
|
registerTool({
|
|
@@ -181,8 +183,8 @@ registerTool({
|
|
|
181
183
|
type: "object",
|
|
182
184
|
properties: {
|
|
183
185
|
command: { type: "string" },
|
|
184
|
-
timeout_ms: { type: "number", description: "default 300000 (5 min)
|
|
185
|
-
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" },
|
|
186
188
|
},
|
|
187
189
|
required: ["command"],
|
|
188
190
|
},
|
|
@@ -192,10 +194,9 @@ registerTool({
|
|
|
192
194
|
return ("Skipped ngrok tunnel: no authentication was found in NGROK_AUTHTOKEN/NGROK_API_KEY or the standard ngrok config files. " +
|
|
193
195
|
"Configure ngrok authentication first, then retry. Do not rotate through other tunnel providers blindly; ask the user which authenticated provider to use.");
|
|
194
196
|
}
|
|
195
|
-
|
|
196
|
-
if (input.background || autoPackageJob) {
|
|
197
|
+
if (input.background) {
|
|
197
198
|
const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
|
|
198
|
-
return
|
|
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.`;
|
|
199
200
|
}
|
|
200
201
|
// Network fault tolerance — short-circuit if this command targets a host already found unreachable this
|
|
201
202
|
// session, so a repeat doesn't burn another ~75s OS connect timeout. Only pays the git-remote lookup
|
|
@@ -226,9 +227,10 @@ registerTool({
|
|
|
226
227
|
: procOut.isTTY
|
|
227
228
|
? (s) => procOut.write(s) // stream output in a plain terminal
|
|
228
229
|
: undefined;
|
|
230
|
+
const timeout = shellTimeoutMs(input.command, input.timeout_ms);
|
|
229
231
|
try {
|
|
230
232
|
const { stdout, stderr } = await runShell(input.command, ctx.cwd, ctx.sandbox ?? "off", {
|
|
231
|
-
timeout
|
|
233
|
+
timeout,
|
|
232
234
|
maxBuffer: 10 * 1024 * 1024,
|
|
233
235
|
onData: live,
|
|
234
236
|
});
|
|
@@ -243,7 +245,7 @@ registerTool({
|
|
|
243
245
|
// lane instead of blind-retrying into the same wall.
|
|
244
246
|
if (/timed out after \d+ms/.test(String(e.message))) {
|
|
245
247
|
base +=
|
|
246
|
-
`\n⏱ hara: the command hit its ${
|
|
248
|
+
`\n⏱ hara: the command hit its ${timeout}ms cap and was killed. Pick ONE: ` +
|
|
247
249
|
`a long build/transform → re-run with a larger timeout_ms; a server/watcher → background:true; ` +
|
|
248
250
|
`a network op (git/curl/npm) → do NOT just retry — check connectivity/proxy or skip this step and tell the user.`;
|
|
249
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 {
|