@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
package/dist/index.js CHANGED
@@ -9,7 +9,6 @@ import { setTheme } from "./tui/theme.js";
9
9
  import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
10
10
  import { nextMode as cycleMode } from "./tui/InputBox.js";
11
11
  import { stdin, stdout } from "node:process";
12
- import { execFileSync } from "node:child_process";
13
12
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
14
13
  import { homedir, tmpdir } from "node:os";
15
14
  import { fileURLToPath } from "node:url";
@@ -17,6 +16,7 @@ import { randomUUID } from "node:crypto";
17
16
  import { dirname, join, relative, resolve } from "node:path";
18
17
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
19
18
  import { runAgent } from "./agent/loop.js";
19
+ import { formatAgentDuration, parseAgentRunTimeoutMs, MIN_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_MAX_ROUNDS, } from "./agent/limits.js";
20
20
  import { parseSchemaArg, structuredOutputTool, STRUCTURED_INSTRUCTION, STRUCTURED_NUDGE } from "./agent/structured.js";
21
21
  import { notifyDone } from "./notify.js";
22
22
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
@@ -37,8 +37,8 @@ import { mapLimit, maxParallel } from "./concurrency.js";
37
37
  import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence, commitMessageInput, protectedStagedPaths, protectedTrackedWorkingTreePaths, protectedWorkingTreePaths, } from "./org/review-chain.js";
38
38
  import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
39
39
  import { parseDeliver } from "./cron/deliver.js";
40
- import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
41
- import { runTick, runJobOnce, selfArgv } from "./cron/runner.js";
40
+ import { addJob, removeJob, setEnabled, resolveJob, loadJobs, logPath } from "./cron/store.js";
41
+ import { runTick, runJobTracked, runSelfAttached, selfArgv } from "./cron/runner.js";
42
42
  import { installScheduler, uninstallScheduler, isInstalled } from "./cron/install.js";
43
43
  import { getTools } from "./tools/registry.js";
44
44
  import { resetReachability } from "./tools/net-reachability.js";
@@ -47,6 +47,7 @@ import { EXPLORE_SYSTEM } from "./tools/agent.js";
47
47
  import { createAnthropicProvider } from "./providers/anthropic.js";
48
48
  import { createOpenAIProvider } from "./providers/openai.js";
49
49
  import { resolvePlatform } from "./providers/registry.js";
50
+ import { boundedProviderTurn } from "./providers/bounded-turn.js";
50
51
  import { levelsFor } from "./tui/model-picker.js";
51
52
  import { listModels } from "./providers/models.js";
52
53
  import { listJobs, tailJob, killJob } from "./exec/jobs.js";
@@ -67,11 +68,12 @@ function renderBgJobs() {
67
68
  return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
68
69
  }
69
70
  import { qwenDeviceLogin, getValidQwenAuth } from "./providers/qwen-oauth.js";
70
- import { loadAgentsMd, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
71
+ import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
72
+ import { homeWorkspaceActionError, isHomeWorkspace } from "./context/workspace-scope.js";
71
73
  import { getEmbedder } from "./search/embed.js";
72
- import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
74
+ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, indexExists } from "./search/semindex.js";
73
75
  import { searchHybrid } from "./search/hybrid.js";
74
- import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
76
+ import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
75
77
  import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
76
78
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
77
79
  import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
@@ -299,24 +301,18 @@ async function pingProvider(args) {
299
301
  const prov = provider === "anthropic"
300
302
  ? createAnthropicProvider({ apiKey, model, baseURL })
301
303
  : createOpenAIProvider({ apiKey, model, baseURL, label: provider });
302
- const ctrl = new AbortController();
303
- const timer = setTimeout(() => ctrl.abort(), 12_000);
304
304
  try {
305
- const r = await prov.turn({
305
+ const r = await boundedProviderTurn(prov, {
306
306
  system: "Reply with the single word: ok",
307
307
  history: [{ role: "user", content: "ping" }],
308
308
  tools: [],
309
309
  onText: () => { },
310
- signal: ctrl.signal,
311
- });
310
+ }, { timeoutMs: 12_000, label: "provider connectivity check" });
312
311
  return r.stop !== "error";
313
312
  }
314
313
  catch {
315
314
  return false;
316
315
  }
317
- finally {
318
- clearTimeout(timer);
319
- }
320
316
  }
321
317
  /** Interactive first-run setup: pick a provider (numbered menu), API key (masked), and model →
322
318
  * ~/.hara/config.json. GLM/DeepSeek/OpenRouter and "custom" route through the OpenAI-compatible
@@ -390,23 +386,32 @@ async function runSetup() {
390
386
  rl.close();
391
387
  }
392
388
  }
393
- async function runInit(provider, cwd, sandbox = "off") {
389
+ function agentRunLimits(cfg) {
390
+ return { timeoutMs: cfg.runTimeoutMs, maxRounds: cfg.maxAgentRounds };
391
+ }
392
+ async function runInit(provider, cwd, sandbox = "off", cfg) {
393
+ if (isHomeWorkspace(cwd))
394
+ throw new Error(homeWorkspaceActionError("initialize AGENTS.md"));
394
395
  const history = [{ role: "user", content: INIT_PROMPT }];
395
- await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true });
396
+ await runAgent(history, { provider, ctx: { cwd, sandbox }, approval: "full-auto", confirm: async () => true, ...(cfg ? agentRunLimits(cfg) : {}) });
396
397
  }
397
398
  /** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
398
399
  * Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
399
- async function autoCommit(provider, cwd) {
400
+ async function autoCommit(provider, cwd, signal) {
401
+ if (signal?.aborted)
402
+ return "error: commit interrupted before staging";
400
403
  const before = protectedWorkingTreePaths(cwd);
401
404
  if (before.length)
402
405
  return `error: refusing to stage protected path(s): ${before.map((p) => JSON.stringify(p)).join(", ")}`;
403
406
  try {
404
- await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
407
+ await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000, signal });
405
408
  }
406
409
  catch {
407
410
  /* fall through — empty diff is reported below */
408
411
  }
409
412
  const protectedAfterStage = protectedStagedPaths(cwd);
413
+ if (signal?.aborted)
414
+ return "error: commit interrupted after staging; nothing was committed";
410
415
  if (protectedAfterStage.length) {
411
416
  return `error: refusing to inspect or commit protected staged path(s): ${protectedAfterStage.map((p) => JSON.stringify(p)).join(", ")}`;
412
417
  }
@@ -419,12 +424,16 @@ async function autoCommit(provider, cwd) {
419
424
  const changeInput = commitMessageInput(staged);
420
425
  if (!changeInput.trim())
421
426
  return "nothing to commit";
422
- const r = await provider.turn({
427
+ const r = await boundedProviderTurn(provider, {
423
428
  system: COMMIT_SYSTEM,
424
429
  history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
425
430
  tools: [],
426
431
  onText: () => { },
427
- });
432
+ }, { timeoutMs: 30_000, label: "commit message generation", signal });
433
+ if (signal?.aborted)
434
+ return "error: commit interrupted; nothing was committed";
435
+ if (r.stop === "error")
436
+ return `error: commit message generation failed (${r.errorMsg ?? "provider error"})`;
428
437
  const msg = stripCommitFence(r.text);
429
438
  if (!msg)
430
439
  return "error: no commit message produced";
@@ -432,10 +441,12 @@ async function autoCommit(provider, cwd) {
432
441
  writeFileSync(tmp, msg + "\n", "utf8");
433
442
  try {
434
443
  const protectedBeforeCommit = protectedStagedPaths(cwd);
444
+ if (signal?.aborted)
445
+ return "error: commit interrupted; nothing was committed";
435
446
  if (protectedBeforeCommit.length) {
436
447
  return `error: staged paths changed while writing the message; protected path(s) will not be committed: ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}`;
437
448
  }
438
- const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
449
+ const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000, signal });
439
450
  return (res.stdout || "").trim().split("\n")[0] || "committed";
440
451
  }
441
452
  catch (e) {
@@ -451,8 +462,10 @@ async function autoCommit(provider, cwd) {
451
462
  }
452
463
  }
453
464
  /** Format an autoCommit result + emit it. */
