@nanhara/hara 0.122.3 → 0.122.4

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 (60) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +15 -2
  3. package/dist/agent/limits.js +51 -0
  4. package/dist/agent/loop.js +367 -112
  5. package/dist/agent/repeat-guard.js +49 -12
  6. package/dist/checkpoints.js +9 -0
  7. package/dist/cli.js +2 -1
  8. package/dist/config.js +10 -2
  9. package/dist/context/agents-md.js +21 -2
  10. package/dist/context/mentions.js +84 -1
  11. package/dist/context/workspace-scope.js +49 -0
  12. package/dist/cron/deliver.js +61 -38
  13. package/dist/cron/runner.js +423 -65
  14. package/dist/cron/schedule.js +23 -7
  15. package/dist/cron/store.js +709 -43
  16. package/dist/fs-walk.js +279 -7
  17. package/dist/fs-write.js +9 -0
  18. package/dist/gateway/feishu.js +351 -64
  19. package/dist/gateway/flows-pending.js +28 -14
  20. package/dist/gateway/flows.js +306 -31
  21. package/dist/gateway/outbound-files.js +20 -6
  22. package/dist/gateway/runtime-state.js +1485 -0
  23. package/dist/gateway/serve.js +550 -242
  24. package/dist/gateway/telegram.js +279 -29
  25. package/dist/gateway/tts.js +182 -38
  26. package/dist/hooks.js +22 -22
  27. package/dist/index.js +466 -158
  28. package/dist/memory/store.js +8 -5
  29. package/dist/org/planner.js +11 -6
  30. package/dist/process-identity.js +52 -0
  31. package/dist/providers/bounded-turn.js +42 -0
  32. package/dist/recall.js +69 -1
  33. package/dist/runtime.js +24 -0
  34. package/dist/sandbox.js +54 -4
  35. package/dist/search/embed.js +13 -2
  36. package/dist/search/hybrid.js +6 -3
  37. package/dist/search/semindex.js +87 -5
  38. package/dist/security/guardian.js +3 -15
  39. package/dist/security/permissions.js +11 -0
  40. package/dist/serve/server.js +70 -42
  41. package/dist/serve/sessions.js +4 -3
  42. package/dist/tools/agent.js +1 -1
  43. package/dist/tools/ask_user.js +5 -1
  44. package/dist/tools/builtin.js +5 -2
  45. package/dist/tools/codebase.js +67 -18
  46. package/dist/tools/computer.js +149 -68
  47. package/dist/tools/cron.js +72 -18
  48. package/dist/tools/edit.js +3 -2
  49. package/dist/tools/external_agent.js +66 -12
  50. package/dist/tools/memory.js +25 -7
  51. package/dist/tools/patch.js +11 -2
  52. package/dist/tools/registry.js +16 -1
  53. package/dist/tools/search.js +68 -9
  54. package/dist/tools/send.js +1 -1
  55. package/dist/tools/skill.js +1 -1
  56. package/dist/tools/web.js +43 -13
  57. package/dist/tui/App.js +93 -25
  58. package/dist/vision.js +5 -6
  59. package/package.json +2 -2
  60. package/runtime-bootstrap.cjs +22 -0
@@ -7,10 +7,11 @@ import { createHash } from "node:crypto";
7
7
  import { homedir } from "node:os";
8
8
  import { basename, join, isAbsolute, resolve } from "node:path";
9
9
  import { findProjectRoot } from "../context/agents-md.js";
10
- import { listProjectFiles, walkFiles } from "../fs-walk.js";
10
+ import { listProjectFiles, listProjectFilesAsync, walkFiles, walkFilesAsync, } from "../fs-walk.js";
11
11
  import { zvecBuild, zvecQueryIds, zvecRemove } from "./zvec-store.js";
12
12
  import { isSensitiveFilePath } from "../security/sensitive-files.js";
13
13
  import { readModelContextFileSync } from "../fs-read.js";
14
+ import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
14
15
  import { ensurePrivateStateSubdirectory, readPrivateStateFileSnapshot, removePrivateStateFile, } from "../security/private-state.js";
15
16
  import { atomicWriteText, bindHaraPrivateStateWritePath, } from "../fs-write.js";
16
17
  // Same code/text extensions codebase_search ranks lexically — keep the two walks in sync.
@@ -51,6 +52,8 @@ async function ensurePrivateIndexState(name, cwd) {
51
52
  }
