@nanhara/hara 0.121.0 → 0.122.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +40 -6
  3. package/dist/agent/failover.js +1 -1
  4. package/dist/agent/loop.js +158 -21
  5. package/dist/agent/reminders.js +22 -7
  6. package/dist/agent/repeat-guard.js +26 -7
  7. package/dist/agent/structured.js +231 -0
  8. package/dist/agent/touched.js +20 -6
  9. package/dist/config.js +62 -26
  10. package/dist/cron/deliver.js +37 -3
  11. package/dist/feedback.js +5 -9
  12. package/dist/fs-read.js +106 -12
  13. package/dist/fs-write.js +242 -16
  14. package/dist/gateway/dingtalk.js +4 -1
  15. package/dist/gateway/discord.js +53 -18
  16. package/dist/gateway/feishu.js +158 -57
  17. package/dist/gateway/flows-pending.js +720 -0
  18. package/dist/gateway/flows.js +391 -16
  19. package/dist/gateway/matrix.js +80 -15
  20. package/dist/gateway/mattermost.js +44 -32
  21. package/dist/gateway/media.js +659 -0
  22. package/dist/gateway/serve.js +657 -162
  23. package/dist/gateway/sessions.js +475 -78
  24. package/dist/gateway/signal.js +27 -22
  25. package/dist/gateway/slack.js +26 -17
  26. package/dist/gateway/telegram.js +32 -18
  27. package/dist/gateway/wecom.js +32 -24
  28. package/dist/gateway/weixin.js +127 -49
  29. package/dist/hooks.js +32 -20
  30. package/dist/index.js +640 -219
  31. package/dist/org/projects.js +347 -0
  32. package/dist/org/roles.js +38 -11
  33. package/dist/security/secrets.js +150 -0
  34. package/dist/serve/server.js +772 -317
  35. package/dist/serve/sessions.js +112 -28
  36. package/dist/session/store.js +337 -44
  37. package/dist/tools/all.js +1 -0
  38. package/dist/tools/builtin.js +61 -23
  39. package/dist/tools/codebase.js +3 -1
  40. package/dist/tools/computer.js +98 -92
  41. package/dist/tools/edit.js +11 -8
  42. package/dist/tools/patch.js +230 -31
  43. package/dist/tools/search.js +482 -72
  44. package/dist/tools/task.js +453 -0
  45. package/dist/tools/todo.js +67 -16
  46. package/dist/tools/web.js +364 -64
  47. package/dist/tui/run.js +26 -23
  48. package/dist/undo.js +83 -7
  49. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -13,9 +13,11 @@ import { execFileSync } from "node:child_process";
13
13
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
14
14
  import { homedir, tmpdir } from "node:os";
15
15
  import { fileURLToPath } from "node:url";
16
- import { dirname, join, relative } from "node:path";
16
+ import { randomUUID } from "node:crypto";
17
+ import { dirname, join, relative, resolve } from "node:path";
17
18
  import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
18
19
  import { runAgent } from "./agent/loop.js";
20
+ import { parseSchemaArg, structuredOutputTool, STRUCTURED_INSTRUCTION, STRUCTURED_NUDGE } from "./agent/structured.js";
19
21
  import { notifyDone } from "./notify.js";
20
22
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
21
23
  import { completionScript } from "./completions.js";
@@ -25,8 +27,8 @@ import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile,
25
27
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
26
28
  import { routingProvider } from "./agent/route.js";
27
29
  import { shouldAutoCompact, shouldAutoCompactTokens, AUTO_COMPACT_TOKEN_CAP, COMPACT_SYSTEM, buildFileRestore, workingSetFromSummary } from "./agent/compact.js";
28
- import { recentTouched } from "./agent/touched.js";
29
- import { INTERJECT_PREFIX } from "./agent/reminders.js";
30
+ import { recentTouched, clearTouched } from "./agent/touched.js";
31
+ import { INTERJECT_PREFIX, disposeReminderScope } from "./agent/reminders.js";
30
32
  import { checkForUpdate } from "./update-check.js";
31
33
  import { formatContextReport } from "./agent/context-report.js";
32
34
  import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
@@ -68,9 +70,10 @@ import { getEmbedder } from "./search/embed.js";
68
70
  import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
69
71
  import { searchHybrid } from "./search/hybrid.js";