454
- async function commitStep(provider, cwd) {
455
- const r = await autoCommit(provider, cwd);
465
+ async function commitStep(provider, cwd, signal) {
466
+ if (signal?.aborted)
467
+ return;
468
+ const r = await autoCommit(provider, cwd, signal);
456
469
  if (r.startsWith("error:"))
457
470
  out(c.red(`✗ ${r}\n`));
458
471
  else if (r === "nothing to commit")
@@ -492,12 +505,14 @@ async function runOrg(task, o) {
492
505
  role = kw.role;
493
506
  }
494
507
  else {
495
- const r = await o.baseProvider.turn({
508
+ const r = await boundedProviderTurn(o.baseProvider, {
496
509
  system: "You are a task dispatcher. Reply with only a role id.",
497
510
  history: [{ role: "user", content: buildDispatchPrompt(task, roles) }],
498
511
  tools: [],
499
512
  onText: () => { },
500
- });
513
+ }, { timeoutMs: 20_000, label: "role dispatch" });
514
+ if (r.stop === "error")
515
+ out(c.yellow(`(role dispatch unavailable — using ${roles[0].id})\n`));
501
516
  role = parseRoleId(r.text, roles) ?? roles[0];
502
517
  }
503
518
  }
@@ -508,7 +523,7 @@ async function runOrg(task, o) {
508
523
  ? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
509
524
  : o.baseProvider;
510
525
  const toolFilter = roleToolFilter(role);
511
- const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
526
+ const history = [{ role: "user", content: await expandMentionsAsync(task, o.cwd) }];
512
527
  const runImplementer = async () => {
513
528
  return runAgent(history, {
514
529
  provider: roleProvider,
@@ -520,6 +535,7 @@ async function runOrg(task, o) {
520
535
  stats: o.stats,
521
536
  systemOverride: role.system,
522
537
  toolFilter,
538
+ ...agentRunLimits(o.cfg),
523
539
  ...(role.readOnly ? { hooks: false } : {}),
524
540
  });
525
541
  };
@@ -571,6 +587,7 @@ async function runOrg(task, o) {
571
587
  systemOverride: revSystem,
572
588
  toolFilter: revTools,
573
589
  hooks: false,
590
+ ...agentRunLimits(o.cfg),
574
591
  });
575
592
  if (reviewerOutcome.status !== "completed")
576
593
  return reviewerOutcome;
@@ -623,6 +640,7 @@ async function executeAtom(atom, plan, done, roles, o) {
623
640
  toolFilter,
624
641
  ...(role?.readOnly ? { hooks: false } : {}),
625
642
  quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
643
+ ...agentRunLimits(o.cfg),
626
644
  });
627
645
  const failure = runFailureDetail(outcome);
628
646
  if (failure) {
@@ -798,7 +816,7 @@ const SESSION_NAME_SYSTEM = "Name this coding session as a SHORT slug: 2–4 Eng
798
816
  "gist to English (use pinyin only if a term is untranslatable). Output ONLY the slug.";
799
817
  /** One short model call → a 2–4 word English kebab-case session name summarizing the work.
800
818
  * Always ASCII (translates non-English gist). Falls back to the lexical title on any failure. */
801
- async function nameSession(provider, history) {
819
+ async function nameSession(provider, history, signal) {
802
820
  const text = (m) => {
803
821
  if (!m)
804
822
  return "";
@@ -810,12 +828,17 @@ async function nameSession(provider, history) {
810
828
  };
811
829
  const basis = `User: ${text(history.find((m) => m.role === "user")).slice(0, 800)}\n` +
812
830
  `Assistant: ${text(history.find((m) => m.role === "assistant")).slice(0, 800)}`;
831
+ const fallback = titleFrom(history);
832
+ if (signal?.aborted)
833
+ return fallback;
813
834
  try {
814
- const r = await provider.turn({ system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } });
815
- return slugify(r.text) || titleFrom(history);
835
+ const r = await boundedProviderTurn(provider, { system: SESSION_NAME_SYSTEM, history: [{ role: "user", content: basis }], tools: [], onText: () => { } }, { timeoutMs: 10_000, label: "session naming", signal });
836
+ if (signal?.aborted || r.stop === "error")
837
+ return fallback;
838
+ return slugify(r.text) || fallback;
816
839
  }
817
840
  catch {
818
- return titleFrom(history);
841
+ return fallback;
819
842
  }
820
843
  }
821
844
  /** Render a proposed plan as a bordered block for the transcript (codex ProposedPlanCell-style).
@@ -847,26 +870,26 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
847
870
  // pin their structure and serve's session.compact shares the exact same distillation.
848
871
  /** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
849
872
  * /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
850
- async function compactConversation(provider, history, meta, stats) {
851
- if (history.length < 2)
873
+ async function compactConversation(provider, history, meta, stats, signal) {
874
+ if (history.length < 2 || signal?.aborted)
852
875
  return null;
853
- const r = await provider.turn({
876
+ const r = await boundedProviderTurn(provider, {
854
877
  system: COMPACT_SYSTEM,
855
878
  history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
856
879
  tools: [],
857
880
  onText: () => { },
858
- });
859
- if (r.stop === "error")
881
+ }, { timeoutMs: 60_000, label: "conversation compaction", signal });
882
+ if (signal?.aborted || r.stop === "error")
860
883
  return null;
861
884
  const summary = r.text.trim();
862
885
  if (!summary)
863
886
  return null;
864
- meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects into the next turns
865
- history.length = 0;
866
- history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
887
+ const workingSet = workingSetFromSummary(summary);
867
888
  // TW5-style file restore: the summary alone loses the working set's ACTUAL content — re-attach the
868
889
  // most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
869
890
  const restore = buildFileRestore(recentTouched(5), (p) => {
891
+ if (signal?.aborted)
892
+ return null;
870
893
  try {
871
894
  return readModelContextFileSync(p, 32 * 1024);
872
895
  }
@@ -874,6 +897,12 @@ async function compactConversation(provider, history, meta, stats) {
874
897
  return null;
875
898
  }
876
899
  });
900
+ // Cancellation during the file snapshot must leave the original conversation untouched.
901
+ if (signal?.aborted)
902
+ return null;
903
+ meta.workingSet = workingSet; // survives the history wipe + injects into the next turns
904
+ history.length = 0;
905
+ history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
877
906
  if (restore)
878
907
  history.push({ role: "user", content: restore });
879
908
  stats.input += r.usage?.input ?? 0;
@@ -885,7 +914,9 @@ async function compactConversation(provider, history, meta, stats) {
885
914
  /** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
886
915
  * doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
887
916
  * a one-line status. Returns true if it compacted. */
888
- async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
917
+ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal) {
918
+ if (signal?.aborted)
919
+ return false;
889
920
  const lastInput = stats.lastInput ?? 0;
890
921
  const pct = bar.ctxPctFor(cfg.model, lastInput);
891
922
  // Two triggers, whichever hits first: % of window (small-window models) OR an absolute token cap
@@ -895,13 +926,17 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
895
926
  const overCap = shouldAutoCompactTokens(lastInput, history.length, cfg.autoCompact, cap);
896
927
  if (!overPct && !overCap)
897
928
  return false;
929
+ if (signal?.aborted)
930
+ return false;
898
931
  notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
899
- const summary = await compactConversation(provider, history, meta, stats);
932
+ const summary = await compactConversation(provider, history, meta, stats, signal);
933
+ if (signal?.aborted)
934
+ return false;
900
935
  notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
901
936
  return !!summary;
902
937
  }
903
938
  /** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
904
- async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
939
+ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId, signal) {
905
940
  const roles = loadRoles(cwd);
906
941
  const roleRef = roleId?.trim();
907
942
  if (roleId !== undefined && !roleRef)
@@ -940,8 +975,9 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
940
975
  const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
941
976
  const subHistory = [{ role: "user", content: task }];
942
977
  const todoScope = `subagent:${randomUUID()}`;
978
+ let outcome;
943
979
  try {
944
- await runAgent(subHistory, {
980
+ outcome = await runAgent(subHistory, {
945
981
  provider,
946
982
  ctx: { cwd, sandbox, todoScope }, // isolated checklist; no spawn → no recursion
947
983
  approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
@@ -953,6 +989,9 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
953
989
  toolFilter,
954
990
  hooks: false,
955
991
  quiet: true,
992
+ signal,
993
+ timeoutMs: Math.min(cfg.runTimeoutMs, 8 * 60_000),
994
+ maxRounds: Math.min(cfg.maxAgentRounds, 24),
956
995
  });
957
996
  }
958
997
  finally {
@@ -961,6 +1000,15 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
961
1000
  resetRepeatGuard(todoScope);
962
1001
  clearTouched(todoScope);
963
1002
  }
1003
+ if (outcome.status !== "completed") {
1004
+ const reason = outcome.error?.trim()
1005
+ || (outcome.status === "empty"
1006
+ ? "the model returned an empty response"
1007
+ : outcome.stopReason
1008
+ ? `the run stopped (${outcome.stopReason})`
1009
+ : `the run ended with status ${outcome.status}`);
1010
+ return `Error: sub-agent ${reason}`;
1011
+ }
964
1012
  for (let i = subHistory.length - 1; i >= 0; i--) {
965
1013
  const m = subHistory[i];
966
1014
  if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
@@ -996,8 +1044,15 @@ function runDoctor(cfg) {
996
1044
  `${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
997
1045
  `${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
998
1046
  `${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
1047
+ `${dot} run-limits ${c.bold(formatAgentDuration(cfg.runTimeoutMs))}${c.dim(" total · ")}${c.bold(String(cfg.maxAgentRounds))}${c.dim(" rounds · sub-agents ≤8m/24")}`,
999
1048
  `${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
1000
- `${dot} cron ${(() => { const n = loadJobs().length; return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add"); })()}`,
1049
+ `${dot} cron ${(() => { try {
1050
+ const n = loadJobs().length;
1051
+ return n ? `${n} job(s) · ${isInstalled() ? c.green("scheduler installed") : c.yellow("scheduler off — hara cron install")}` : c.dim("no jobs — hara cron add");
1052
+ }
1053
+ catch {
1054
+ return c.red("job store invalid — run hara cron list");
1055
+ } })()}`,
1001
1056
  `${dot} input ${cfg.vimMode ? c.bold("vim") + c.dim(" (modal)") : c.dim("default — hara config set vimMode true for vim keys")}`,
1002
1057
  ];
1003
1058
  return lines.join("\n");
@@ -1049,13 +1104,18 @@ program
1049
1104
  .description("analyze the project and (re)generate AGENTS.md")
1050
1105
  .action(async () => {
1051
1106
  const cfg = loadConfig();
1107
+ if (isHomeWorkspace(cfg.cwd)) {
1108
+ out(c.red(homeWorkspaceActionError("initialize AGENTS.md")) + "\n");
1109
+ process.exitCode = 2;
1110
+ return;
1111
+ }
1052
1112
  const provider = await buildProvider(cfg);
1053
1113
  if (!provider) {
1054
1114
  out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1055
1115
  process.exit(1);
1056
1116
  }
1057
1117
  out(c.dim("Analyzing project to generate AGENTS.md…\n"));
1058
- await runInit(provider, cfg.cwd, cfg.sandbox);
1118
+ await runInit(provider, cfg.cwd, cfg.sandbox, cfg);
1059
1119
  });
1060
1120
  program
1061
1121
  .command("sessions")
@@ -1074,7 +1134,7 @@ program
1074
1134
  program
1075
1135
  .command("resume [id]")
1076
1136
  .description("resume a session — no id resumes the most recent here (list ids with `hara sessions`)")
1077
- .action((id) => {
1137
+ .action(async (id) => {
1078
1138
  let full;
1079
1139
  if (id) {
1080
1140
  full = resolveSessionId(id) ?? undefined;
@@ -1092,8 +1152,22 @@ program
1092
1152
  full = latest.meta.id;
1093
1153
  }
1094
1154
  out(c.dim(`↩ resuming ${shortId(full)}…\n`));
1095
- // reuse the existing --resume path exactly (one engine), inheriting this terminal
1096
- execFileSync(process.execPath, [process.argv[1], "--resume", full], { stdio: "inherit" });
1155
+ // Reuse the existing --resume path exactly (one engine), inheriting this terminal. selfArgv() inside
1156
+ // runSelfAttached distinguishes node+entry from a Bun-compiled binary (whose argv[1] is a user arg).
1157
+ try {
1158
+ const result = await runSelfAttached(["--resume", full]);
1159
+ if (result.signal) {
1160
+ out(c.yellow(`Resumed Hara process stopped by ${result.signal}.\n`));
1161
+ process.exitCode = 1;
1162
+ }
1163
+ else if (result.code) {
1164
+ process.exitCode = result.code;
1165
+ }
1166
+ }
1167
+ catch (error) {
1168
+ out(c.red(`Could not start the resumed Hara process: ${error instanceof Error ? error.message : String(error)}\n`));
1169
+ process.exitCode = 1;
1170
+ }
1097
1171
  });
1098
1172
  program
1099
1173
  .command("org <task...>")
@@ -1149,7 +1223,7 @@ program
1149
1223
  sandbox: cfg.sandbox,
1150
1224
  approval: "full-auto",
1151
1225
  confirm: async () => true,
1152
- projectContext: loadAgentsMd(orgCwd) || undefined,
1226
+ projectContext: loadAgentContext(orgCwd) || undefined,
1153
1227
  stats,
1154
1228
  forceRole,
1155
1229
  review: opts2.review,
@@ -1213,7 +1287,7 @@ program
1213
1287
  sandbox: cfg.sandbox,
1214
1288
  approval: "full-auto",
1215
1289
  confirm: async () => true,
1216
- projectContext: loadAgentsMd(cfg.cwd) || undefined,
1290
+ projectContext: loadAgentContext(cfg.cwd) || undefined,
1217
1291
  stats,
1218
1292
  parallel: opts.parallel,
1219
1293
  };
@@ -1262,6 +1336,18 @@ program
1262
1336
  .option("--all", "index everything")
1263
1337
  .action(async (opts) => {
1264
1338
  const cfg = loadConfig();
1339
+ const cwd = process.cwd();
1340
+ let doRepo = !!(opts.all || opts.repo || (!opts.assets && !opts.all));
1341
+ const doAssets = !!(opts.all || opts.assets);
1342
+ if (doRepo && isHomeWorkspace(cwd)) {
1343
+ if (!doAssets) {
1344
+ out(c.red(homeWorkspaceActionError("build a repository index")) + "\n");
1345
+ process.exitCode = 2;
1346
+ return;
1347
+ }
1348
+ out(c.yellow(homeWorkspaceActionError("build the repository part of --all")) + c.dim(" Global assets/memory will still be indexed.\n"));
1349
+ doRepo = false;
1350
+ }
1265
1351
  const embed = getEmbedder(cfg);
1266
1352
  if (!embed) {
1267
1353
  out(c.yellow("Semantic search is off — search stays lexical (which still works).\n"));
@@ -1270,10 +1356,7 @@ program
1270
1356
  out(c.dim(" hara config set embedProvider qwen # DashScope text-embedding-v3 (uses your key)\n"));
1271
1357
  return;
1272
1358
  }
1273
- const cwd = process.cwd();
1274
1359
  const model = `${cfg.embedProvider}:${cfg.embedModel ?? "default"}`;
1275
- const doRepo = opts.all || opts.repo || (!opts.assets && !opts.all);
1276
- const doAssets = opts.all || opts.assets;
1277
1360
  const build = async (name, chunks, blurb) => {
1278
1361
  if (!chunks.length)
1279
1362
  return void out(c.dim(`Nothing to index for ${name}.\n`));
@@ -1288,11 +1371,38 @@ program
1288
1371
  out(c.dim("Check the embedding endpoint/key; search still works lexically.\n"));
1289
1372
  }
1290
1373
  };
1291
- if (doRepo)
1292
- await build("repo", collectRepoChunks(findProjectRoot(cwd)), "codebase_search");
1374
+ const collectionOptions = {
1375
+ maxFiles: 50_000,
1376
+ maxDirectories: 100_000,
1377
+ maxEntries: 500_000,
1378
+ timeoutMs: 60_000,
1379
+ yieldEvery: 128,
1380
+ };
1381
+ const noteTruncation = (name, collection) => {
1382
+ if (collection.truncated) {
1383
+ out(c.yellow(`Index collection for ${name} stopped at its ${collection.reason?.replace("_", " ") ?? "safety limit"}; the partial index is not being published.\n`));
1384
+ }
1385
+ };
1386
+ if (doRepo) {
1387
+ const repo = await collectRepoChunksAsync(findProjectRoot(cwd), collectionOptions);
1388
+ noteTruncation("repo", repo);
1389
+ if (!repo.truncated)
1390
+ await build("repo", repo.chunks, "codebase_search");
1391
+ }
1293
1392
  if (doAssets) {
1294
- await build("assets", [...collectDirChunks(assetsDir(), "code-assets"), ...collectDirChunks(globalSkillsDir(), "skills")], "recall");
1295
- await build("memory", collectDirChunks(memoryDir("global", cwd), "memory"), "memory_search");
1393
+ const [assets, skills, memory] = await Promise.all([
1394
+ collectDirChunksAsync(assetsDir(), "code-assets", collectionOptions),
1395
+ collectDirChunksAsync(globalSkillsDir(), "skills", collectionOptions),
1396
+ collectDirChunksAsync(memoryDir("global", cwd), "memory", collectionOptions),
1397
+ ]);
1398
+ noteTruncation("assets", assets);
1399
+ noteTruncation("skills", skills);
1400
+ noteTruncation("memory", memory);
1401
+ if (!assets.truncated && !skills.truncated) {
1402
+ await build("assets", [...assets.chunks, ...skills.chunks], "recall");
1403
+ }
1404
+ if (!memory.truncated)
1405
+ await build("memory", memory.chunks, "memory_search");
1296
1406
  }
1297
1407
  });
1298
1408
  program
@@ -1734,12 +1844,36 @@ program
1734
1844
  });
1735
1845
  program
1736
1846
  .command("gateway")
1737
- .description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
1847
+ .description("run a supported chat gateway so you can drive your local hara from your phone — opt-in daemon")
1738
1848
  .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
1739
1849
  .option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
1850
+ .option("--recover-outcome <message-id>", "recover exactly one interrupted/terminal run marker by its original platform message id")
1851
+ .option("--confirm-recovery <action:message-id>", "required exact confirmation: terminalize:<id> or delete-terminal:<id>")
1740
1852
  .option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
1741
1853
  .action(async (opts) => {
1742
1854
  const mod = await import("./gateway/serve.js");
1855
+ if (opts.recoverOutcome !== undefined || opts.confirmRecovery !== undefined) {
1856
+ if (typeof opts.recoverOutcome !== "string" || typeof opts.confirmRecovery !== "string") {
1857
+ throw new Error("gateway outcome recovery requires both --recover-outcome <message-id> and --confirm-recovery <action:message-id>");
1858
+ }
1859
+ const result = await mod.recoverGatewayRunOutcome({
1860
+ platform: opts.platform,
1861
+ messageId: opts.recoverOutcome,
1862
+ confirmation: opts.confirmRecovery,
1863
+ });
1864
+ if (result === "missing")
1865
+ out(c.yellow("No outcome marker exists for that message id and gateway credential.\n"));
1866
+ else if (result === "terminalized") {
1867
+ out(c.green("✓ interrupted marker converted to terminal; one active slot is free and coding remains blocked from rerun.\n"));
1868
+ }
1869
+ else if (result === "already-terminal") {
1870
+ out(c.yellow("Marker is already terminal. Delete it only after platform redelivery is impossible, using delete-terminal:<same-message-id>.\n"));
1871
+ }
1872
+ else {
1873
+ out(c.yellow("✓ terminal marker removed. This message id is no longer protected if the platform redelivers it later.\n"));
1874
+ }
1875
+ return;
1876
+ }
1743
1877
  if (opts.platform === "weixin" && opts.login) {
1744
1878
  await mod.weixinLogin();
1745
1879
  return;
@@ -1795,9 +1929,10 @@ program
1795
1929
  effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
1796
1930
  };
1797
1931
  },
1798
- spawnSubagent: (provider, scwd, projectContext, stats, task, role) => {
1932
+ runLimits: (targetCwd) => agentRunLimits(loadConfig({ cwd: targetCwd ?? cwd })),
1933
+ spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal) => {
1799
1934
  const live = loadConfig({ cwd: scwd });
1800
- return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role);
1935
+ return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal);
1801
1936
  },
1802
1937
  guardian: guardianOpt,
1803
1938
  buildGuardian: async (targetCwd) => {
@@ -1953,9 +2088,10 @@ program
1953
2088
  systemOverride: REVIEW_SYSTEM,
1954
2089
  toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
1955
2090
  hooks: false,
1956
- projectContext: loadAgentsMd(cfg.cwd) || undefined,
2091
+ projectContext: loadAgentContext(cfg.cwd) || undefined,
1957
2092
  memory: memoryDigest(cfg.cwd),
1958
2093
  stats,
2094
+ ...agentRunLimits(cfg),
1959
2095
  });
1960
2096
  if (stats.input || stats.output)
1961
2097
  out("\n" + statusLine(cfg.model, stats.input, stats.output) + "\n");
@@ -1998,12 +2134,12 @@ program
1998
2134
  if (!changeInput.trim())
1999
2135
  return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
2000
2136
  out(c.dim("Writing a commit message…\n"));
2001
- const r = await provider.turn({
2137
+ const r = await boundedProviderTurn(provider, {
2002
2138
  system: COMMIT_SYSTEM,
2003
2139
  history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
2004
2140
  tools: [],
2005
2141
  onText: () => { },
2006
- });
2142
+ }, { timeoutMs: 30_000, label: "commit message generation" });
2007
2143
  if (r.stop === "error")
2008
2144
  return void out(c.red(`message generation failed: ${r.errorMsg ?? "provider error"}\n`));
2009
2145
  const msg = r.text.trim().replace(/^```[a-z]*\n?/i, "").replace(/\n?```$/i, "").trim();
@@ -2040,15 +2176,31 @@ program
2040
2176
  }
2041
2177
  });
2042
2178
  function renderCronJobs() {
2043
- const jobs = loadJobs();
2044
2179
  const head = isInstalled() ? c.green("scheduler: installed") : c.yellow("scheduler: NOT installed — run `hara cron install`");
2180
+ let jobs;
2181
+ try {
2182
+ jobs = loadJobs();
2183
+ }
2184
+ catch (error) {
2185
+ return head + "\n" + c.red(`${error instanceof Error ? error.message : String(error)}\n`);
2186
+ }
2045
2187
  if (!jobs.length)
2046
2188
  return head + "\n" + c.dim('No jobs. Add one: hara cron add "every 1h" "<task>"\n');
2047
2189
  const now = Date.now();
2048
2190
  const lines = jobs.map((j) => {
2049
2191
  const nxt = nextRun(j, now);
2050
- const status = j.lastStatus ? (j.lastStatus === "ok" ? c.green("ok") : c.red("err")) : c.dim("—");
2051
- return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nxt ? new Date(nxt).toLocaleString() : ""} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
2192
+ const nextLabel = nxt !== null && nxt <= now ? "due now" : nxt !== null ? new Date(nxt).toLocaleString() : "—";
2193
+ const elapsed = j.lastDurationMs === undefined ? "" : ` after ${formatAgentDuration(j.lastDurationMs)}`;
2194
+ const status = j.lastStatus === "running"
2195
+ ? c.yellow(`running since ${new Date(j.runningSince ?? j.lastRunAt ?? now).toLocaleString()}`)
2196
+ : j.lastStatus === "timed_out"
2197
+ ? c.red(`timed out${elapsed}`)
2198
+ : j.lastStatus === "ok"
2199
+ ? c.green(`ok${elapsed}`)
2200
+ : j.lastStatus === "error"
2201
+ ? c.red(`err${elapsed}`)
2202
+ : c.dim("—");
2203
+ return `${c.bold(j.id)} ${describeSchedule(j.schedule)} ${c.dim(`· ${j.mode} · next ${nextLabel} · last ${status}`)}${j.enabled ? "" : c.dim(" [disabled]")}\n ${c.dim(j.name)}`;
2052
2204
  });
2053
2205
  return head + "\n" + lines.join("\n") + "\n";
2054
2206
  }
@@ -2061,6 +2213,7 @@ cronCmd
2061
2213
  .option("--command", "run the task as a plain SHELL COMMAND — deterministic, no agent, no tokens")
2062
2214
  .option("--tz <zone>", 'IANA timezone for cron exprs (e.g. "Asia/Shanghai"); default = local time')
2063
2215
  .option("--deliver <spec>", "push each run's result: telegram:<chatId> | feishu:<chatId> | webhook:<url>")
2216
+ .option("--alert-after <n>", "send a 🚨 after N consecutive failures (1..1000; default 3)")
2064
2217
  .action((schedule, taskParts, opts) => {
2065
2218
  const task = taskParts.join(" ");
2066
2219
  if (opts.org && opts.command)
@@ -2077,30 +2230,73 @@ cronCmd
2077
2230
  if ("error" in d)
2078
2231
  return void out(c.red(d.error + "\n"));
2079
2232
  }
2233
+ const alertAfter = opts.alertAfter === undefined ? undefined : Number(opts.alertAfter);
2234
+ if (alertAfter !== undefined && (!Number.isInteger(alertAfter) || alertAfter < 1 || alertAfter > 1_000)) {
2235
+ return void out(c.red("--alert-after must be an integer from 1 to 1000\n"));
2236
+ }
2080
2237
  const mode = opts.command ? "command" : opts.org ? "org" : "print";
2081
- const job = addJob({
2082
- name: opts.name || task.slice(0, 48),
2083
- schedule: sched,
2084
- task,
2085
- mode,
2086
- cwd: process.cwd(),
2087
- ...(opts.tz ? { tz: opts.tz } : {}),
2088
- ...(opts.deliver ? { deliver: opts.deliver } : {}),
2089
- createdAt: Date.now(),
2090
- });
2091
- out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver}` : ""} · cwd ${job.cwd}\n`));
2238
+ let job;
2239
+ try {
2240
+ job = addJob({
2241
+ name: opts.name || task.slice(0, 48),
2242
+ schedule: sched,
2243
+ task,
2244
+ mode,
2245
+ cwd: process.cwd(),
2246
+ ...(opts.tz ? { tz: opts.tz } : {}),
2247
+ ...(opts.deliver ? { deliver: opts.deliver } : {}),
2248
+ ...(alertAfter !== undefined ? { alertAfter } : {}),
2249
+ createdAt: Date.now(),
2250
+ });
2251
+ }
2252
+ catch (error) {
2253
+ return void out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
2254
+ }
2255
+ out(c.green(`✓ scheduled ${job.id}`) + c.dim(` · ${describeSchedule(sched)}${opts.tz ? ` @ ${opts.tz}` : ""} · ${job.mode}${opts.deliver ? ` · → ${opts.deliver}` : ""}${alertAfter !== undefined ? ` · alert ≥${alertAfter}` : ""} · cwd ${job.cwd}\n`));
2092
2256
  if (!isInstalled())
2093
2257
  out(c.yellow("⚠ scheduler not installed yet — run `hara cron install` so jobs actually fire.\n"));
2094
2258
  });
2095
2259
  // Resolve an id/prefix to one job, printing a clear error for none / ambiguous (never act on a guess).
2096
2260
  const cronResolve = (id) => {
2097
- const r = resolveJob(id);
2261
+ let r;
2262
+ try {
2263
+ r = resolveJob(id);
2264
+ }
2265
+ catch (error) {
2266
+ out(c.red(`${error instanceof Error ? error.message : String(error)}\n`));
2267
+ return null;
2268
+ }
2098
2269
  if (r === "ambiguous")
2099
2270
  return void out(c.red(`ambiguous id "${id}" — matches multiple jobs; type more characters\n`)), null;
2100
2271
  if (!r)
2101
2272
  return void out(c.red(`no such job: ${id}\n`)), null;
2102
2273
  return r;
2103
2274
  };
2275
+ /** Commander actions otherwise inherit Node's default SIGTERM/SIGINT exit, which kills only the tick parent
2276
+ * and can orphan its detached job process group. Convert the signal into cooperative cancellation, await
2277
+ * tree cleanup + lock release, then retain the conventional shell exit status. */
2278
+ const withCronCliSignal = async (run) => {
2279
+ const controller = new AbortController();
2280
+ let interrupted;
2281
+ const stop = (signal) => {
2282
+ interrupted ??= signal;
2283
+ if (!controller.signal.aborted)
2284
+ controller.abort(new Error(`cron command interrupted by ${signal}`));
2285
+ };
2286
+ const onInt = () => stop("SIGINT");
2287
+ const onTerm = () => stop("SIGTERM");
2288
+ process.once("SIGINT", onInt);
2289
+ process.once("SIGTERM", onTerm);
2290
+ try {
2291
+ return await run(controller.signal);
2292
+ }
2293
+ finally {
2294
+ process.removeListener("SIGINT", onInt);
2295
+ process.removeListener("SIGTERM", onTerm);
2296
+ if (interrupted)
2297
+ process.exitCode = interrupted === "SIGINT" ? 130 : 143;
2298
+ }
2299
+ };
2104
2300
  cronCmd.command("list").alias("ls").description("list scheduled jobs").action(() => out(renderCronJobs()));
2105
2301
  cronCmd
2106
2302
  .command("remove <id>")
@@ -2133,17 +2329,18 @@ cronCmd
2133
2329
  if (!job)
2134
2330
  return;
2135
2331
  out(c.dim(`running ${job.id} (${job.name})…\n`));
2136
- const r = await runJobOnce(job);
2137
- recordRun(job.id, Date.now(), r.ok ? "ok" : "error", r.error);
2332
+ const r = await withCronCliSignal((signal) => runJobTracked(job, { signal }));
2138
2333
  out((r.ok ? c.green("✓ done") : c.red(`✗ ${r.error}`)) + c.dim(` · log: ${logPath(job.id)}\n`));
2139
2334
  });