52
53
  function existingPrivateIndexPath(name, cwd) {
53
54
  try {
55
+ if (name === "repo" && isHomeWorkspace(cwd))
56
+ return null;
54
57
  const requested = indexPath(name, cwd);
55
58
  const base = realpathSync.native(indexBase(name, cwd));
56
59
  const hara = join(base, ".hara");
@@ -126,7 +129,11 @@ async function rotateLegacyIndex(name, cwd, state) {
126
129
  /** Build/refresh the index. **Incremental**: files whose content hash is unchanged keep their existing
127
130
  * vectors (no re-embed); only new/changed files are embedded, and deleted files drop out. A changed
128
131
  * embedding model forces a full rebuild (old vectors aren't comparable). Returns counts. */
129
- export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
132
+ export async function buildIndex(name, chunks, embed, cwd, model = "embed", signal) {
133
+ if (signal?.aborted)
134
+ throw new Error("semantic index build interrupted");
135
+ if (name === "repo" && isHomeWorkspace(cwd))
136
+ throw new Error(homeWorkspaceActionError("build a repository index"));
130
137
  const state = await ensurePrivateIndexState(name, cwd);
131
138
  const p = state.path;
132
139
  // Load the previous index → reuse vectors for unchanged files.
@@ -169,6 +176,8 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
169
176
  const toEmbed = [];
170
177
  let reused = 0;
171
178
  for (const [file, fchunks] of byFile) {
179
+ if (signal?.aborted)
180
+ throw new Error("semantic index build interrupted");
172
181
  const currentHash = contentHash(fchunks);
173
182
  const prev = prevByFile.get(file);
174
183
  if (sameModel
@@ -183,11 +192,17 @@ export async function buildIndex(name, chunks, embed, cwd, model = "embed") {
183
192
  }
184
193
  const B = 64;
185
194
  for (let i = 0; i < toEmbed.length; i += B) {
195
+ if (signal?.aborted)
196
+ throw new Error("semantic index build interrupted");
186
197
  const batch = toEmbed.slice(i, i + B);
187
- const vecs = await embed(batch.map((c) => c.text));
198
+ const vecs = await embed(batch.map((c) => c.text), signal);
199
+ if (signal?.aborted)
200
+ throw new Error("semantic index build interrupted");
188
201
  batch.forEach((c, j) => vecs[j] && items.push({ ...c, vec: vecs[j] }));
189
202
  }
190
203
  // The index is derived + rebuildable (and may embed file contents) — never let it be committed.
204
+ if (signal?.aborted)
205
+ throw new Error("semantic index build interrupted");
191
206
  if (!state.ignoreSnapshot || state.ignoreSnapshot.text !== "*\n") {
192
207
  await atomicWriteText(state.ignoreBoundary.target, "*\n", {
193
208
  expected: state.ignoreSnapshot?.text ?? null,
@@ -239,6 +254,8 @@ export function collectDirChunks(dir, source) {
239
254
  }
240
255
  /** Walk the repo (respecting .gitignore) and chunk every code/text file — the corpus `hara index` embeds. */
241
256
  export function collectRepoChunks(root) {
257
+ if (isHomeWorkspace(root))
258
+ throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
242
259
  const chunks = [];
243
260
  for (const rel of listProjectFiles(root)) {
244
261
  if (!CODE_RE.test(rel) || looksSecret(rel))
@@ -255,8 +272,65 @@ export function collectRepoChunks(root) {
255
272
  }
256
273
  return chunks;
257
274
  }
275
+ async function chunksFromInventory(root, files, source, absolutePaths, startedAt, timeoutMs, signal) {
276
+ const chunks = [];
277
+ let fileIndex = 0;
278
+ for (const rel of files) {
279
+ if (signal?.aborted)
280
+ throw signal.reason instanceof Error ? signal.reason : new Error("index collection cancelled");
281
+ if (Date.now() - startedAt >= timeoutMs)
282
+ return { chunks, truncated: true, reason: "time_limit" };
283
+ if (fileIndex++ > 0 && fileIndex % 32 === 0) {
284
+ await new Promise((resolveTurn) => setImmediate(resolveTurn));
285
+ if (signal?.aborted)
286
+ throw signal.reason instanceof Error ? signal.reason : new Error("index collection cancelled");
287
+ }
288
+ if (!CODE_RE.test(rel) || looksSecret(rel))
289
+ continue;
290
+ const abs = join(root, rel);
291
+ let text;
292
+ try {
293
+ text = readModelContextFileSync(abs, 200_000);
294
+ }
295
+ catch {
296
+ continue;
297
+ }
298
+ chunks.push(...chunkText(text, absolutePaths ? abs : rel, source, statMtime(abs)));
299
+ }
300
+ return { chunks, truncated: false };
301
+ }
302
+ /** Interruptible knowledge-directory collection for the explicit index command and background callers. */
303
+ export async function collectDirChunksAsync(dir, source, options = {}) {
304
+ if (!existsSync(dir))
305
+ return { chunks: [], truncated: false };
306
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
307
+ const startedAt = Date.now();
308
+ const inventory = await walkFilesAsync(dir, { ...options, timeoutMs });
309
+ const collected = await chunksFromInventory(dir, inventory.files, source, true, startedAt, timeoutMs, options.signal);
310
+ return collected.truncated ? collected : {
311
+ chunks: collected.chunks,
312
+ truncated: inventory.truncated,
313
+ reason: inventory.reason,
314
+ };
315
+ }
316
+ /** Interruptible repository collection. Git discovery and file reads share one total wall budget. */
317
+ export async function collectRepoChunksAsync(root, options = {}) {
318
+ if (isHomeWorkspace(root))
319
+ throw new Error(homeWorkspaceActionError("scan the home directory for a repository index"));
320
+ const timeoutMs = Number.isFinite(options.timeoutMs) ? Math.max(0, Math.floor(options.timeoutMs)) : 30_000;
321
+ const startedAt = Date.now();
322
+ const inventory = await listProjectFilesAsync(root, { ...options, timeoutMs });
323
+ const collected = await chunksFromInventory(root, inventory.files, "repo", false, startedAt, timeoutMs, options.signal);
324
+ return collected.truncated ? collected : {
325
+ chunks: collected.chunks,
326
+ truncated: inventory.truncated,
327
+ reason: inventory.reason,
328
+ };
329
+ }
258
330
  /** Cosine-rank the index against the query embedding. Returns top-k hits (empty if no index). */
259
- export async function queryIndex(name, query, embed, cwd, k = 6) {
331
+ export async function queryIndex(name, query, embed, cwd, k = 6, signal) {
332
+ if (signal?.aborted)
333
+ throw new Error("semantic query interrupted");
260
334
  if (!existingPrivateIndexPath(name, cwd))
261
335
  return [];
262
336
  let state;
@@ -291,7 +365,9 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
291
365
  });
292
366
  if (!safeItems.length)
293
367
  return [];
294
- const [qv] = await embed([query]);
368
+ const [qv] = await embed([query], signal);
369
+ if (signal?.aborted)
370
+ throw new Error("semantic query interrupted");
295
371
  if (!qv)
296
372
  return [];
297
373
  // A format marker proves which writer created the cache, not that its source still has the same bytes.
@@ -321,6 +397,8 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
321
397
  .sort((a, b) => b.score - a.score);
322
398
  const hits = [];
323
399
  for (const { item, score } of ranked) {
400
+ if (signal?.aborted)
401
+ break;
324
402
  if (!isFresh(item))
325
403
  continue;
326
404
  hits.push({ file: item.file, source: item.source, text: item.text, score });
@@ -332,7 +410,11 @@ export async function queryIndex(name, query, embed, cwd, k = 6) {
332
410
  // Prefer the zvec ANN index for candidate retrieval; re-rank candidates by EXACT cosine from the JSON
333
411
  // store (identical score semantics to the brute-force path). Fall back to full brute-force if zvec is
334
412
  // unavailable / has no index / errors.
413
+ if (signal?.aborted)
414
+ throw new Error("semantic query interrupted");
335
415
  const ids = await zvecQueryIds(name, qv, cwd, k);
416
+ if (signal?.aborted)
417
+ throw new Error("semantic query interrupted");
336
418
  if (ids?.length) {
337
419
  const byId = new Map(safeItems.map((it) => [it.id, it]));
338
420
  const candidates = ids
@@ -17,6 +17,7 @@
17
17
  // trips a hard stop that requires explicit user confirmation to continue — a harder stop than the soft
18
18
  // stuck-guard nudge, and one that aborts safely (never hangs) when there's no interactive user.
19
19
  import { resolve, isAbsolute } from "node:path";
20
+ import { boundedProviderTurn } from "../providers/bounded-turn.js";
20
21
  import { canonicalize, splitCompound } from "./permissions.js";
21
22
  // ── Deterministic risk classifier ────────────────────────────────────────────────────────────────────
22
23
  // The classifier is intentionally NARROW: false positives here cost latency + an LLM call + potential
@@ -208,23 +209,13 @@ export async function guardianVeto(provider, action, history, opts = {}) {
208
209
  `HIGH-RISK ACTION (flagged: ${action.classifierReason}):\n` +
209
210
  `tool: ${action.tool}\n${action.detail}\n\n` +
210
211
  `Allow or block this action? Reply with only the JSON verdict.`;
211
- const ac = new AbortController();
212
- const timer = setTimeout(() => ac.abort(), opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
213
- // Chain the caller's interrupt signal so Esc also aborts the guardian call.
214
- if (opts.signal) {
215
- if (opts.signal.aborted)
216
- ac.abort();
217
- else
218
- opts.signal.addEventListener("abort", () => ac.abort(), { once: true });
219
- }
220
212
  try {
221
- const r = await provider.turn({
213
+ const r = await boundedProviderTurn(provider, {
222
214
  system: GUARDIAN_SYSTEM,
223
215
  history: [{ role: "user", content: prompt }],
224
216
  tools: [],
225
217
  onText: () => { },
226
- signal: ac.signal,
227
- });
218
+ }, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal: opts.signal, label: "security guardian" });
228
219
  if (r.stop === "error")
229
220
  return { decision: "allow", reason: "" }; // fail-open on model error
230
221
  return parseVerdict(r.text);
@@ -232,9 +223,6 @@ export async function guardianVeto(provider, action, history, opts = {}) {
232
223
  catch {
233
224
  return { decision: "allow", reason: "" }; // fail-open on timeout/abort/throw
234
225
  }
235
- finally {
236
- clearTimeout(timer);
237
- }
238
226
  }
239
227
  export function newBreaker() {
240
228
  return { blocks: 0, tripped: false };
@@ -15,6 +15,7 @@ import { closeSync, existsSync, fstatSync, fsyncSync, linkSync, lstatSync, mkdir
15
15
  import { psArgumentsExposeEnvironment } from "./sensitive-files.js";
16
16
  import { projectRepositoryTrustedAtStartup } from "./project-trust.js";
17
17
  import { readVerifiedRegularFileSnapshotSync } from "../fs-read.js";
18
+ import { homeWorkspaceActionError, isHomeWorkspace } from "../context/workspace-scope.js";
18
19
  const PROJECT_ROOT_MARKERS = [".git", "package.json", "Cargo.toml", "go.mod", "pyproject.toml", ".hg"];
19
20
  const MAX_PROJECT_PERMISSIONS_BYTES = 64 * 1024;
20
21
  const projectPermissionWarnings = new Set();
@@ -279,6 +280,10 @@ function readProjectRules(cwd) {
279
280
  dir = resolve(cwd);
280
281
  }
281
282
  for (;;) {
283
+ // ~/.hara/permissions.json is global policy, not a project policy file. Stopping before Home also
284
+ // prevents an unmarked child workspace from inheriting a repository marker above the user's home.
285
+ if (isHomeWorkspace(dir))
286
+ break;
282
287
  const hara = join(dir, ".hara");
283
288
  const file = join(hara, "permissions.json");
284
289
  let parentInfo;
@@ -350,6 +355,8 @@ export function globalPermissionsPath() {
350
355
  export function projectPermissionsPath(cwd) {
351
356
  let dir = resolve(cwd);
352
357
  for (;;) {
358
+ if (isHomeWorkspace(dir))
359
+ break;
353
360
  const p = join(dir, ".hara", "permissions.json");
354
361
  if (existsSync(p))
355
362
  return p;
@@ -426,6 +433,10 @@ function existingScaffoldTarget(path) {
426
433
  /** Create project permissions without following `.hara` or replacing any existing directory entry. */
427
434
  function scaffoldProjectPermissions(cwd) {
428
435
  const project = realpathSync.native(resolve(cwd));
436
+ // Project scaffolding at ~/ would target the same ~/.hara/permissions.json used by global policy and
437
+ // silently promote repository starter rules to every Hara session. Reject before creating `.hara`.
438
+ if (isHomeWorkspace(project))
439
+ throw new Error(homeWorkspaceActionError("create project permissions"));
429
440
  const projectIdentity = verifiedDirectory(project);
430
441
  const parent = join(project, ".hara");
431
442
  try {
@@ -16,14 +16,14 @@ import { analyzeContext } from "../agent/context-report.js";
16
16
  import { clearTouched, recentTouched } from "../agent/touched.js";
17
17
  import { resetRepeatGuard } from "../agent/repeat-guard.js";
18
18
  import { contextWindow, ctxPctFor } from "../statusbar.js";
19
- import { listProjectFiles } from "../fs-walk.js";
19
+ import { listProjectFilesAsync } from "../fs-walk.js";
20
20
  import { fuzzyRank } from "../fuzzy.js";
21
- import { loadAgentsMd } from "../context/agents-md.js";
22
- import { expandMentions } from "../context/mentions.js";
21
+ import { loadAgentContext } from "../context/agents-md.js";
22
+ import { expandMentionsAsync } from "../context/mentions.js";
23
23
  import { memoryDigest } from "../memory/store.js";
24
24
  import { listInstalled, enabledPlugins, setPluginEnabled, panelsForProject } from "../plugins/plugins.js";
25
25
  import { loadSkillIndex, loadSkillBody } from "../skills/skills.js";
26
- import { loadJobs, saveJobs, addJob } from "../cron/store.js";
26
+ import { loadJobs, addJob, removeJob, setEnabled } from "../cron/store.js";
27
27
  import { parseSchedule, describeSchedule } from "../cron/schedule.js";
28
28
  import { loadTasks } from "../tools/task.js";
29
29
  import { listPending, resolvePending } from "../gateway/flows-pending.js";
@@ -315,7 +315,7 @@ export async function startServe(opts, deps) {
315
315
  diff: (t) => broadcast("event.diff", { sessionId, text: t }),
316
316
  notice: (t) => broadcast("event.notice", { sessionId, text: t }),
317
317
  };
318
- const confirm = (q) => new Promise((resolve) => {
318
+ const confirm = (q, signal = turnAbort.signal) => new Promise((resolve) => {
319
319
  const approvalId = randomUUID();
320
320
  let settled = false;
321
321
  let timer;
@@ -325,16 +325,18 @@ export async function startServe(opts, deps) {
325
325
  settled = true;
326
326
  clearTimeout(timer);
327
327
  pendingApprovals.delete(approvalId);
328
- turnAbort.signal.removeEventListener("abort", onAbort);
328
+ signal.removeEventListener("abort", onAbort);
329
329
  resolve(v);
330
330
  };
331
331
  const onAbort = () => finish(false);
332
332
  timer = setTimeout(() => finish(false), APPROVAL_TIMEOUT_MS); // unanswered → deny, turn continues
333
333
  pendingApprovals.set(approvalId, finish);
334
- if (turnAbort.signal.aborted)
334
+ if (signal.aborted)
335
335
  finish(false);
336
336
  else {
337
- turnAbort.signal.addEventListener("abort", onAbort, { once: true });
337
+ // `signal` is runAgent's combined Esc + total-deadline signal. Listening only to turnAbort would
338
+ // leave the approval map and Desktop prompt stale when the internal lifecycle deadline fires.
339
+ signal.addEventListener("abort", onAbort, { once: true });
338
340
  broadcast("approval.request", { sessionId, approvalId, question: q });
339
341
  }
340
342
  });
@@ -358,14 +360,14 @@ export async function startServe(opts, deps) {
358
360
  }
359
361
  // @file mentions expand to file contents, same as the CLI (`@src/foo.ts` in the composer works).
360
362
  // Pasted images ride along as NeutralMsg.images — a vision-capable model sees them inline.
361
- s.history.push({ role: "user", content: expandMentions(content, s.meta.cwd), ...(images && images.length ? { images } : {}) });
363
+ s.history.push({ role: "user", content: await expandMentionsAsync(content, s.meta.cwd, { signal: turnAbort.signal }), ...(images && images.length ? { images } : {}) });
362
364
  const outcome = await runAgent(s.history, {
363
365
  provider: s.provider,
364
366
  ctx: {
365
367
  cwd: s.meta.cwd,
366
368
  sandbox: deps.sandbox,
367
369
  todoScope: sessionId,
368
- spawn: (t, role) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role),
370
+ spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal),
369
371
  ui: sink,
370
372
  },
371
373
  approval: s.approval,
@@ -384,7 +386,21 @@ export async function startServe(opts, deps) {
384
386
  };
385
387
  void turn.then(settled, settled);
386
388
  },
389
+ onToolRun: (toolRun) => {
390
+ s.pendingToolRuns += 1;
391
+ const settled = () => {
392
+ s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
393
+ // `abort === null` means the logical turn already returned. Keep the session busy/locked until
394
+ // every late side-effect-capable Promise has physically stopped.
395
+ if (s.pendingToolRuns === 0 && s.abort === null)
396
+ s.busy = false;
397
+ if (closing)
398
+ hub.releaseIdle();
399
+ };
400
+ void toolRun.then(settled, settled);
401
+ },
387
402
  guardian: turnGuardian,
403
+ ...(deps.runLimits?.(s.meta.cwd) ?? {}),
388
404
  });
389
405
  s.meta.todos = serializeTodos(sessionId);
390
406
  hub.save(s);
@@ -393,11 +409,11 @@ export async function startServe(opts, deps) {
393
409
  // clients render a meter without an extra round-trip.
394
410
  const ctx = ctxOf(s);
395
411
  if (outcome.status !== "completed") {
396
- const failure = outcome.status === "error"
397
- ? (outcome.error ?? "agent turn failed")
398
- : outcome.status === "empty"
399
- ? "the model returned an empty response after retrying"
400
- : "agent turn halted by the safety circuit-breaker";
412
+ const failure = outcome.error ?? (outcome.status === "empty"
413
+ ? "the model returned an empty response after retrying"
414
+ : outcome.status === "halted"
415
+ ? "agent turn halted by a safety control"
416
+ : "agent turn failed");
401
417
  broadcast("event.turn_end", { sessionId, reply: "", error: failure, status: outcome.status, usage, ctx });
402
418
  throw new Error(failure);
403
419
  }
@@ -408,8 +424,8 @@ export async function startServe(opts, deps) {
408
424
  return { reply, usage, ctx };
409
425
  }
410
426
  finally {
411
- s.busy = false;
412
427
  s.abort = null;
428
+ s.busy = s.pendingToolRuns > 0;
413
429
  }
414
430
  };
415
431
  /** Context watermark for a session: how full the model's window was on the last turn. */
@@ -444,27 +460,33 @@ export async function startServe(opts, deps) {
444
460
  if (controller.signal.aborted)
445
461
  return onAbort();
446
462
  // Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
447
- void Promise.resolve().then(() => s.provider.turn({
448
- system: COMPACT_SYSTEM,
449
- history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
450
- tools: [],
451
- onText: () => { },
452
- signal: controller.signal,
453
- })).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
463
+ void Promise.resolve().then(() => {
464
+ // The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
465
+ // the actual invocation boundary so an interrupted/expired compact cannot start a late request.
466
+ if (controller.signal.aborted)
467
+ throw new Error(timedOut ? "compaction timed out" : "compaction interrupted");
468
+ return s.provider.turn({
469
+ system: COMPACT_SYSTEM,
470
+ history: [...s.history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
471
+ tools: [],
472
+ onText: () => { },
473
+ signal: controller.signal,
474
+ });
475
+ }).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
454
476
  });
455
- if (r.stop === "error")
477
+ if (controller.signal.aborted || r.stop === "error")
456
478
  return null;
457
479
  const summary = r.text.trim();
458
480
  if (!summary)
459
481
  return null;
460
- s.meta.workingSet = workingSetFromSummary(summary);
461
- s.history.length = 0;
462
- s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
482
+ const workingSet = workingSetFromSummary(summary);
463
483
  const touched = recentTouched(20, s.meta.id).filter((file) => {
464
484
  const rel = relative(s.meta.cwd, file);
465
485
  return !!rel && rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
466
486
  }).slice(0, 5);
467
487
  const restore = buildFileRestore(touched, (f) => {
488
+ if (controller.signal.aborted)
489
+ return null;
468
490
  try {
469
491
  return readModelContextFileSync(f, 32 * 1024);
470
492
  }
@@ -472,6 +494,11 @@ export async function startServe(opts, deps) {
472
494
  return null;
473
495
  }
474
496
  });
497
+ if (controller.signal.aborted)
498
+ return null;
499
+ s.meta.workingSet = workingSet;
500
+ s.history.length = 0;
501
+ s.history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
475
502
  if (restore)
476
503
  s.history.push({ role: "user", content: restore });
477
504
  s.stats.input += r.usage?.input ?? 0;
@@ -530,7 +557,7 @@ export async function startServe(opts, deps) {
530
557
  if (!provider)
531
558
  return reply(rpcError(id, ERR.INTERNAL, "provider not authenticated — check the active profile and ~/.hara/config.json"));
532
559
  const approval = ["suggest", "auto-edit", "full-auto"].includes(p.approval) ? p.approval : deps.approval;
533
- const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentsMd(cwd) || undefined });
560
+ const s = hub.create({ cwd, provider, providerId: provider.id, model: provider.model, approval, projectContext: loadAgentContext(cwd) || undefined });
534
561
  return reply(rpcResult(id, { sessionId: s.meta.id, model: s.meta.model }));
535
562
  }
536
563
  case "session.resume": {
@@ -566,7 +593,7 @@ export async function startServe(opts, deps) {
566
593
  hub.detach(r.session.meta.id);
567
594
  return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
568
595
  }
569
- r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
596
+ r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
570
597
  return reply(rpcResult(id, { sessionId: r.session.meta.id, model: r.session.meta.model, history: historyForClient(r.session.history) }));
571
598
  }
572
599
  case "session.send": {
@@ -660,7 +687,7 @@ export async function startServe(opts, deps) {
660
687
  hub.delete(r.session.meta.id);
661
688
  return reply(rpcError(id, ERR.INTERNAL, `provider not authenticated for pinned model '${r.session.meta.model}'`));
662
689
  }
663
- r.session.projectContext = loadAgentsMd(r.session.meta.cwd) || undefined;
690
+ r.session.projectContext = loadAgentContext(r.session.meta.cwd) || undefined;
664
691
  return reply(rpcResult(id, { sessionId: r.session.meta.id, title: r.session.meta.title, model: r.session.meta.model, history: historyForClient(r.session.history) }));
665
692
  }
666
693
  case "session.delete": {
@@ -770,21 +797,15 @@ export async function startServe(opts, deps) {
770
797
  case "automation.toggle": {
771
798
  if (typeof p.id !== "string" || typeof p.enabled !== "boolean")
772
799
  return reply(rpcError(id, ERR.PARAMS, "id + enabled required"));
773
- const jobs = loadJobs();
774
- const job = jobs.find((j) => j.id === p.id);
775
- if (!job)
800
+ if (!setEnabled(p.id, p.enabled))
776
801
  return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
777
- job.enabled = p.enabled;
778
- saveJobs(jobs);
779
- return reply(rpcResult(id, { id: job.id, enabled: job.enabled }));
802
+ return reply(rpcResult(id, { id: p.id, enabled: p.enabled }));
780
803
  }
781
804
  case "automation.delete": {
782
805
  if (typeof p.id !== "string")
783
806
  return reply(rpcError(id, ERR.PARAMS, "id required"));
784
- const jobs = loadJobs();
785
- if (!jobs.some((j) => j.id === p.id))
807
+ if (!removeJob(p.id))
786
808
  return reply(rpcError(id, ERR.PARAMS, `no job ${p.id}`));
787
- saveJobs(jobs.filter((j) => j.id !== p.id));
788
809
  return reply(rpcResult(id, { id: p.id, deleted: true }));
789
810
  }
790
811
  case "skills.list": {
@@ -805,10 +826,17 @@ export async function startServe(opts, deps) {
805
826
  const sess = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
806
827
  const cwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (sess?.meta.cwd ?? opts.cwd);
807
828
  const limit = Math.min(Math.max(Math.trunc(Number(p.limit) || 20), 1), 50);
808
- const all = listProjectFiles(cwd);
829
+ const inventory = await listProjectFilesAsync(cwd, {
830
+ maxFiles: 8_000,
831
+ maxDirectories: 20_000,
832
+ maxEntries: 100_000,
833
+ timeoutMs: 1_000,
834
+ yieldEvery: 64,
835
+ });
836
+ const all = inventory.files;
809
837
  const query = typeof p.query === "string" ? p.query : "";
810
838
  const files = query ? fuzzyRank(query, all, (f) => f).slice(0, limit).map((r) => r.item) : all.slice(0, limit);
811
- return reply(rpcResult(id, { files, cwd }));
839
+ return reply(rpcResult(id, { files, cwd, truncated: inventory.truncated, reason: inventory.reason }));
812
840
  }
813
841
  case "session.context": {
814
842
  // context-spend breakdown + watermark on demand (codex thread/tokenUsage + /context).
@@ -934,7 +962,7 @@ export async function startServe(opts, deps) {
934
962
  await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
935
963
  let quiet = false;
936
964
  while (Date.now() < deadline) {
937
- if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0)) {
965
+ if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0 && session.pendingToolRuns === 0)) {
938
966
  quiet = true;
939
967
  break;
940
968
  }
@@ -47,7 +47,7 @@ export class SessionHub {
47
47
  const lock = this.store.acquire(meta.id); // fresh UUID, but filesystem errors must still fail closed
48
48
  if (!lock.ok)
49
49
  throw new Error(`could not acquire session lock for ${meta.id}${lock.pid ? ` (held by pid ${lock.pid})` : ""}`);
50
- const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null };
50
+ const s = { meta, history: [], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null };
51
51
  try {
52
52
  this.sessions.set(meta.id, s);
53
53
  this.store.save(meta, []); // an empty newly-created thread must survive restart and appear in lists
@@ -76,7 +76,7 @@ export class SessionHub {
76
76
  return { missing: true };
77
77
  // Credential/provider routing is live, while the model remains the session's explicit pin.
78
78
  prior.meta.provider = o.provider.id;
79
- const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, abort: null, effort: prior.meta.effort };
79
+ const s = { meta: prior.meta, history: [...prior.history], provider: o.provider, approval: o.approval, autoApprove: new Set(), stats: { input: 0, output: 0 }, projectContext: o.projectContext, busy: false, configuring: false, pendingProviderTurns: 0, pendingToolRuns: 0, abort: null, effort: prior.meta.effort };
80
80
  this.sessions.set(id, s);
81
81
  keepLock = true; // live session owns it until delete/releaseAll
82
82
  return { session: s };
@@ -180,6 +180,7 @@ export class SessionHub {
180
180
  busy: false,
181
181
  configuring: false,
182
182
  pendingProviderTurns: 0,
183
+ pendingToolRuns: 0,
183
184
  abort: null,
184
185
  effort: src.meta.effort,
185
186
  };
@@ -221,7 +222,7 @@ export class SessionHub {
221
222
  /** Release only idle sessions; logical work and abandoned-but-physical provider turns retain their locks. */
222
223
  releaseIdle() {
223
224
  for (const [id, session] of this.sessions) {
224
- if (session.busy || session.configuring || session.pendingProviderTurns > 0)
225
+ if (session.busy || session.configuring || session.pendingProviderTurns > 0 || session.pendingToolRuns > 0)
225
226
  continue;
226
227
  this.store.release(id);
227
228
  this.sessions.delete(id);
@@ -34,6 +34,6 @@ registerTool({
34
34
  return "Error: sub-agents are not available in this context.";
35
35
  if (typeof input.task !== "string" || !input.task.trim())
36
36
  return "Error: agent needs a `task`.";
37
- return await ctx.spawn(input.task, input.role ? String(input.role) : undefined);
37
+ return await ctx.spawn(input.task, input.role ? String(input.role) : undefined, ctx.signal);
38
38
  },
39
39
  });
@@ -52,11 +52,15 @@ registerTool({
52
52
  // Compose a compact prompt: [header] (context) question — the channel renders it.
53
53
  const prompt = [header ? `[${header}] ` : "", context ? `${context}\n` : "", question].join("");
54
54
  try {
55
- const answer = await ctx.ask(prompt, options && options.length ? options : undefined);
55
+ const answer = await ctx.ask(prompt, options && options.length ? options : undefined, ctx.signal);
56
56
  const text = typeof answer === "string" ? answer.trim() : "";
57
57
  return text || "(the user gave an empty answer)";
58
58
  }
59
59
  catch (e) {
60
+ // Cancellation is authoritative. Let the agent loop close the open tool round as interrupted/deadline;
61
+ // converting it into an ordinary "no user" result would let the model continue after Esc.
62
+ if (ctx.signal?.aborted)
63
+ throw e;
60
64
  // If the interactive prompt fails for any reason, degrade gracefully rather than crash the turn.
61
65
  return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
62
66
  }
@@ -4,7 +4,7 @@ import { resolve, isAbsolute } from "node:path";
4
4
  import { stdout as procOut } from "node:process";
5
5
  import { registerTool } from "./registry.js";
6
6
  import { runShell } from "../sandbox.js";
7
- import { nearestPaths } from "../fs-walk.js";
7
+ import { nearestPathsAsync } from "../fs-walk.js";
8
8
  import { emitDiff } from "../diff.js";
9
9
  import { recordEdit } from "../undo.js";
10
10
  import { atomicWriteText, bindAtomicWritePath } from "../fs-write.js";
@@ -140,7 +140,7 @@ registerTool({
140
140
  catch (e) {
141
141
  if (e instanceof BinaryFileError)
142
142
  return `Error: cannot read ${input.path}: file appears binary; use an image/media-specific tool or inspect it with \`file\`.`;
143
- const near = nearestPaths(ctx.cwd, input.path);
143
+ const near = await nearestPathsAsync(ctx.cwd, input.path, 3, { timeoutMs: 1_000, signal: ctx.signal });
144
144
  return `Error: cannot read ${input.path}: ${e.message ?? e.code}.` + (near.length ? ` Did you mean: ${near.join(", ")}?` : "");
145
145
  }
146
146
  },
@@ -185,6 +185,7 @@ registerTool({
185
185
  expected: prev,
186
186
  expectedIdentity: prevSnapshot ?? undefined,
187
187
  boundary,
188
+ signal: ctx.signal,
188
189
  });
189
190
  }
190
191
  catch (error) {
@@ -209,6 +210,7 @@ registerTool({
209
210
  required: ["command"],
210
211
  },
211
212
  kind: "exec",
213
+ requiresProjectWorkspace: true,
212
214
  async run(input, ctx) {
213
215
  const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
214
216
  if (protectedReason) {
@@ -259,6 +261,7 @@ registerTool({
259
261
  timeout,
260
262
  maxBuffer: 10 * 1024 * 1024,
261
263
  onData: live,
264
+ signal: ctx.signal,
262
265
  });
263
266
  flushLive();
264
267
  const combined = (stdout || "") + (stderr ? `\n[stderr]\n${stderr}` : "");