70
72
  import { expandMentions, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
71
- import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
73
+ import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
72
74
  import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
73
- import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
75
+ import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
76
+ import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
74
77
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
75
78
  import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
76
79
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
@@ -92,6 +95,8 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
92
95
  import "./tools/skill.js"; // register the skill loader tool
93
96
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
94
97
  import "./tools/todo.js"; // register todo_write (inline task checklist)
98
+ import { clearTodos, disposeTodoScope, restoreTodos, onTodosChange } from "./tools/todo.js"; // scoped session todo persistence
99
+ import "./tools/task.js"; // register task (project-level durable task pool)
95
100
  import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
96
101
  import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
97
102
  import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
@@ -443,18 +448,29 @@ async function commitStep(provider, cwd) {
443
448
  out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
444
449
  }
445
450
  /** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
451
+ function runFailureDetail(outcome) {
452
+ if (outcome.status === "completed")
453
+ return null;
454
+ if (outcome.error?.trim())
455
+ return outcome.error.trim();
456
+ if (outcome.status === "empty")
457
+ return "the model returned an empty response";
458
+ if (outcome.status === "halted")
459
+ return "the run was halted by safety controls";
460
+ return "the agent run failed";
461
+ }
446
462
  async function runOrg(task, o) {
447
463
  const roles = loadRoles(o.cwd);
448
464
  if (!roles.length) {
449
465
  out(c.yellow("No roles defined — run ") + c.bold("hara roles init") + c.yellow(" to scaffold some.\n"));
450
- return;
466
+ return { status: "error", error: "no roles are defined" };
451
467
  }
452
468
  let role;
453
469
  if (o.forceRole) {
454
470
  role = roles.find((r) => r.id === o.forceRole);
455
471
  if (!role) {
456
472
  out(c.red(`No role '${o.forceRole}'. Available: ${roles.map((r) => r.id).join(", ")}\n`));
457
- return;
473
+ return { status: "error", error: `no role '${o.forceRole}' is available` };
458
474
  }
459
475
  }
460
476
  else {
@@ -478,23 +494,22 @@ async function runOrg(task, o) {
478
494
  const roleProvider = __roleModel
479
495
  ? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
480
496
  : o.baseProvider;
481
- const toolFilter = role.allowTools
482
- ? (n) => role.allowTools.includes(n)
483
- : role.denyTools
484
- ? (n) => !role.denyTools.includes(n)
485
- : undefined;
497
+ const toolFilter = roleToolFilter(role);
486
498
  const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
487
- const runImplementer = () => runAgent(history, {
488
- provider: roleProvider,
489
- ctx: { cwd: o.cwd, sandbox: o.sandbox },
490
- approval: o.approval,
491
- confirm: o.confirm,
492
- projectContext: o.projectContext,
493
- memory: memoryDigest(o.cwd),
494
- stats: o.stats,
495
- systemOverride: role.system,
496
- toolFilter,
497
- });
499
+ const runImplementer = async () => {
500
+ return runAgent(history, {
501
+ provider: roleProvider,
502
+ ctx: { cwd: o.cwd, sandbox: o.sandbox },
503
+ approval: o.approval,
504
+ confirm: o.confirm,
505
+ projectContext: o.projectContext,
506
+ memory: memoryDigest(o.cwd),
507
+ stats: o.stats,
508
+ systemOverride: role.system,
509
+ toolFilter,
510
+ ...(role.readOnly ? { hooks: false } : {}),
511
+ });
512
+ };
498
513
  const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
499
514
  const doCommit = async (ok) => {
500
515
  if (!o.commit)
@@ -505,27 +520,29 @@ async function runOrg(task, o) {
505
520
  return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
506
521
  await commitStep(o.baseProvider, o.cwd);
507
522
  };
508
- await runImplementer();
523
+ let implementerOutcome = await runImplementer();
524
+ if (implementerOutcome.status !== "completed")
525
+ return implementerOutcome;
509
526
  if (!o.review) {
510
527
  await doCommit(true);
511
- return;
528
+ return implementerOutcome;
512
529
  }
513
530
  // Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
514
531
  const reviewer = roles.find((r) => r.id === "reviewer");
515
532
  const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
516
533
  const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
517
534
  const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
518
- const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
535
+ const revTools = roleToolFilter(reviewer ? { ...reviewer, readOnly: true } : undefined) ?? ((n) => READONLY_TOOLS.has(n));
519
536
  const maxRounds = Math.max(1, o.rounds ?? 3);
520
537
  for (let round = 1; round <= maxRounds; round++) {
521
538
  const changes = captureChanges(o.cwd);
522
539
  if (!changes.diff && !changes.newFiles.length) {
523
540
  out(c.dim("(no changes to review)\n"));
524
- return;
541
+ return implementerOutcome;
525
542
  }
526
543
  out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
527
544
  const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
528
- await runAgent(rHist, {
545
+ const reviewerOutcome = await runAgent(rHist, {
529
546
  provider: revProvider,
530
547
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
531
548
  approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
@@ -535,22 +552,28 @@ async function runOrg(task, o) {
535
552
  stats: o.stats,
536
553
  systemOverride: revSystem,
537
554
  toolFilter: revTools,
555
+ hooks: false,
538
556
  });
557
+ if (reviewerOutcome.status !== "completed")
558
+ return reviewerOutcome;
539
559
  const verdict = parseVerdict(lastAssistantText(rHist));
540
560
  if (verdict.approved) {
541
561
  out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
542
562
  await doCommit(true);
543
- return;
563
+ return implementerOutcome;
544
564
  }
545
565
  if (round === maxRounds) {
546
566
  out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
547
567
  await doCommit(false);
548
- return;
568
+ return { status: "halted", error: `reviewer did not approve after ${maxRounds} round(s)` };
549
569
  }
550
570
  out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
551
571
  history.push({ role: "user", content: fixPrompt(verdict.issues) });
552
- await runImplementer();
572
+ implementerOutcome = await runImplementer();
573
+ if (implementerOutcome.status !== "completed")
574
+ return implementerOutcome;
553
575
  }
576
+ return implementerOutcome;
554
577
  }
555
578
  function lastAssistantText(history) {
556
579
  for (let i = history.length - 1; i >= 0; i--) {
@@ -567,14 +590,10 @@ async function executeAtom(atom, plan, done, roles, o) {
567
590
  const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
568
591
  const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
569
592
  const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
570
- const toolFilter = role?.allowTools
571
- ? (n) => role.allowTools.includes(n)
572
- : role?.denyTools
573
- ? (n) => !role.denyTools.includes(n)
574
- : undefined;
593
+ const toolFilter = roleToolFilter(role);
575
594
  const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
576
595
  try {
577
- await runAgent(history, {
596
+ const outcome = await runAgent(history, {
578
597
  provider: roleProvider,
579
598
  ctx: { cwd: o.cwd, sandbox: o.sandbox },
580
599
  approval: o.approval,
@@ -584,8 +603,17 @@ async function executeAtom(atom, plan, done, roles, o) {
584
603
  stats: o.stats,
585
604
  systemOverride: role?.system,
586
605
  toolFilter,
606
+ ...(role?.readOnly ? { hooks: false } : {}),
587
607
  quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
588
608
  });
609
+ const failure = runFailureDetail(outcome);
610
+ if (failure) {
611
+ atom.status = "failed";
612
+ atom.note = failure;
613
+ savePlan(o.cwd, plan);
614
+ out(c.red(` ✗ ${atom.id} agent ${outcome.status}: ${failure}\n`));
615
+ return false;
616
+ }
589
617
  }
590
618
  catch (e) {
591
619
  atom.status = "failed";
@@ -606,10 +634,13 @@ async function executeAtom(atom, plan, done, roles, o) {
606
634
  async function executePlan(plan, roles, o) {
607
635
  const done = plan.atoms.filter((a) => a.status === "done");
608
636
  const doneIds = new Set(done.map((a) => a.id));
637
+ let failed = false;
609
638
  if (o.parallel) {
610
639
  const waved = topoWaves(plan.atoms);
611
- if ("error" in waved)
612
- return void out(c.red(`${waved.error}\n`));
640
+ if ("error" in waved) {
641
+ out(c.red(`${waved.error}\n`));
642
+ return { status: "error", error: waved.error };
643
+ }
613
644
  out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
614
645
  for (const wave of waved.ok) {
615
646
  const todo = wave.filter((a) => !doneIds.has(a.id));
@@ -624,6 +655,7 @@ async function executePlan(plan, roles, o) {
624
655
  }
625
656
  });
626
657
  if (results.some((r) => !r)) {
658
+ failed = true;
627
659
  out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
628
660
  break;
629
661
  }
@@ -631,8 +663,10 @@ async function executePlan(plan, roles, o) {
631
663
  }
632
664
  else {
633
665
  const ord = topoOrder(plan.atoms);
634
- if ("error" in ord)
635
- return void out(c.red(`${ord.error}\n`));
666
+ if ("error" in ord) {
667
+ out(c.red(`${ord.error}\n`));
668
+ return { status: "error", error: ord.error };
669
+ }
636
670
  for (const atom of ord.ok) {
637
671
  if (doneIds.has(atom.id))
638
672
  continue; // resume: skip completed atoms
@@ -642,12 +676,21 @@ async function executePlan(plan, roles, o) {
642
676
  doneIds.add(atom.id);
643
677
  }
644
678
  else {
679
+ failed = true;
645
680
  out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
646
681
  break;
647
682
  }
648
683
  }
649
684
  }
650
685
  out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
686
+ if (failed || plan.atoms.some((atom) => atom.status === "failed")) {
687
+ const first = plan.atoms.find((atom) => atom.status === "failed");
688
+ return { status: "error", error: first ? `atom ${first.id} failed${first.note ? `: ${first.note}` : ""}` : "plan execution failed" };
689
+ }
690
+ if (plan.atoms.some((atom) => atom.status !== "done")) {
691
+ return { status: "halted", error: "plan stopped before every atom completed" };
692
+ }
693
+ return { status: "completed" };
651
694
  }
652
695
  /** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
653
696
  * With `parallel`, independent atoms (the same dependency wave) run concurrently. */
@@ -657,12 +700,12 @@ async function runPlan(task, o) {
657
700
  const plan = await decompose(o.baseProvider, task, roles);
658
701
  if (!plan.atoms.length) {
659
702
  out(c.red("Planner returned no atoms — try rephrasing the task.\n"));
660
- return;
703
+ return { status: "error", error: "planner returned no atoms" };
661
704
  }
662
705
  const ord = topoOrder(plan.atoms);
663
706
  if ("error" in ord) {
664
707
  out(c.red(`${ord.error}\n`));
665
- return;
708
+ return { status: "error", error: ord.error };
666
709
  }
667
710
  out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
668
711
  for (const a of ord.ok) {
@@ -670,34 +713,42 @@ async function runPlan(task, o) {
670
713
  }
671
714
  if (o.approval !== "full-auto") {
672
715
  const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
673
- if (!ok)
674
- return void out(c.dim("(cancelled)\n"));
716
+ if (!ok) {
717
+ out(c.dim("(cancelled)\n"));
718
+ return { status: "halted", error: "plan execution was cancelled" };
719
+ }
675
720
  }
676
721
  savePlan(o.cwd, plan);
677
- await executePlan(plan, roles, o);
722
+ return executePlan(plan, roles, o);
678
723
  }
679
724
  /** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
680
725
  async function runResume(o) {
681
726
  const roles = loadRoles(o.cwd);
682
727
  const plan = loadPlan(o.cwd);
683
- if (!plan)
684
- return void out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
728
+ if (!plan) {
729
+ out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
730
+ return { status: "error", error: "no saved plan" };
731
+ }
685
732
  const remaining = plan.atoms.filter((a) => a.status !== "done");
686
- if (!remaining.length)
687
- return void out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
733
+ if (!remaining.length) {
734
+ out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
735
+ return { status: "completed" };
736
+ }
688
737
  out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
689
738
  for (const a of remaining)
690
739
  out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
691
740
  if (o.approval !== "full-auto") {
692
741
  const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
693
- if (!ok)
694
- return void out(c.dim("(cancelled)\n"));
742
+ if (!ok) {
743
+ out(c.dim("(cancelled)\n"));
744
+ return { status: "halted", error: "plan resume was cancelled" };
745
+ }
695
746
  }
696
747
  for (const a of plan.atoms)
697
748
  if (a.status === "failed" || a.status === "running")
698
749
  a.status = "pending"; // retry interrupted
699
750
  savePlan(o.cwd, plan);
700
- await executePlan(plan, roles, o);
751
+ return executePlan(plan, roles, o);
701
752
  }
702
753
  const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
703
754
  const REVIEW_SYSTEM = "You are a senior code reviewer. Review the git diff the user provides for: correctness bugs, security " +
@@ -818,29 +869,64 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
818
869
  /** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
819
870
  async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
820
871
  const roles = loadRoles(cwd);
821
- const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
872
+ const roleRef = roleId?.trim();
873
+ if (roleId !== undefined && !roleRef)
874
+ return "Error: sub-agent role cannot be blank.";
875
+ let role;
876
+ if (roleRef?.includes(":")) {
877
+ const hit = resolveAgent(roleRef, cwd);
878
+ if (hit && "ambiguous" in hit) {
879
+ return `Error: sub-agent role '${roleRef}' is ambiguous; use one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}.`;
880
+ }
881
+ if (hit?.project && resolve(hit.home) !== resolve(cwd)) {
882
+ return `Error: sub-agent role '${roleRef}' belongs to ${hit.home}; nested read-only agents stay in their parent home (${cwd}).`;
883
+ }
884
+ if (hit && !("ambiguous" in hit)) {
885
+ role = hit.project
886
+ ? roles.find((candidate) => candidate.id === hit.name)
887
+ : loadGlobalRoles().find((candidate) => candidate.id === hit.name);
888
+ }
889
+ }
890
+ else if (roleRef) {
891
+ role = roles.find((candidate) => candidate.id === roleRef);
892
+ }
822
893
  // Built-in explore persona: `agent(role:"explore")` works with ZERO user setup (a user-defined
823
894
  // explore role still wins — it was found above and carries its own system).
824
- const builtinSystem = !role && roleId === "explore" ? EXPLORE_SYSTEM : undefined;
825
- const __subModel = effectiveRoleModel(role?.model, cfg.model);
826
- const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
895
+ const builtinSystem = !role && roleRef === "explore" ? EXPLORE_SYSTEM : undefined;
896
+ if (roleRef && !role && !builtinSystem) {
897
+ return `Error: no sub-agent role '${roleRef}' is available in ${cwd}. Use a local role id, global:<name>, or role "explore".`;
898
+ }
899
+ const __subModel = effectiveRoleModel(role?.model, baseProvider.model);
900
+ const provider = __subModel && __subModel !== baseProvider.model
901
+ ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider)
902
+ : baseProvider;
827
903
  // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
828
904
  // further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
829
905
  // Write-capable roles run in the main loop via `hara org`, behind the user's gate.
830
906
  const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
831
907
  const subHistory = [{ role: "user", content: task }];
832
- await runAgent(subHistory, {
833
- provider,
834
- ctx: { cwd, sandbox }, // no `spawn` here → sub-agents can't recurse
835
- approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
836
- confirm: async () => true,
837
- projectContext,
838
- memory: memoryDigest(cwd),
839
- stats,
840
- systemOverride: role?.system ?? builtinSystem,
841
- toolFilter,
842
- quiet: true,
843
- });
908
+ const todoScope = `subagent:${randomUUID()}`;
909
+ try {
910
+ await runAgent(subHistory, {
911
+ provider,
912
+ ctx: { cwd, sandbox, todoScope }, // isolated checklist; no spawn → no recursion
913
+ approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
914
+ confirm: async () => true,
915
+ projectContext,
916
+ memory: memoryDigest(cwd),
917
+ stats,
918
+ systemOverride: role?.system ?? builtinSystem,
919
+ toolFilter,
920
+ hooks: false,
921
+ quiet: true,
922
+ });
923
+ }
924
+ finally {
925
+ disposeTodoScope(todoScope);
926
+ disposeReminderScope(todoScope);
927
+ resetRepeatGuard(todoScope);
928
+ clearTouched(todoScope);
929
+ }
844
930
  for (let i = subHistory.length - 1; i >= 0; i--) {
845
931
  const m = subHistory[i];
846
932
  if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
@@ -898,6 +984,8 @@ program
898
984
  .description("A coding agent CLI that runs like an engineering org.")
899
985
  .version(pkg.version)
900
986
  .option("-p, --print <prompt>", "run a single prompt non-interactively, then exit")
987
+ .option("--schema <json|file>", "(with -p) force a schema-shaped result: the model must call structured_output; stdout = that JSON")
988
+ .option("--role <id>", "(with -p) run as a local, global:name, or project:name role (qualified projects run at their registered home)")
901
989
  .option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
902
990
  .option("-m, --model <model>", "model id (overrides config)")
903
991
  .option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
@@ -980,31 +1068,98 @@ program
980
1068
  .option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
981
1069
  .option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
982
1070
  .option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
983
- .action(async (taskParts, opts2) => {
984
- const cfg = loadConfig();
1071
+ .action(async (taskParts, _localOpts, command) => {
1072
+ // The root headless command and `org` both expose --role. Commander stores that shared flag on the
1073
+ // parent command, while review/rounds/commit stay local; optsWithGlobals is the supported merged view.
1074
+ // Without it, flow-approved `hara org --role ...` children silently fell back to model dispatching.
1075
+ const opts2 = command.optsWithGlobals();
1076
+ let cfg = loadConfig();
1077
+ // Home dispatch (globally addressable, executes at home): a --role of "project:name" — or a bare name
1078
+ // that only exists in a REGISTERED project — resolves via the global agent index and runs with cwd =
1079
+ // that project (its AGENTS.md/data context), instead of failing or running context-blind here.
1080
+ let orgCwd = cfg.cwd;
1081
+ let forceRole = opts2.role;
1082
+ if (opts2.role && (opts2.role.includes(":") || !loadRoles(cfg.cwd).some((r) => r.id === opts2.role))) {
1083
+ const hit = resolveAgent(opts2.role);
1084
+ if (hit && "ambiguous" in hit) {
1085
+ out(c.yellow(`'${opts2.role}' exists in several projects — qualify it:\n`) + hit.ambiguous.map((e) => ` ${e.project}:${e.name}`).join("\n") + "\n");
1086
+ process.exit(1);
1087
+ }
1088
+ if (hit?.home) {
1089
+ orgCwd = hit.home;
1090
+ forceRole = hit.name;
1091
+ cfg = loadConfig({ cwd: hit.home });
1092
+ out(c.dim(`(dispatching to ${hit.project}:${hit.name} · home ${hit.home})\n`));
1093
+ }
1094
+ else if (hit) {
1095
+ // Explicit `global:name` is an address, not the literal role id. Global roles intentionally run
1096
+ // in the caller's current project so they retain that project's AGENTS.md and tool context.
1097
+ forceRole = hit.name;
1098
+ out(c.dim(`(dispatching to global:${hit.name} · current home ${orgCwd})\n`));
1099
+ }
1100
+ else if (!hit) {
1101
+ out(c.red(`No agent '${opts2.role}' was found here or in the registered project index.\n`));
1102
+ process.exit(1);
1103
+ }
1104
+ }
985
1105
  const provider = await buildProvider(cfg);
986
1106
  if (!provider) {
987
- out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
1107
+ out(c.red(`Not authenticated for provider '${cfg.provider}' at ${orgCwd}.\n`) + authHint(cfg) + "\n");
988
1108
  process.exit(1);
989
1109
  }
990
1110
  const stats = { input: 0, output: 0, lastInput: 0 };
991
- await runOrg(taskParts.join(" "), {
1111
+ const outcome = await runOrg(taskParts.join(" "), {
992
1112
  cfg,
993
1113
  baseProvider: provider,
994
- cwd: cfg.cwd,
1114
+ cwd: orgCwd,
995
1115
  sandbox: cfg.sandbox,
996
1116
  approval: "full-auto",
997
1117
  confirm: async () => true,
998
- projectContext: loadAgentsMd(cfg.cwd) || undefined,
1118
+ projectContext: loadAgentsMd(orgCwd) || undefined,
999
1119
  stats,
1000
- forceRole: opts2.role,
1120
+ forceRole,
1001
1121
  review: opts2.review,
1002
1122
  rounds: opts2.rounds,
1003
1123
  commit: opts2.commit,
1004
1124
  });
1125
+ const failure = runFailureDetail(outcome);
1126
+ if (failure) {
1127
+ process.stderr.write(`hara: org run failed (${outcome.status}) — ${failure}\n`);
1128
+ process.exitCode = 2;
1129
+ }
1005
1130
  if (stats.input || stats.output)
1006
1131
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1007
1132
  });
1133
+ program
1134
+ .command("agents")
1135
+ .description("global agent index — every addressable agent (global roles + registered projects) and its home")
1136
+ .action(() => {
1137
+ const idx = buildAgentsIndex();
1138
+ if (!idx.length) {
1139
+ out(c.dim("(no agents — add roles to ~/.hara/roles, or register projects: hara projects add <name> <path>)\n"));
1140
+ return;
1141
+ }
1142
+ for (const e of idx) {
1143
+ out(c.bold(e.project ? `${e.project}:${e.name}` : `global:${e.name}`) + c.dim(e.home ? ` · ${e.home}` : " · current project") + "\n");
1144
+ if (e.description)
1145
+ out(c.dim(` ${e.description.slice(0, 120)}\n`));
1146
+ }
1147
+ out(c.dim(`\n${idx.length} agent(s). Run one: hara org --role <global:name|project:name> "<task>"\n`));
1148
+ });
1149
+ const projectsCmd = program.command("projects").description("register project homes for the global agent index (see `hara agents`)");
1150
+ projectsCmd.command("list").action(() => {
1151
+ const list = loadProjects();
1152
+ out(list.length ? list.map((p) => `${c.bold(p.name)} ${c.dim(p.path)}`).join("\n") + "\n" : c.dim("(none — hara projects add <name> <path>)\n"));
1153
+ });
1154
+ projectsCmd.command("add <name> <path>").action((name, path) => {
1155
+ const err = addProject(name, resolve(path));
1156
+ out(err ? c.red(`${err}\n`) : c.green(`✓ registered ${name} → ${resolve(path)}\n`));
1157
+ if (err)
1158
+ process.exit(1);
1159
+ });
1160
+ projectsCmd.command("remove <name>").action((name) => {
1161
+ out(removeProject(name) ? c.green(`✓ removed ${name}\n`) : c.yellow(`no project '${name}'\n`));
1162
+ });
1008
1163
  program
1009
1164
  .command("plan [task...]")
1010
1165
  .description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
@@ -1029,12 +1184,20 @@ program
1029
1184
  parallel: opts.parallel,
1030
1185
  };
1031
1186
  const task = (taskParts ?? []).join(" ").trim();
1187
+ let outcome;
1032
1188
  if (task === "resume")
1033
- await runResume(o);
1189
+ outcome = await runResume(o);
1034
1190
  else if (!task)
1035
1191
  out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
1036
1192
  else
1037
- await runPlan(task, o);
1193
+ outcome = await runPlan(task, o);
1194
+ if (outcome) {
1195
+ const failure = runFailureDetail(outcome);
1196
+ if (failure) {
1197
+ process.stderr.write(`hara: plan run failed (${outcome.status}) — ${failure}\n`);
1198
+ process.exitCode = 2;
1199
+ }
1200
+ }
1038
1201
  if (stats.input || stats.output)
1039
1202
  out(statusLine(cfg.model, stats.input, stats.output) + "\n");
1040
1203
  });
@@ -1574,12 +1737,40 @@ program
1574
1737
  version: pkg.version,
1575
1738
  providerId: cfg.provider,
1576
1739
  model: cfg.model,
1577
- buildSessionProvider: async () => withRouting(await buildProvider(cfg), cfg),
1578
- buildProviderFor: async (model, effort) => withRouting(await buildProvider({ ...cfg, model, reasoningEffort: effort ?? cfg.reasoningEffort }), cfg),
1579
- listModels: () => listModels(cfg.baseURL ?? providerDefaultBaseURL(cfg.provider), cfg.apiKey ?? ""),
1740
+ // `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
1741
+ // new/resumed session and model operation so a repaired/rotated key takes effect without restarting
1742
+ // the desktop server (and, critically, never ask for a key that is already on disk).
1743
+ buildSessionProvider: async (targetCwd) => {
1744
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1745
+ return withRouting(await buildProvider(live), live);
1746
+ },
1747
+ buildProviderFor: async (model, effort, targetCwd) => {
1748
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1749
+ return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
1750
+ },
1751
+ listModels: (targetCwd) => {
1752
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1753
+ return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
1754
+ },
1580
1755
  effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
1581
- spawnSubagent: (provider, scwd, projectContext, stats, task, role) => runSubagent(cfg, provider, scwd, sandbox, projectContext, stats, task, role),
1756
+ runtimeInfo: (targetCwd) => {
1757
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1758
+ return {
1759
+ providerId: live.provider,
1760
+ model: live.model,
1761
+ effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
1762
+ };
1763
+ },
1764
+ spawnSubagent: (provider, scwd, projectContext, stats, task, role) => {
1765
+ const live = loadConfig({ cwd: scwd });
1766
+ return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role);
1767
+ },
1582
1768
  guardian: guardianOpt,
1769
+ buildGuardian: async (targetCwd) => {
1770
+ const live = loadConfig({ cwd: targetCwd ?? cwd });
1771
+ const base = await withRouting(await buildProvider(live), live);
1772
+ return buildGuardian(live, base);
1773
+ },
1583
1774
  sandbox,
1584
1775
  approval,
1585
1776
  });
@@ -1724,6 +1915,7 @@ program
1724
1915
  confirm: async () => true,
1725
1916
  systemOverride: REVIEW_SYSTEM,
1726
1917
  toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
1918
+ hooks: false,
1727
1919
  projectContext: loadAgentsMd(cfg.cwd) || undefined,
1728
1920
  memory: memoryDigest(cfg.cwd),
1729
1921
  stats,
@@ -2274,7 +2466,48 @@ program.action(async (opts) => {
2274
2466
  // hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
2275
2467
  // pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
2276
2468
  // in the priority chain than --profile).
2277
- const cfg = loadConfig({ overlay: opts.overlay });
2469
+ // Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
2470
+ // an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
2471
+ // constructed from that home rather than from the shell directory that happened to launch hara.
2472
+ let requestedHeadlessAgent;
2473
+ if (opts.print && opts.role) {
2474
+ const ref = String(opts.role).trim();
2475
+ const isLocalRole = !ref.includes(":") && loadRoles(process.cwd()).some((role) => role.id === ref);
2476
+ if (!isLocalRole) {
2477
+ const hit = resolveAgent(ref, process.cwd());
2478
+ if (hit && "ambiguous" in hit) {
2479
+ process.stderr.write(`hara: role '${ref}' is ambiguous; choose one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}\n`);
2480
+ process.exitCode = 2;
2481
+ return;
2482
+ }
2483
+ if (!hit) {
2484
+ process.stderr.write(`hara: no agent '${ref}' was found locally, globally, or in the registered project index.\n`);
2485
+ process.exitCode = 2;
2486
+ return;
2487
+ }
2488
+ requestedHeadlessAgent = hit;
2489
+ }
2490
+ }
2491
+ const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
2492
+ const cwd = cfg.cwd;
2493
+ // Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
2494
+ // stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
2495
+ // object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
2496
+ let requestedHeadlessRole;
2497
+ if (opts.print && opts.role) {
2498
+ const requestedRole = String(opts.role).trim();
2499
+ requestedHeadlessRole = requestedHeadlessAgent?.project
2500
+ ? loadRoles(requestedHeadlessAgent.home).find((candidate) => candidate.id === requestedHeadlessAgent.name)
2501
+ : requestedHeadlessAgent
2502
+ ? loadGlobalRoles().find((candidate) => candidate.id === requestedHeadlessAgent.name)
2503
+ : loadRoles(cwd).find((candidate) => candidate.id === requestedRole);
2504
+ if (!requestedHeadlessRole) {
2505
+ process.stderr.write(`hara: role '${opts.role}' disappeared from its declared home (${cwd}); refusing to start providers or MCP servers under the wrong persona.\n`);
2506
+ process.exitCode = 2;
2507
+ return;
2508
+ }
2509
+ }
2510
+ const machineOutput = !!opts.print && !!opts.schema;
2278
2511
  if (opts.model)
2279
2512
  cfg.model = opts.model;
2280
2513
  const provider0 = await withRouting(await buildProvider(cfg), cfg);
@@ -2319,7 +2552,11 @@ program.action(async (opts) => {
2319
2552
  process.exit(0);
2320
2553
  }
2321
2554
  }
2322
- out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
2555
+ const message = `Not authenticated for provider '${cfg.provider}'.\n${authHint(cfg)}\n`;
2556
+ if (machineOutput)
2557
+ process.stderr.write(message);
2558
+ else
2559
+ out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
2323
2560
  process.exit(1);
2324
2561
  }
2325
2562
  let provider = provider0;
@@ -2338,146 +2575,291 @@ program.action(async (opts) => {
2338
2575
  void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
2339
2576
  void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
2340
2577
  }
2341
- const cwd = cfg.cwd;
2342
2578
  let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
2343
2579
  let currentTurn = null; // set during a running turn so Esc can abort it
2344
2580
  const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
2345
2581
  let recalledContext = ""; // snippets queued by /recall, prepended to the next message
2346
2582
  const sandbox = opts.sandbox || cfg.sandbox;
2347
2583
  if (sandbox !== "off" && !sandboxSupported()) {
2348
- out(c.yellow(`(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`));
2584
+ const message = `(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`;
2585
+ if (machineOutput)
2586
+ process.stderr.write(message);
2587
+ else
2588
+ out(c.yellow(message));
2349
2589
  }
2350
2590
  const stats = { input: 0, output: 0, lastInput: 0 };
2351
- const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2352
- if (Object.keys(mcpAll).length) {
2353
- await connectMcpServers(mcpAll, (m) => out(c.dim(m + "\n")));
2591
+ // A read-only role has a process-side-effect-free tool surface: even connecting to an MCP server runs an
2592
+ // arbitrary configured command before a tool is selected. Interactive sessions and non-read-only roles keep
2593
+ // the existing MCP behavior unchanged.
2594
+ if (!requestedHeadlessRole?.readOnly) {
2595
+ const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2596
+ if (Object.keys(mcpAll).length) {
2597
+ await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")));
2598
+ }
2354
2599
  }
2355
2600
  // one-shot
2356
2601
  if (opts.print) {
2357
- const projectContext = loadAgentsMd(cwd) || undefined;
2358
- // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
2359
- // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
2360
- // describeScreenshot a configured visionModel, else the main model if it's vision-capable.
2361
- const describeImage = async (path, hint) => {
2362
- const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2363
- const vp = cfg.visionModel
2364
- ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
2365
- : cap === "vision"
2366
- ? provider
2367
- : null;
2368
- if (!vp)
2369
- return "";
2370
- try {
2371
- return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
2372
- }
2373
- catch {
2374
- return "";
2375
- }
2376
- };
2377
- // Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
2378
- // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
2379
- // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
2380
- let meta = null;
2381
- const history = [];
2382
- if (opts.resume || opts.continue) {
2383
- const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
2384
- const prior = rid ? loadSession(rid) : null;
2385
- if (prior?.history)
2386
- history.push(...prior.history);
2387
- // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2388
- // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2389
- const src = sessionSourceFromEnv();
2390
- meta = prior?.meta ?? {
2391
- id: rid ?? newSessionId(),
2392
- cwd,
2393
- provider: cfg.provider,
2394
- model: cfg.model,
2395
- title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2396
- createdAt: new Date().toISOString(),
2397
- updatedAt: "",
2398
- ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2602
+ let headlessLockId = null;
2603
+ try {
2604
+ const projectContext = loadAgentsMd(cwd) || undefined;
2605
+ // Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
2606
+ // "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
2607
+ // describeScreenshot a configured visionModel, else the main model if it's vision-capable.
2608
+ const describeImage = async (path, hint) => {
2609
+ const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2610
+ const vp = cfg.visionModel
2611
+ ? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
2612
+ : cap === "vision"
2613
+ ? provider
2614
+ : null;
2615
+ if (!vp)
2616
+ return "";
2617
+ try {
2618
+ return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
2619
+ }
2620
+ catch {
2621
+ return "";
2622
+ }
2399
2623
  };
2400
- // Apply per-session pinned model on headless resume (mirrors the interactive path).
2401
- // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2402
- if (prior) {
2403
- if (opts.model) {
2404
- meta.model = cfg.model;
2624
+ // Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
2625
+ // saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
2626
+ // Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
2627
+ let meta = null;
2628
+ const history = [];
2629
+ if (opts.resume || opts.continue) {
2630
+ const resumeArg = opts.resume ? String(opts.resume) : undefined;
2631
+ if (resumeArg && !validSessionId(resumeArg)) {
2632
+ process.stderr.write("hara: --resume contains an invalid session id. Use an id shown by `hara sessions`.\n");
2633
+ process.exitCode = 2;
2634
+ return;
2405
2635
  }
2406
- else if (meta.model && meta.model !== cfg.model) {
2407
- const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
2408
- if (__allowed && !__activeP.availableModels.includes(meta.model)) {
2409
- const __fb = __activeP.defaultModel || cfg.model;
2410
- // headless: log to stderr so it doesn't pollute the captured stdout reply
2411
- try {
2412
- process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
2636
+ const resolvedResume = resumeArg ? resolveSessionId(resumeArg) : null;
2637
+ if (resumeArg && !resolvedResume) {
2638
+ const prefixMatches = listSessions().filter((session) => session.id.startsWith(resumeArg));
2639
+ if (prefixMatches.length > 1) {
2640
+ process.stderr.write(`hara: session prefix '${resumeArg}' is ambiguous; use more characters.\n`);
2641
+ process.exitCode = 2;
2642
+ return;
2643
+ }
2644
+ }
2645
+ const rid = (resumeArg ? (resolvedResume ?? resumeArg) : latestForCwd(cwd)?.meta.id) ?? newSessionId();
2646
+ const lock = acquireSessionLock(rid);
2647
+ if (!lock.ok) {
2648
+ process.stderr.write(`hara: session ${shortId(rid)} is already open in another process (pid ${lock.pid ?? "unknown"}); refusing a concurrent headless resume.\n`);
2649
+ process.exitCode = 1;
2650
+ await closeMcp();
2651
+ return;
2652
+ }
2653
+ headlessLockId = rid;
2654
+ // Re-read only after acquiring the single-writer lock. Loading first would leave a race window where
2655
+ // another gateway/cron process appends history that this stale snapshot later overwrites.
2656
+ const prior = loadSession(rid);
2657
+ if (sessionFileExists(rid) && !prior) {
2658
+ process.stderr.write(`hara: session ${shortId(rid)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${rid}.json.\n`);
2659
+ process.exitCode = 2;
2660
+ return;
2661
+ }
2662
+ if (prior && canonicalProjectPath(prior.meta.cwd) !== canonicalProjectPath(cwd)) {
2663
+ process.stderr.write(`hara: session ${shortId(rid)} belongs to ${prior.meta.cwd}, but this run is rooted at ${cwd}; refusing to resume across project homes. Run hara from the session's project directory instead.\n`);
2664
+ process.exitCode = 2;
2665
+ return;
2666
+ }
2667
+ if (prior && requestedHeadlessAgent?.project) {
2668
+ const priorHome = canonicalProjectPath(prior.meta.cwd);
2669
+ if (priorHome !== requestedHeadlessAgent.home) {
2670
+ process.stderr.write(`hara: session ${shortId(rid)} belongs to ${prior.meta.cwd}; refusing to resume it as ${requestedHeadlessAgent.project}:${requestedHeadlessAgent.name} at ${requestedHeadlessAgent.home}. Start a new role session or resume one from that home.\n`);
2671
+ process.exitCode = 2;
2672
+ return;
2673
+ }
2674
+ }
2675
+ if (prior?.history)
2676
+ history.push(...prior.history);
2677
+ // Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
2678
+ // automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
2679
+ const src = sessionSourceFromEnv();
2680
+ meta = prior?.meta ?? {
2681
+ id: rid,
2682
+ cwd,
2683
+ provider: cfg.provider,
2684
+ model: cfg.model,
2685
+ title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
2686
+ createdAt: new Date().toISOString(),
2687
+ updatedAt: "",
2688
+ ...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
2689
+ };
2690
+ // Task-state continuity: restore the persisted checklist, then mirror every change back onto meta
2691
+ // live — all existing saveSession() sites persist it for free (no per-site threading).
2692
+ restoreTodos(meta.todos);
2693
+ onTodosChange((list) => {
2694
+ if (meta)
2695
+ meta.todos = [...list];
2696
+ });
2697
+ // Apply per-session pinned model on headless resume (mirrors the interactive path).
2698
+ // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
2699
+ if (prior) {
2700
+ if (opts.model) {
2701
+ meta.model = cfg.model;
2702
+ }
2703
+ else if (meta.model && meta.model !== cfg.model) {
2704
+ const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
2705
+ if (__allowed && !__activeP.availableModels.includes(meta.model)) {
2706
+ const __fb = __activeP.defaultModel || cfg.model;
2707
+ // headless: log to stderr so it doesn't pollute the captured stdout reply
2708
+ try {
2709
+ process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
2710
+ }
2711
+ catch { /* ignore */ }
2712
+ cfg.model = __fb;
2713
+ meta.model = __fb;
2714
+ const __rb = await buildProvider(cfg);
2715
+ if (__rb)
2716
+ provider = __rb;
2717
+ }
2718
+ else {
2719
+ cfg.model = meta.model;
2720
+ const __rb = await buildProvider(cfg);
2721
+ if (__rb)
2722
+ provider = __rb;
2413
2723
  }