2140
2335
  cronCmd
2141
2336
  .command("tick")
2142
2337
  .description("run all due jobs now (your OS scheduler calls this every minute)")
2143
2338
  .action(async () => {
2144
- const r = await runTick(Date.now());
2339
+ const r = await withCronCliSignal((signal) => runTick(Date.now(), undefined, { signal }));
2145
2340
  if (r.skipped)
2146
2341
  return void out(c.dim(`(skipped — ${r.skipped})\n`));
2342
+ if (r.stopped)
2343
+ return void out(c.yellow(`(stopped — ${r.stopped}; ran ${r.ran.length} job(s): ${r.ran.join(", ") || "none"})\n`));
2147
2344
  out(c.dim(r.ran.length ? `ran ${r.ran.length} job(s): ${r.ran.join(", ")}\n` : "(no jobs due)\n"));
2148
2345
  });
2149
2346
  cronCmd
@@ -2207,6 +2404,7 @@ memoryCmd
2207
2404
  toolFilter: (n) => n === "memory_write" || READONLY_TOOLS.has(n),
2208
2405
  systemOverride: MEMORY_DISTILL_SYSTEM,
2209
2406
  stats,
2407
+ ...agentRunLimits(cfg),
2210
2408
  });
2211
2409
  if (stats.input || stats.output)
