@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.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
@@ -1,8 +1,9 @@
1
1
  // Session persistence — conversations saved as JSON under ~/.hara/sessions, resumable.
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
- import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync, rmSync } from "node:fs";
4
+ import { chmodSync, closeSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
5
5
  import { randomUUID } from "node:crypto";
6
+ import { redactSensitiveValue } from "../security/secrets.js";
6
7
  /** Derive the session source from the spawn environment — the gateway subprocess runs with
7
8
  * HARA_GATEWAY=<platform>, the cron runner with HARA_CRON=1 (+ HARA_CRON_NAME=<job name>). */
8
9
  export function sessionSourceFromEnv() {
@@ -20,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
- const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
27
- const lockFile = (id) => join(sessionsDir(), `${id}.lock`);
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 exclusive lock on a session so two hara processes can't resume the SAME session and corrupt
40
- * its append-only history by racing writes. Returns `{ ok: true }` if acquired (or the previous holder
41
- * is dead we take over), or `{ ok: false, pid }` if a LIVE process already holds it. Best-effort: any
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
- const f = lockFile(id);
125
+ let f;
126
+ let reclaim;
46
127
  try {
47
- if (existsSync(f)) {
48
- const held = JSON.parse(readFileSync(f, "utf8"));
49
- if (typeof held.pid === "number" && held.pid !== process.pid && pidAlive(held.pid)) {
50
- return { ok: false, pid: held.pid };
51
- }
52
- // stale (dead pid) or already ours → fall through and (re)claim it
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
- writeFileSync(f, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
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
- if (!existsSync(f))
66
- return;
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
- /* best-effort */
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
- const f = sessionFile(id);
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
- rmSync(lockFile(id), { force: true });
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 hit = listSessions().find((m) => m.id.startsWith(idOrPrefix));
103
- return hit ? hit.id : null;
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 data = { meta, history };
160
- writeFileSync(sessionFile(meta.id), JSON.stringify(data, null, 2), "utf8");
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
- /** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
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" && !!o.meta && typeof o.meta === "object" && Array.isArray(o.history);
454
+ return !!o && typeof o === "object" && !Array.isArray(o) && isSessionMeta(o.meta) &&
455
+ Array.isArray(o.history) && o.history.every(isNeutralMessage);
166
456
  }
167
- export function loadSession(id) {
168
- const p = sessionFile(id);
169
- if (!existsSync(p))
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; // a corrupt / hand-edited file resumes as "no session" instead of crashing
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
- try {
186
- const d = JSON.parse(readFileSync(join(sessionsDir(), f), "utf8"));
187
- if (d?.meta && typeof d.meta === "object" && d.meta.id && d.meta.updatedAt)
188
- metas.push(d.meta); // skip metaless/corrupt
189
- }
190
- catch {
191
- /* skip corrupt */
192
- }
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.localeCompare(a.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
@@ -1,17 +1,53 @@
1
- import { readFile, stat } from "node:fs/promises";
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 { isProbablyBinary, nearestPaths } from "../fs-walk.js";
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
- const info = await stat(p);
93
- if (info.size > BUFFERED_READ_BYTES)
94
- return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
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.code ?? e.message}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
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 prev = null;
155
+ let prevSnapshot = null;
125
156
  try {
126
- prev = await readFile(p, "utf8");
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?.code ?? error?.message}. No changes written.`;
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); raise for a long build/transform, or use background:true for a server" },
154
- background: { type: "boolean", description: "run as a background job (dev server, watcher, long task); returns a job id immediately tail/kill it with the `job` tool" },
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: input.timeout_ms ?? 300_000, // was 120s — a long file transform/build legitimately runs longer
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 ${input.timeout_ms ?? 300_000}ms cap and was killed. Pick ONE: ` +
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
  }
@@ -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
- const cfg = loadConfig();
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 {