2414
- catch { /* ignore */ }
2415
- cfg.model = __fb;
2416
- meta.model = __fb;
2417
- const __rb = await buildProvider(cfg);
2418
- if (__rb)
2419
- provider = __rb;
2420
2724
  }
2421
- else {
2422
- cfg.model = meta.model;
2423
- const __rb = await buildProvider(cfg);
2424
- if (__rb)
2425
- provider = __rb;
2725
+ }
2726
+ }
2727
+ // --schema: schema-enforced structured output. The schema (inline JSON or a file path) becomes a run-scoped
2728
+ // structured_output tool the model MUST call; stdout is then exactly that JSON (streaming suppressed), so
2729
+ // scripts / gateway flows / cron parse a guaranteed shape instead of regex-fishing prose.
2730
+ let schemaObj = null;
2731
+ if (opts.schema) {
2732
+ const rawArg = String(opts.schema);
2733
+ const raw = existsSync(rawArg) ? readFileSync(rawArg, "utf8") : rawArg;
2734
+ const parsed = parseSchemaArg(raw);
2735
+ if ("error" in parsed) {
2736
+ process.stderr.write(`hara: ${parsed.error}\n`);
2737
+ process.exitCode = 2;
2738
+ await closeMcp();
2739
+ return;
2740
+ }
2741
+ schemaObj = parsed;
2742
+ }
2743
+ // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
2744
+ // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
2745
+ // visionModel sidecar and folded into the message (text-only models can't take image blocks).
2746
+ const userText = expandMentions(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
2747
+ const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
2748
+ .split("\n")
2749
+ .map((s) => s.trim())
2750
+ .filter((p) => p && existsSync(p))
2751
+ .map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
2752
+ if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
2753
+ history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
2754
+ }
2755
+ else if (inboundImgs.length && cfg.visionModel) {
2756
+ let desc = "";
2757
+ try {
2758
+ const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
2759
+ if (vp)
2760
+ desc = await describeImages(vp, inboundImgs);
2761
+ }
2762
+ catch {
2763
+ /* describe is best-effort — fall back to the marker-only text */
2764
+ }
2765
+ const n = inboundImgs.length;
2766
+ history.push({
2767
+ role: "user",
2768
+ content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
2769
+ });
2770
+ }
2771
+ else {
2772
+ history.push({ role: "user", content: userText });
2773
+ }
2774
+ // --role: run this headless turn AS an org role/agent persona (the gateway's /agent switch lands here).
2775
+ // Local roles resolve at cwd; qualified project agents were resolved before config/provider startup and
2776
+ // cwd is already their registered home. Explicit global roles remain portable in the current project.
2777
+ let roleOverride;
2778
+ let headlessProvider = provider;
2779
+ let headlessToolFilter;
2780
+ let headlessHooks = true;
2781
+ if (requestedHeadlessRole) {
2782
+ roleOverride = requestedHeadlessRole.system;
2783
+ headlessToolFilter = roleToolFilter(requestedHeadlessRole);
2784
+ if (requestedHeadlessRole.readOnly)
2785
+ headlessHooks = false;
2786
+ const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
2787
+ if (roleModel && roleModel !== provider.model) {
2788
+ const selected = await buildProvider({ ...cfg, model: roleModel });
2789
+ if (!selected) {
2790
+ process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
2791
+ process.exitCode = 2;
2792
+ await closeMcp();
2793
+ return;
2426
2794
  }
2795
+ headlessProvider = selected;
2427
2796
  }
2428
2797
  }