2212
2410
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
@@ -2484,6 +2682,20 @@ config
2484
2682
  out(c.red(`Invalid reasoning effort. One of: ${REASONING_EFFORTS.join(", ")}.\n`));
2485
2683
  process.exit(1);
2486
2684
  }
2685
+ if (key === "runTimeoutMs") {
2686
+ const parsed = parseAgentRunTimeoutMs(value);
2687
+ if (parsed === undefined || parsed < MIN_AGENT_RUN_TIMEOUT_MS || parsed > MAX_AGENT_RUN_TIMEOUT_MS) {
2688
+ out(c.red("Invalid runTimeoutMs. Use 1s..2h, for example `30m`, `90s`, or milliseconds.\n"));
2689
+ process.exit(1);
2690
+ }
2691
+ }
2692
+ if (key === "maxAgentRounds") {
2693
+ const parsed = Number(value);
2694
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > MAX_AGENT_MAX_ROUNDS) {
2695
+ out(c.red(`Invalid maxAgentRounds. Use an integer from 1 to ${MAX_AGENT_MAX_ROUNDS}.\n`));
2696
+ process.exit(1);
2697
+ }
2698
+ }
2487
2699
  writeConfigValue(key, value);
2488
2700
  out(c.green(`Set ${key} → ${configPath()}\n`));
