@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.
Files changed (47) hide show
  1. package/CHANGELOG.md +66 -0
  2. package/README.md +40 -6
  3. package/dist/agent/loop.js +136 -21
  4. package/dist/agent/reminders.js +22 -7
  5. package/dist/agent/repeat-guard.js +26 -7
  6. package/dist/agent/structured.js +231 -0
  7. package/dist/agent/touched.js +20 -6
  8. package/dist/config.js +33 -23
  9. package/dist/cron/deliver.js +37 -3
  10. package/dist/feedback.js +3 -2
  11. package/dist/fs-read.js +106 -12
  12. package/dist/fs-write.js +242 -16
  13. package/dist/gateway/dingtalk.js +4 -1
  14. package/dist/gateway/discord.js +53 -18
  15. package/dist/gateway/feishu.js +158 -57
  16. package/dist/gateway/flows-pending.js +720 -0
  17. package/dist/gateway/flows.js +391 -16
  18. package/dist/gateway/matrix.js +80 -15
  19. package/dist/gateway/mattermost.js +44 -32
  20. package/dist/gateway/media.js +659 -0
  21. package/dist/gateway/serve.js +657 -162
  22. package/dist/gateway/sessions.js +475 -78
  23. package/dist/gateway/signal.js +27 -22
  24. package/dist/gateway/slack.js +26 -17
  25. package/dist/gateway/telegram.js +32 -18
  26. package/dist/gateway/wecom.js +32 -24
  27. package/dist/gateway/weixin.js +127 -49
  28. package/dist/hooks.js +32 -20
  29. package/dist/index.js +631 -222
  30. package/dist/org/projects.js +347 -0
  31. package/dist/org/roles.js +38 -11
  32. package/dist/security/secrets.js +84 -9
  33. package/dist/serve/server.js +772 -317
  34. package/dist/serve/sessions.js +113 -33
  35. package/dist/session/store.js +298 -47
  36. package/dist/tools/all.js +1 -0
  37. package/dist/tools/builtin.js +30 -28
  38. package/dist/tools/codebase.js +3 -1
  39. package/dist/tools/computer.js +98 -92
  40. package/dist/tools/edit.js +11 -8
  41. package/dist/tools/patch.js +230 -31
  42. package/dist/tools/search.js +482 -72
  43. package/dist/tools/task.js +453 -0
  44. package/dist/tools/todo.js +67 -16
  45. package/dist/tools/web.js +168 -54
  46. package/dist/undo.js +83 -7
  47. package/package.json +1 -1
@@ -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
- chmodSync(d, 0o700); // tighten legacy installs too; session transcripts are private
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
- const sessionFile = (id) => join(sessionsDir(), `${id}.json`);
29
- 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
+ }
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 exclusive lock on a session so two hara processes can't resume the SAME session and corrupt
42
- * its append-only history by racing writes. Returns `{ ok: true }` if acquired (or the previous holder
43
- * is dead we take over), or `{ ok: false, pid }` if a LIVE process already holds it. Best-effort: any
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
- const f = lockFile(id);
125
+ let f;
126
+ let reclaim;
48
127
  try {
49
- if (existsSync(f)) {
50
- const held = JSON.parse(readFileSync(f, "utf8"));
51
- if (typeof held.pid === "number" && held.pid !== process.pid && pidAlive(held.pid)) {
52
- return { ok: false, pid: held.pid };
53
- }
54
- // 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 };
55
140
  }
56
- writeFileSync(f, JSON.stringify({ pid: process.pid, startedAt: Date.now() }));
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
- if (!existsSync(f))
68
- return;
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
- /* best-effort */
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
- const f = sessionFile(id);
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
- rmSync(lockFile(id), { force: true });
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 hit = listSessions().find((m) => m.id.startsWith(idOrPrefix));
105
- return hit ? hit.id : null;
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
- export function saveSession(meta, history) {
160
- meta.updatedAt = new Date().toISOString();
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
- // Keep structural routing/identity fields stable even if a path/model happens to resemble a secret.
166
- safe.meta.id = meta.id;
167
- safe.meta.cwd = meta.cwd;
168
- safe.meta.provider = meta.provider;
169
- safe.meta.model = meta.model;
170
- safe.meta.createdAt = meta.createdAt;
171
- safe.meta.updatedAt = meta.updatedAt;
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
- renameSync(tmp, target); // same filesystem: readers see the complete old or complete new JSON
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
- /** True if a parsed object has the SessionData shape we can safely use (meta object + history array). */
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" && !!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);
206
456
  }
207
- /** Parse a legacy session and redact the in-memory copy. Reads stay read-only (no unlocked migration race);
208
- * the next explicit save atomically replaces the old file with its private, redacted form. */
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
- if (!isSessionData(d))
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
- return readSessionFile(p); // a corrupt / hand-edited file resumes as "no session" instead of crashing
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 metaless/corrupt; never mutate while listing
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.localeCompare(a.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
@@ -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 { isProbablyBinary, nearestPaths } from "../fs-walk.js";
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. When the model omits an
18
- * explicit foreground/background choice, detach these commands into the existing job system so the UI
19
- * remains responsive and the agent can poll output instead of waiting five minutes for a hard kill. */
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
- const info = await stat(p);
124
- if (info.size > BUFFERED_READ_BYTES)
125
- return cap(await streamFileSlice(p, input.offset, input.limit ?? READ_LINES, { lineCap: LINE_CAP }));
126
- const buf = await readFile(p);
127
- if (isProbablyBinary(buf))
128
- throw new BinaryFileError(p);
129
- return cap(renderFileSlice(buf.toString("utf8"), input.offset, input.limit));
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.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(", ")}?` : "");
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 prev = null;
155
+ let prevSnapshot = null;
156
156
  try {
157
- prev = await readFile(p, "utf8");
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?.code ?? error?.message}. No changes written.`;
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); set explicitly for a foreground long build/transform" },
185
- background: { type: "boolean", description: "run as a background job (dev server, watcher, long task). Package installs auto-background when this field and timeout_ms are omitted. Poll with the `job` tool before depending on completion." },
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
- const autoPackageJob = input.background === undefined && input.timeout_ms === undefined && isPackageInstallCommand(input.command);
196
- if (input.background || autoPackageJob) {
197
+ if (input.background) {
197
198
  const id = startJob(input.command, ctx.cwd, ctx.sandbox ?? "off");
198
- return `${autoPackageJob ? "Package install auto-started" : "Started"} background job ${id}: \`${input.command}\`. Manage with the \`job\` tool — {action:"tail",id:"${id}"} for output, {action:"kill",id:"${id}"} to stop, {action:"list"} for all. Poll until it exits before running steps that depend on installed packages.`;
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: input.timeout_ms ?? 300_000, // was 120s — a long file transform/build legitimately runs longer
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 ${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: ` +
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
  }
@@ -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 {