2429
- }
2430
- // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
2431
- // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
2432
- // visionModel sidecar and folded into the message (text-only models can't take image blocks).
2433
- const userText = expandMentions(String(opts.print), cwd);
2434
- const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
2435
- .split("\n")
2436
- .map((s) => s.trim())
2437
- .filter((p) => p && existsSync(p))
2438
- .map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
2439
- if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
2440
- history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
2441
- }
2442
- else if (inboundImgs.length && cfg.visionModel) {
2443
- let desc = "";
2444
- try {
2445
- const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
2446
- if (vp)
2447
- desc = await describeImages(vp, inboundImgs);
2798
+ let structured;
2799
+ let structuredSet = false;
2800
+ const printRunOpts = {
2801
+ provider: headlessProvider,
2802
+ ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role), describeImage },
2803
+ approval: "full-auto",
2804
+ confirm: async () => true,
2805
+ projectContext,
2806
+ memory: memoryDigest(cwd),
2807
+ ...(roleOverride ? { systemOverride: roleOverride } : {}),
2808
+ ...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
2809
+ hooks: headlessHooks,
2810
+ stats,
2811
+ guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
2812
+ ...(schemaObj
2813
+ ? {
2814
+ extraTools: [structuredOutputTool(schemaObj, (v) => ((structured = v), (structuredSet = true)))],
2815
+ quiet: true, // stdout must be exactly the JSON — no streamed prose
2816
+ }
2817
+ : {}),
2818
+ };
2819
+ let runOutcome = await runAgent(history, printRunOpts);
2820
+ if (schemaObj) {
2821
+ // The tool call IS the answer — if the model finished without it, nudge and retry (bounded).
2822
+ for (let attempt = 0; attempt < 2 && !structuredSet && runOutcome.status === "completed"; attempt++) {
2823
+ history.push({ role: "user", content: STRUCTURED_NUDGE });
2824
+ runOutcome = await runAgent(history, printRunOpts);
2825
+ }
2826
+ const failure = runFailureDetail(runOutcome);
2827
+ if (failure) {
2828
+ // A valid structured_output call is provisional until the whole agent run completes. A later provider
2829
+ // error or safety halt must never be hidden behind stale-looking success JSON on stdout.
2830
+ process.stderr.write(`hara: structured run failed (${runOutcome.status}) — ${failure}\n`);
2831
+ process.exitCode = 2;
2832
+ }
2833
+ else if (structuredSet)
2834
+ out(JSON.stringify(structured) + "\n");
2835
+ else {
2836
+ process.stderr.write("hara: model never called structured_output — no result.\n");
2837
+ process.exitCode = 2;
2838
+ }
2448
2839
  }