2489
2701
  });
@@ -2502,6 +2714,8 @@ config
2502
2714
  `baseURL: ${raw.baseURL ?? "(provider default)"}\n` +
2503
2715
  `approval: ${raw.approval ?? "(default suggest)"}\n` +
2504
2716
  `sandbox: ${raw.sandbox ?? "(default off)"}\n` +
2717
+ `timeout: ${raw.runTimeoutMs ?? "(default 30m)"}\n` +
2718
+ `rounds: ${raw.maxAgentRounds ?? "(default 64)"}\n` +
2505
2719
  `apiKey: ${maskKey(raw.apiKey)}\n`);
2506
2720
  }
2507
2721
  });
@@ -2539,6 +2753,7 @@ program.action(async (opts) => {
2539
2753
  }
2540
2754
  const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
2541
2755
  const cwd = cfg.cwd;
2756
+ const homeWorkspace = isHomeWorkspace(cwd);
2542
2757
  // Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
2543
2758
  // stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
2544
2759
  // object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
@@ -2670,12 +2885,18 @@ program.action(async (opts) => {
2670
2885
  // one-shot
2671
2886
  if (opts.print) {
2672
2887
  let headlessLockId = null;
2888
+ const pendingHeadlessOperations = new Set();
2889
+ const trackHeadlessOperation = (operation) => {
2890
+ pendingHeadlessOperations.add(operation);
2891
+ const settled = () => { pendingHeadlessOperations.delete(operation); };
2892
+ void operation.then(settled, settled);
2893
+ };
2673
2894
  try {
2674
- const projectContext = loadAgentsMd(cwd) || undefined;
2895
+ const projectContext = loadAgentContext(cwd) || undefined;
2675
2896
  // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
2676
2897
  // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
2677
2898
  // describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
2678
- const describeImage = async (path, hint) => {
2899
+ const describeImage = async (path, hint, signal) => {
2679
2900
  const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2680
2901
  const vp = cfg.visionModel
2681
2902
  ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
@@ -2685,7 +2906,7 @@ program.action(async (opts) => {
2685
2906
  if (!vp)
2686
2907
  return "";
2687
2908
  try {
2688
- return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
2909
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint, signal });
2689
2910
  }
2690
2911
  catch {
2691
2912
  return "";
@@ -2824,7 +3045,7 @@ program.action(async (opts) => {
2824
3045
  // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
2825
3046
  // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
2826
3047
  // visionModel sidecar and folded into the message (text-only models can't take image blocks).
2827
- const userText = expandMentions(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
3048
+ const userText = await expandMentionsAsync(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
2828
3049
  const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
2829
3050
  .split("\n")
2830
3051
  .map((s) => s.trim())
@@ -2880,7 +3101,7 @@ program.action(async (opts) => {
2880
3101
  let structuredSet = false;
2881
3102
  const printRunOpts = {
2882
3103
  provider: headlessProvider,
2883
- ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role), describeImage },
3104
+ ctx: { cwd, sandbox, spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal), describeImage },
2884
3105
  approval: "full-auto",
2885
3106
  confirm: async () => true,
2886
3107
  projectContext,
@@ -2890,6 +3111,9 @@ program.action(async (opts) => {
2890
3111
  hooks: headlessHooks,
2891
3112
  stats,
2892
3113
  guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
3114
+ onProviderTurn: trackHeadlessOperation,
3115
+ onToolRun: trackHeadlessOperation,
3116
+ ...agentRunLimits(cfg),
2893
3117
  ...(schemaObj
2894
3118
  ? {
2895
3119
  extraTools: [structuredOutputTool(schemaObj, (v) => ((structured = v), (structuredSet = true)))],
@@ -2928,7 +3152,9 @@ program.action(async (opts) => {
2928
3152
  if (meta) {
2929
3153
  // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
2930
3154
  // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
2931
- await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
3155
+ if (runOutcome.status === "completed") {
3156
+ await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
3157
+ }
2932
3158
  saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
2933
3159
  }
2934
3160
  if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
@@ -2937,14 +3163,28 @@ program.action(async (opts) => {
2937
3163
  return;
2938
3164
  }
2939
3165
  finally {
2940
- if (headlessLockId)
2941
- releaseSessionLock(headlessLockId);
3166
+ if (headlessLockId) {
3167
+ const lockId = headlessLockId;
3168
+ if (pendingHeadlessOperations.size) {
3169
+ // Do not await here: the logical deadline stays hard. A late operation with active handles keeps
3170
+ // this process (and therefore its cross-process session lease) alive until physical settlement.
3171
+ // A truly inert never-settling Promise has no side effect/handle, so normal process exit makes the
3172
+ // PID-backed lease safely reclaimable.
3173
+ void Promise.allSettled([...pendingHeadlessOperations]).then(() => releaseSessionLock(lockId));
3174
+ }
3175
+ else {
3176
+ releaseSessionLock(lockId);
3177
+ }
3178
+ }
2942
3179
  await closeMcp();
2943
3180
  }
2944
3181
  }
2945
3182
  // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
2946
3183
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
2947
3184
  out(c.bold(`hara ${pkg.version}`) + c.dim(` · ${cfg.provider}:${cfg.model} · ${approval}${sandbox !== "off" ? ` · sandbox:${sandbox}` : ""} · ${cwd}\n`));
3185
+ if (homeWorkspace) {
3186
+ out(c.yellow("⚠ Home directory is not treated as a project workspace. Run `cd /path/to/project`; recursive project scans are disabled here.\n"));
3187
+ }
2948
3188
  // Startup update notice — cache-driven (a previous session's background probe), so it costs zero
2949
3189
  // latency; today's probe (if due) fires in the background for the NEXT launch. TTY sessions only.
2950
3190
  if (cfg.updateCheck && stdout.isTTY) {
@@ -2964,16 +3204,21 @@ program.action(async (opts) => {
2964
3204
  return mentionCompleter(line, cwd);
2965
3205
  },
2966
3206
  });
2967
- const confirm = async (q) => (await rl.question(`${q} ${c.dim("[y/N]")} `)).trim().toLowerCase().startsWith("y");
3207
+ const confirm = async (q, signal) => {
3208
+ const prompt = `${q} ${c.dim("[y/N]")} `;
3209
+ const answer = signal ? await rl.question(prompt, { signal }) : await rl.question(prompt);
3210
+ return answer.trim().toLowerCase().startsWith("y");
3211
+ };
2968
3212
  // ask_user (classic REPL): print the question + a numbered menu (matching the setup menu look) and read the
2969
3213
  // answer through the SAME rl.question channel confirm uses. A bare option number selects it; any other text
2970
3214
  // is taken as a free-text answer — so the user can always type their own response.
2971
- const askUser = async (q, options) => {
3215
+ const askUser = async (q, options, signal) => {
2972
3216
  out(c.bold("\n? ") + q + "\n");
2973
3217
  const opts = (options ?? []).map((o) => o.trim()).filter(Boolean);
2974
3218
  opts.forEach((o, i) => out(` ${c.bold(String(i + 1))}) ${o}\n`));
2975
3219
  const hint = opts.length ? c.dim(`(1-${opts.length} to pick, or type your own answer) `) : c.dim("(type your answer) ");
2976
- const raw = (await rl.question(`${c.cyan("›")} ${hint}`)).trim();
3220
+ const prompt = `${c.cyan("›")} ${hint}`;
3221
+ const raw = (signal ? await rl.question(prompt, { signal }) : await rl.question(prompt)).trim();
2977
3222
  if (opts.length) {
2978
3223
  const n = Number.parseInt(raw, 10);
2979
3224
  if (Number.isInteger(n) && n >= 1 && n <= opts.length)
@@ -3004,20 +3249,20 @@ program.action(async (opts) => {
3004
3249
  // First-run AGENTS.md offer — classic REPL only. In TUI mode we must NOT call rl.question before ink
3005
3250
  // mounts: a readline question puts stdin in a state ink can't read from, leaving the input box dead
3006
3251
  // (the TUI shows a `/init` tip instead, below). See the `tip` in the runTui header.
3007
- if (!hasAgentsMd(cwd) && !useTui) {
3252
+ if (!homeWorkspace && !hasAgentsMd(cwd) && !useTui) {
3008
3253
  const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
3009
3254
  if (ans === "" || ans.startsWith("y")) {
3010
3255
  out(c.dim("Analyzing project…\n"));
3011
3256
  try {
3012
- await runInit(provider, cwd, sandbox);
3257
+ await runInit(provider, cwd, sandbox, cfg);
3013
3258
  }
3014
3259
  catch (e) {
3015
3260
  out(c.red(`[init error] ${e.message}\n`));
3016
3261
  }
3017
3262
  }
3018
3263
  }
3019
- let projectContext = loadAgentsMd(cwd) || undefined;
3020
- const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
3264
+ let projectContext = loadAgentContext(cwd) || undefined;
3265
+ const spawn = (t, role, signal) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role, signal);
3021
3266
  // session: --resume <id> / --continue (latest in this cwd) / new
3022
3267
  let resumeId = null;
3023
3268
  if (opts.resume) {
@@ -3147,8 +3392,8 @@ program.action(async (opts) => {
3147
3392
  run: async () => {
3148
3393
  out(c.dim("Analyzing project…\n"));
3149
3394
  try {
3150
- await runInit(provider, cwd, sandbox);
3151
- projectContext = loadAgentsMd(cwd) || undefined;
3395
+ await runInit(provider, cwd, sandbox, cfg);
3396
+ projectContext = loadAgentContext(cwd) || undefined;
3152
3397
  out(c.green("AGENTS.md updated.\n"));
3153
3398
  }
3154
3399
  catch (e) {
@@ -3367,7 +3612,16 @@ program.action(async (opts) => {
3367
3612
  desc: "summarize the conversation so far to free up context",
3368
3613
  run: async () => {
3369
3614
  out(c.dim("Compacting…\n"));
3370
- const summary = await compactConversation(provider, history, meta, stats);
3615
+ const compactTurn = new AbortController();
3616
+ currentTurn = compactTurn;
3617
+ let summary = null;
3618
+ try {
3619
+ summary = await compactConversation(provider, history, meta, stats, compactTurn.signal);
3620
+ }
3621
+ finally {
3622
+ if (currentTurn === compactTurn)
3623
+ currentTurn = null;
3624
+ }
3371
3625
  out(summary ? c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`) : c.dim("(nothing to compact / compact failed)\n"));
3372
3626
  },
3373
3627
  },
@@ -3427,16 +3681,16 @@ program.action(async (opts) => {
3427
3681
  // First-run AGENTS.md offer — via a tiny ink prompt, NOT readline. A readline question before the
3428
3682
  // main TUI leaves stdin unreadable by ink (dead input box); ink cleans up on unmount, so the TUI
3429
3683
  // mounted right after gets working input. Runs before mount, like the classic path.
3430
- if (!hasAgentsMd(cwd)) {
3684
+ if (!homeWorkspace && !hasAgentsMd(cwd)) {
3431
3685
  if (await askConfirm("No AGENTS.md here — analyze this project and create one?")) {
3432
3686
  out(c.dim("Analyzing project…\n"));
3433
3687
  try {
3434
- await runInit(provider, cwd, sandbox);
3688
+ await runInit(provider, cwd, sandbox, cfg);
3435
3689
  }
3436
3690
  catch (e) {
3437
3691
  out(c.red(`[init error] ${e.message}\n`));
3438
3692
  }
3439
- projectContext = loadAgentsMd(cwd) || undefined;
3693
+ projectContext = loadAgentContext(cwd) || undefined;
3440
3694
  }
3441
3695
  }
3442
3696
  setTheme(cfg.theme);
@@ -3452,13 +3706,13 @@ program.action(async (opts) => {
3452
3706
  // lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
3453
3707
  // Uses the screenshot-tuned prompt (actionable UI elements + positions) + an optional focus hint, so a
3454
3708
  // text-only main model gets something it can click on rather than a generic transcription.
3455
- const describeScreenshot = async (path, hint) => {
3709
+ const describeScreenshot = async (path, hint, signal) => {
3456
3710
  const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
3457
3711
  const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
3458
3712
  if (!vp)
3459
3713
  return "";
3460
3714
  try {
3461
- return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
3715
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint, signal });
3462
3716
  }
3463
3717
  catch {
3464
3718
  return "";
@@ -3466,13 +3720,13 @@ program.action(async (opts) => {
3466
3720
  };
3467
3721
  // grounding for accurate RPA: ask the vision model WHERE an element is (0..1 fractions) so the computer
3468
3722
  // tool can click it precisely instead of guessing pixels from a text description.
3469
- const locateScreenshot = async (path, target) => {
3723
+ const locateScreenshot = async (path, target, signal) => {
3470
3724
  const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
3471
3725
  const vp = cfg.visionModel ? await getVisionProvider() : cap === "vision" ? provider : null;
3472
3726
  if (!vp)
3473
3727
  return null;
3474
3728
  try {
3475
- return await locateImage(vp, { path, mediaType: "image/png" }, target);
3729
+ return await locateImage(vp, { path, mediaType: "image/png" }, target, { signal });
3476
3730
  }
3477
3731
  catch {
3478
3732
  return null;
@@ -3558,7 +3812,7 @@ program.action(async (opts) => {
3558
3812
  version: pkg.version,
3559
3813
  modelLabel: `${cfg.provider}:${cfg.model}`,
3560
3814
  cwd,
3561
- agentsMdLoaded: !!projectContext,
3815
+ agentsMdLoaded: hasAgentsMd(cwd),
3562
3816
  session: meta.id,
3563
3817
  kind: __activeP.kind === "gateway" ? "org" : "personal",
3564
3818
  profileId: __activeP.kind === "gateway" || __activeP.id === PERSONAL_ID ? undefined : __activeP.id,
@@ -3570,6 +3824,9 @@ program.action(async (opts) => {
3570
3824
  // the pre-mount stdout notice (line ~2497) doesn't survive ink taking the screen — TUI users
3571
3825
  // never saw update notices and versions silently went stale (field report: stuck on 0.112.5)
3572
3826
  updateNotice: cfg.updateCheck ? (checkForUpdate(pkg.version) ?? undefined) : undefined,
3827
+ workspaceNotice: homeWorkspace
3828
+ ? "Home is not a project workspace · cd /path/to/project (recursive scans disabled here)"
3829
+ : undefined,
3573
3830
  },
3574
3831
  visionNotice: __visionNotice,
3575
3832
  cycleApproval: (m) => cycleMode(m),
@@ -3595,6 +3852,7 @@ program.action(async (opts) => {
3595
3852
  memory: buildMemory(),
3596
3853
  stats,
3597
3854
  signal: h.signal,
3855
+ ...agentRunLimits(cfg),
3598
3856
  });
3599
3857
  saveSession(meta, history);
3600
3858
  }
@@ -3713,9 +3971,10 @@ program.action(async (opts) => {
3713
3971
  return void h.sink.notice("(nothing to compact)");
3714
3972
  h.sink.notice("✻ compacting…");
3715
3973
  const cui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
3974
+ let distillOutcome;
3716
3975
  if (cfg.evolve !== "off") {
3717
3976
  try {
3718
- await runAgent(history, {
3977
+ distillOutcome = await runAgent(history, {
3719
3978
  provider,
3720
3979
  ctx: { cwd, sandbox, spawn, ui: cui },
3721
3980
  approval: cfg.assetCapture === "auto" ? "full-auto" : "suggest", // ask → prompt before each save; auto → silent
@@ -3725,13 +3984,17 @@ program.action(async (opts) => {
3725
3984
  memory: buildMemory(),
3726
3985
  stats,
3727
3986
  signal: h.signal,
3987
+ ...agentRunLimits(cfg),
3728
3988
  });
3729
3989
  }
3730
3990
  catch {
3731
- /* flush is best-effort */
3991
+ return void h.sink.notice("(compact cancelled — memory distillation did not complete)");
3732
3992
  }
3733
3993
  }
3734
- const summary = await compactConversation(provider, history, meta, stats);
3994
+ if (h.signal.aborted || (distillOutcome && distillOutcome.status !== "completed")) {
3995
+ return void h.sink.notice("(compact cancelled — memory distillation was interrupted)");
3996
+ }
3997
+ const summary = await compactConversation(provider, history, meta, stats, h.signal);
3735
3998
  return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
3736
3999
  }
3737
4000
  if (nm === "sessions") {
@@ -3790,7 +4053,7 @@ program.action(async (opts) => {
3790
4053
  }
3791
4054
  if (nm === "commit") {
3792
4055
  h.sink.notice("✻ writing a commit message…");
3793
- const r = await autoCommit(provider, cwd); // stages all + commits with an AI message
4056
+ const r = await autoCommit(provider, cwd, h.signal); // stages all + commits with an AI message
3794
4057
  return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
3795
4058
  }
3796
4059
  if (nm === "review") {
@@ -3817,6 +4080,7 @@ program.action(async (opts) => {
3817
4080
  memory: buildMemory(),
3818
4081
  stats,
3819
4082
  signal: h.signal,
4083
+ ...agentRunLimits(cfg),
3820
4084
  });
3821
4085
  h.sink.usage(stats.input - xin, stats.output - xout);
3822
4086
  return;
@@ -3839,14 +4103,15 @@ program.action(async (opts) => {
3839
4103
  // ApprovalMode (no "plan"). Inside a /<skill> kickoff "plan" wouldn't make sense anyway —
3840
4104
  // fall back to "suggest" so we keep the user's confirm gate without crashing the type check.
3841
4105
  const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
4106
+ let skillOutcome;
3842
4107
  try {
3843
- await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt });
4108
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
3844
4109
  }
3845
4110
  catch (e) {
3846
4111
  h.sink.notice(`[error] ${e?.message ?? e}`);
3847
4112
  }
3848
- if (!meta.title) {
3849
- meta.title = await nameSession(provider, history);
4113
+ if (!meta.title && skillOutcome?.status === "completed" && !h.signal.aborted) {
4114
+ meta.title = await nameSession(provider, history, h.signal);
3850
4115
  h.sink.session(meta.title);
3851
4116
  }
3852
4117
  h.sink.usage(stats.input - skin, stats.output - skout);
@@ -3869,7 +4134,7 @@ program.action(async (opts) => {
3869
4134
  const out = [];
3870
4135
  for (const it of h.drainQueue()) {
3871
4136
  const r2 = await resolveImages(it.images, h);
3872
- const body = expandMentions(it.line, cwd) + (r2.skip ? "" : (r2.extraText ?? ""));
4137
+ const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
3873
4138
  const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
3874
4139
  if (!body.trim() && !attach)
3875
4140
  continue; // image-only message whose image was skipped → nothing to add
@@ -3885,7 +4150,7 @@ program.action(async (opts) => {
3885
4150
  const planImg = await resolveImages(images, h);
3886
4151
  if (planImg.skip)
3887
4152
  return;
3888
- history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
4153
+ history.push({ role: "user", content: (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (planImg.extraText ?? ""), ...(planImg.attach?.length ? { images: planImg.attach } : {}) });
3889
4154
  recalledContext = "";
3890
4155
  const pin = stats.input;
3891
4156
  const pout = stats.output;
@@ -3905,7 +4170,7 @@ program.action(async (opts) => {
3905
4170
  return "Plan submitted to the user for approval. Stop now and wait for their decision — do not keep working.";
3906
4171
  },
3907
4172
  };
3908
- await runAgent(history, {
4173
+ let planOutcome = await runAgent(history, {
3909
4174
  provider,
3910
4175
  ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
3911
4176
  approval: "suggest",
@@ -3919,16 +4184,28 @@ program.action(async (opts) => {
3919
4184
  stats,
3920
4185
  signal: h.signal,
3921
4186
  pendingInput,
4187
+ ...agentRunLimits(cfg),
3922
4188
  });
3923
- if (!meta.title) {
3924
- meta.title = await nameSession(provider, history);
4189
+ if (!meta.title && planOutcome.status === "completed" && !h.signal.aborted) {
4190
+ meta.title = await nameSession(provider, history, h.signal);
3925
4191
  h.sink.session(meta.title);
3926
4192
  }
3927
4193
  h.sink.usage(stats.input - pin, stats.output - pout);
3928
4194
  saveSession(meta, history);
4195
+ if (planOutcome.status !== "completed" || h.signal.aborted) {
4196
+ notifyDone(cfg.notify, {
4197
+ message: planOutcome.error ?? "plan turn did not complete",
4198
+ elapsedMs: Date.now() - turnStart,
4199
+ minMs: 0,
4200
+ });
4201
+ return;
4202
+ }
3929
4203
  if (!proposedPlan) {
3930
4204
  // No exit_plan this turn — the model was investigating or answering. Stay in plan mode quietly.
3931
- notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
4205
+ notifyDone(cfg.notify, {
4206
+ message: meta.title || "plan turn complete",
4207
+ elapsedMs: Date.now() - turnStart,
4208
+ });
3932
4209
  return;
3933
4210
  }
3934
4211
  const choice = await h.select("hara has a plan — proceed?", [
@@ -3941,7 +4218,7 @@ program.action(async (opts) => {
3941
4218
  history.push({ role: "user", content: "Proceed: execute the plan above." });
3942
4219
  const xin = stats.input;
3943
4220
  const xout = stats.output;
3944
- await runAgent(history, {
4221
+ planOutcome = await runAgent(history, {
3945
4222
  provider,
3946
4223
  ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
3947
4224
  approval: choice,
@@ -3953,24 +4230,29 @@ program.action(async (opts) => {
3953
4230
  signal: h.signal,
3954
4231
  pendingInput,
3955
4232
  guardian: guardianOpt,
4233
+ ...agentRunLimits(cfg),
3956
4234
  });
3957
4235
  h.sink.usage(stats.input - xin, stats.output - xout);
3958
4236
  saveSession(meta, history);
3959
4237
  }
3960
- notifyDone(cfg.notify, { message: meta.title || "plan turn complete", elapsedMs: Date.now() - turnStart });
4238
+ notifyDone(cfg.notify, {
4239
+ message: planOutcome.status === "halted" ? (planOutcome.error ?? "agent run halted") : (meta.title || "plan turn complete"),
4240
+ elapsedMs: Date.now() - turnStart,
4241
+ minMs: planOutcome.status === "halted" ? 0 : undefined,
4242
+ });
3961
4243
  return;
3962
4244
  }
3963
4245
  const ri = await resolveImages(images, h);
3964
4246
  if (ri.skip)
3965
4247
  return;
3966
- const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
4248
+ const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd, { signal: h.signal }) + (ri.extraText ?? "");
3967
4249
  recalledContext = "";
3968
4250
  history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
3969
4251
  if (cfg.fileCheckpoints)
3970
4252
  checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
3971
4253
  const beforeIn = stats.input;
3972
4254
  const beforeOut = stats.output;
3973
- await runAgent(history, {
4255
+ const turnOutcome = await runAgent(history, {
3974
4256
  provider,
3975
4257
  ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
3976
4258
  approval: appr,
@@ -3983,15 +4265,22 @@ program.action(async (opts) => {
3983
4265
  pendingInput,
3984
4266
  fallback: fbOpt,
3985
4267
  guardian: guardianOpt,
4268
+ ...agentRunLimits(cfg),
3986
4269
  });
3987
- if (!meta.title) {
3988
- meta.title = await nameSession(provider, history);
4270
+ if (!meta.title && turnOutcome.status === "completed" && !h.signal.aborted) {
4271
+ meta.title = await nameSession(provider, history, h.signal);
3989
4272
  h.sink.session(meta.title);
3990
4273
  }
3991
4274
  h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
3992
- notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
4275
+ notifyDone(cfg.notify, {
4276
+ message: turnOutcome.status === "halted" ? (turnOutcome.error ?? "agent run halted") : (meta.title || "turn complete"),
4277
+ elapsedMs: Date.now() - turnStart,
4278
+ minMs: turnOutcome.status === "halted" ? 0 : undefined,
4279
+ });
3993
4280
  saveSession(meta, history);
3994
- await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
4281
+ if (turnOutcome.status === "completed" && !h.signal.aborted) {
4282
+ await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m), h.signal);
4283
+ }
3995
4284
  },
3996
4285
  });
3997
4286
  // Only claim "saved · resume" if a turn actually persisted the session. A zero-turn session (opened,
@@ -4002,7 +4291,7 @@ program.action(async (opts) => {
4002
4291
  await closeMcp();
4003
4292
  process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
4004
4293
  }
4005
- out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${projectContext ? " (AGENTS.md loaded)" : ""}\n\n`));
4294
+ out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${hasAgentsMd(cwd) ? " (AGENTS.md loaded)" : ""}\n\n`));
4006
4295
  bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
4007
4296
  process.on("exit", () => {
4008
4297
  try {
@@ -4038,19 +4327,25 @@ program.action(async (opts) => {
4038
4327
  role: "user",
4039
4328
  content: `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest.length ? ` — request: ${rest.join(" ")}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`,
4040
4329
  });
4041
- currentTurn = new AbortController();
4330
+ const skillTurn = new AbortController();
4331
+ currentTurn = skillTurn;
4332
+ let skillOutcome;
4042
4333
  try {
4043
- await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
4334
+ skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4044
4335
  }
4045
4336
  catch (e) {
4046
4337
  out(c.red(`\n[error] ${e.message}\n`));
4047
4338
  }
4339
+ try {
4340
+ if (!meta.title && skillOutcome?.status === "completed" && !skillTurn.signal.aborted) {
4341
+ meta.title = await nameSession(provider, history, skillTurn.signal);
4342
+ }
4343
+ saveSession(meta, history);
4344
+ }
4048
4345
  finally {
4049
- currentTurn = null;
4346
+ if (currentTurn === skillTurn)
4347
+ currentTurn = null;
4050
4348
  }
4051
- if (!meta.title)
4052
- meta.title = await nameSession(provider, history);
4053
- saveSession(meta, history);
4054
4349
  continue;
4055
4350
  }
4056
4351
  const near = nearest(name, [...byName.keys()]);
@@ -4064,41 +4359,54 @@ program.action(async (opts) => {
4064
4359
  continue;
4065
4360
  }
4066
4361
  line = inlineLeadingPath(line, existsSync); // leading dropped file path → @-mention so it's read in
4067
- const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
4362
+ const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + await expandMentionsAsync(line, cwd);
4068
4363
  recalledContext = "";
4069
4364
  history.push({ role: "user", content: userContent });
4070
4365
  if (cfg.fileCheckpoints)
4071
4366
  checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
4072
- currentTurn = new AbortController();
4367
+ const turnController = new AbortController();
4368
+ currentTurn = turnController;
4073
4369
  const t0 = Date.now();
4370
+ let turnOutcome;
4074
4371
  try {
4075
- await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
4372
+ turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
4076
4373
  }
4077
4374
  catch (e) {
4078
4375
  out(c.red(`\n[error] ${e.message}\n`));
4079
4376
  }
4080
- finally {
4081
- currentTurn = null;
4082
- }
4083
- notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - t0 });
4084
- if (!meta.title)
4085
- meta.title = await nameSession(provider, history);
4086
- if (bar.isActive()) {
4087
- bar.update({
4088
- sessionName: meta.title,
4089
- input: stats.input,
4090
- output: stats.output,
4091
- ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0),
4092
- });
4093
- }
4094
- else {
4095
- out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
4377
+ notifyDone(cfg.notify, {
4378
+ message: turnOutcome?.status === "halted" ? (turnOutcome.error ?? "agent run halted") : (meta.title || "turn complete"),
4379
+ elapsedMs: Date.now() - t0,
4380
+ minMs: turnOutcome?.status === "halted" ? 0 : undefined,
4381
+ });
4382
+ try {
4383
+ if (!meta.title && turnOutcome?.status === "completed" && !turnController.signal.aborted) {
4384
+ meta.title = await nameSession(provider, history, turnController.signal);
4385
+ }
4386
+ if (bar.isActive()) {
4387
+ bar.update({
4388
+ sessionName: meta.title,
4389
+ input: stats.input,
4390
+ output: stats.output,
4391
+ ctxPct: bar.ctxPctFor(cfg.model, stats.lastInput ?? 0),
4392
+ });
4393
+ }
4394
+ else {
4395
+ out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
4396
+ }
4397
+ saveSession(meta, history);
4398
+ const compacted = turnOutcome?.status === "completed" && !turnController.signal.aborted
4399
+ ? await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`)), turnController.signal)
4400
+ : false;
4401
+ if (!compacted) {
4402
+ const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
4403
+ if (ctxPct >= 80)
4404
+ out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
4405
+ }
4096
4406
  }
4097
- saveSession(meta, history);
4098
- if (!(await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`))))) {
4099
- const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
4100
- if (ctxPct >= 80)
4101
- out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
4407
+ finally {
4408
+ if (currentTurn === turnController)
4409
+ currentTurn = null;
4102
4410
  }
4103
4411
  }
4104
4412
  bar.uninstall();