2449
- catch {
2450
- /* describe is best-effort — fall back to the marker-only text */
2840
+ else {
2841
+ const failure = runFailureDetail(runOutcome);
2842
+ if (failure) {
2843
+ process.stderr.write(`hara: headless run failed (${runOutcome.status}) — ${failure}\n`);
2844
+ process.exitCode = 2;
2845
+ }
2451
2846
  }
2452
- const n = inboundImgs.length;
2453
- history.push({
2454
- role: "user",
2455
- content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
2456
- });
2847
+ if (meta) {
2848
+ // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
2849
+ // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
2850
+ await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
2851
+ saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
2852
+ }
2853
+ if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
2854
+ out(statusLine(headlessProvider.model, stats.input, stats.output) + "\n");
2855
+ await closeMcp();
2856
+ return;
2457
2857
  }
2458
- else {
2459
- history.push({ role: "user", content: userText });
2858
+ finally {
2859
+ if (headlessLockId)
2860
+ releaseSessionLock(headlessLockId);
2861
+ await closeMcp();
2460
2862
  }
2461
- await runAgent(history, {
2462
- provider,
2463
- ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
2464
- approval: "full-auto",
2465
- confirm: async () => true,
2466
- projectContext,
2467
- memory: memoryDigest(cwd),
2468
- stats,
2469
- guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
2470
- });
2471
- if (meta) {
2472
- // Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
2473
- // Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
2474
- await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
2475
- saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
2476
- }
2477
- if (stats.input || stats.output)
2478
- out(statusLine(cfg.model, stats.input, stats.output) + "\n");
2479
- await closeMcp();
2480
- return;
2481
2863
  }
2482
2864
  // interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
2483
2865
  const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
@@ -2556,20 +2938,43 @@ program.action(async (opts) => {
2556
2938
  let projectContext = loadAgentsMd(cwd) || undefined;
2557
2939
  const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
2558
2940
  // session: --resume <id> / --continue (latest in this cwd) / new
2559
- let resumed = null;
2941
+ let resumeId = null;
2560
2942
  if (opts.resume) {
2561
2943
  const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
2562
- resumed = rid ? loadSession(rid) : null;
2563
- if (!resumed)
2944
+ resumeId = rid;
2945
+ if (!resumeId)
2564
2946
  out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
2565
2947
  }
2566
2948
  else if (opts.continue) {
2567
- resumed = latestForCwd(cwd);
2568
- if (!resumed)
2949
+ resumeId = latestForCwd(cwd)?.meta.id ?? null;
2950
+ if (!resumeId)
2569
2951
  out(c.dim("(no prior session in this directory; starting fresh)\n"));
2570
2952
  }
2953
+ // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2954
+ // corrupt it. Acquire BEFORE reading the history, then re-read under the lock so no stale snapshot can
2955
+ // overwrite a turn appended between load and lock.
2956
+ const sessionId = resumeId ?? newSessionId();
2957
+ const lock = acquireSessionLock(sessionId);
2958
+ if (!lock.ok) {
2959
+ out(c.red(`Session ${shortId(sessionId)} is already open in another hara process (pid ${lock.pid}).`) +
2960
+ c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${sessionId}.lock)\n`));
2961
+ process.exit(1);
2962
+ }
2963
+ process.on("exit", () => releaseSessionLock(sessionId));
2964
+ const resumed = resumeId ? loadSession(resumeId) : null;
2965
+ if (resumeId && sessionFileExists(resumeId) && !resumed) {
2966
+ releaseSessionLock(sessionId);
2967
+ out(c.red(`Session ${shortId(resumeId)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${resumeId}.json.\n`));
2968
+ process.exit(2);
2969
+ }
2970
+ if (resumed && canonicalProjectPath(resumed.meta.cwd) !== canonicalProjectPath(cwd)) {
2971
+ releaseSessionLock(sessionId);
2972
+ out(c.red(`Session ${shortId(resumed.meta.id)} belongs to ${resumed.meta.cwd}, but this run is rooted at ${cwd}; refusing to resume across project homes.\n`) +
2973
+ c.dim(`Run hara from the session's project directory instead.\n`));
2974
+ process.exit(2);
2975
+ }
2571
2976
  const meta = resumed?.meta ?? {
2572
- id: newSessionId(),
2977
+ id: sessionId,
2573
2978
  cwd,
2574
2979
  provider: cfg.provider,
2575
2980
  model: cfg.model,
@@ -2578,16 +2983,11 @@ program.action(async (opts) => {
2578
2983
  updatedAt: "",
2579
2984
  source: "interactive",
2580
2985
  };
2581
- // Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
2582
- // corrupt it. Lock it here (a brand-new session's id is unique, so this only ever blocks a DOUBLE-resume
2583
- // of a session already open elsewhere). Released on exit.
2584
- const lock = acquireSessionLock(meta.id);
2585
- if (!lock.ok) {
2586
- out(c.red(`Session ${shortId(meta.id)} is already open in another hara process (pid ${lock.pid}).`) +
2587
- c.dim(` Resuming the same session twice races writes and can corrupt its history. Close that one, or run \`hara\` for a new session. (Override: rm ~/.hara/sessions/${meta.id}.lock)\n`));
2588
- process.exit(1);
2589
- }
2590
- process.on("exit", () => releaseSessionLock(meta.id));
2986
+ // Task-state continuity (interactive twin of the -p path): restore the checklist, mirror changes onto meta.
2987
+ restoreTodos(meta.todos);
2988
+ onTodosChange((list) => {
2989
+ meta.todos = [...list];
2990
+ });
2591
2991
  // Per-session model precedence on resume:
2592
2992
  // 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
2593
2993
  // 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
@@ -2917,7 +3317,22 @@ program.action(async (opts) => {
2917
3317
  out(c.green(`(renamed → ${meta.title})\n`));
2918
3318
  },
2919
3319
  },
2920
- { name: "reset", aliases: ["clear"], desc: "clear conversation context", run: () => void ((history.length = 0), (recalledContext = ""), out(c.dim("(context cleared)\n"))) },
3320
+ {
3321
+ name: "reset",
3322
+ aliases: ["clear"],
3323
+ desc: "clear conversation context",
3324
+ run: () => {
3325
+ history.length = 0;
3326
+ recalledContext = "";
3327
+ clearTodos();
3328
+ meta.todos = [];
3329
+ resetReachability();
3330
+ resetRepeatGuard();
3331
+ clearTouched();
3332
+ saveSession(meta, history);
3333
+ out(c.dim("(context cleared)\n"));
3334
+ },
3335
+ },
2921
3336
  { name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
2922
3337
  ];
2923
3338
  const byName = new Map();
@@ -3117,6 +3532,10 @@ program.action(async (opts) => {
3117
3532
  recalledContext = "";
3118
3533
  resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
3119
3534
  resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
3535
+ clearTouched();
3536
+ clearTodos();
3537
+ meta.todos = [];
3538
+ saveSession(meta, history);
3120
3539
  return void h.sink.notice("(context cleared)");
3121
3540
  }
3122
3541
  if (nm === "undo") {
@@ -3312,6 +3731,7 @@ program.action(async (opts) => {
3312
3731
  approval: "full-auto", // read-only via the tool filter, so nothing prompts
3313
3732
  confirm: h.confirm,
3314
3733
  toolFilter: (n) => READONLY_TOOLS.has(n),
3734
+ hooks: false,
3315
3735
  systemOverride: REVIEW_SYSTEM,
3316
3736
  memory: buildMemory(),
3317
3737
  stats,
@@ -3410,6 +3830,7 @@ program.action(async (opts) => {
3410
3830
  approval: "suggest",
3411
3831
  confirm: h.confirm,
3412
3832
  toolFilter: (n) => READONLY_TOOLS.has(n),
3833
+ hooks: false,
3413
3834
  extraTools: [exitPlanTool],
3414
3835
  systemOverride: PLAN_SYSTEM,
3415
3836
  memory: buildMemory(),