@nanhara/hara 0.121.1 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +40 -6
- package/dist/agent/loop.js +136 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +33 -23
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +631 -222
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +84 -9
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +30 -28
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- 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 {
|
|
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
|
|
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 = () =>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 &&
|
|
825
|
-
|
|
826
|
-
|
|
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
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
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,
|
|
984
|
-
|
|
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:
|
|
1114
|
+
cwd: orgCwd,
|
|
995
1115
|
sandbox: cfg.sandbox,
|
|
996
1116
|
approval: "full-auto",
|
|
997
1117
|
confirm: async () => true,
|
|
998
|
-
projectContext: loadAgentsMd(
|
|
1118
|
+
projectContext: loadAgentsMd(orgCwd) || undefined,
|
|
999
1119
|
stats,
|
|
1000
|
-
forceRole
|
|
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
|
});
|
|
@@ -1577,21 +1740,37 @@ program
|
|
|
1577
1740
|
// `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
|
|
1578
1741
|
// new/resumed session and model operation so a repaired/rotated key takes effect without restarting
|
|
1579
1742
|
// the desktop server (and, critically, never ask for a key that is already on disk).
|
|
1580
|
-
buildSessionProvider: async () => {
|
|
1581
|
-
const live = loadConfig();
|
|
1743
|
+
buildSessionProvider: async (targetCwd) => {
|
|
1744
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1582
1745
|
return withRouting(await buildProvider(live), live);
|
|
1583
1746
|
},
|
|
1584
|
-
buildProviderFor: async (model, effort) => {
|
|
1585
|
-
const live = loadConfig();
|
|
1747
|
+
buildProviderFor: async (model, effort, targetCwd) => {
|
|
1748
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1586
1749
|
return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
|
|
1587
1750
|
},
|
|
1588
|
-
listModels: () => {
|
|
1589
|
-
const live = loadConfig();
|
|
1751
|
+
listModels: (targetCwd) => {
|
|
1752
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1590
1753
|
return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
|
|
1591
1754
|
},
|
|
1592
1755
|
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1593
|
-
|
|
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
|
+
},
|
|
1594
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
|
+
},
|
|
1595
1774
|
sandbox,
|
|
1596
1775
|
approval,
|
|
1597
1776
|
});
|
|
@@ -1736,6 +1915,7 @@ program
|
|
|
1736
1915
|
confirm: async () => true,
|
|
1737
1916
|
systemOverride: REVIEW_SYSTEM,
|
|
1738
1917
|
toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
|
|
1918
|
+
hooks: false,
|
|
1739
1919
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
1740
1920
|
memory: memoryDigest(cfg.cwd),
|
|
1741
1921
|
stats,
|
|
@@ -2286,7 +2466,48 @@ program.action(async (opts) => {
|
|
|
2286
2466
|
// hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
|
|
2287
2467
|
// pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
|
|
2288
2468
|
// in the priority chain than --profile).
|
|
2289
|
-
|
|
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;
|
|
2290
2511
|
if (opts.model)
|
|
2291
2512
|
cfg.model = opts.model;
|
|
2292
2513
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
@@ -2331,7 +2552,11 @@ program.action(async (opts) => {
|
|
|
2331
2552
|
process.exit(0);
|
|
2332
2553
|
}
|
|
2333
2554
|
}
|
|
2334
|
-
|
|
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");
|
|
2335
2560
|
process.exit(1);
|
|
2336
2561
|
}
|
|
2337
2562
|
let provider = provider0;
|
|
@@ -2350,146 +2575,291 @@ program.action(async (opts) => {
|
|
|
2350
2575
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
2351
2576
|
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
2352
2577
|
}
|
|
2353
|
-
const cwd = cfg.cwd;
|
|
2354
2578
|
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
2355
2579
|
let currentTurn = null; // set during a running turn so Esc can abort it
|
|
2356
2580
|
const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
|
|
2357
2581
|
let recalledContext = ""; // snippets queued by /recall, prepended to the next message
|
|
2358
2582
|
const sandbox = opts.sandbox || cfg.sandbox;
|
|
2359
2583
|
if (sandbox !== "off" && !sandboxSupported()) {
|
|
2360
|
-
|
|
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));
|
|
2361
2589
|
}
|
|
2362
2590
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
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
|
+
}
|
|
2366
2599
|
}
|
|
2367
2600
|
// one-shot
|
|
2368
2601
|
if (opts.print) {
|
|
2369
|
-
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
const
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
?
|
|
2379
|
-
:
|
|
2380
|
-
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
// saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
|
|
2391
|
-
// Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
|
|
2392
|
-
let meta = null;
|
|
2393
|
-
const history = [];
|
|
2394
|
-
if (opts.resume || opts.continue) {
|
|
2395
|
-
const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
|
|
2396
|
-
const prior = rid ? loadSession(rid) : null;
|
|
2397
|
-
if (prior?.history)
|
|
2398
|
-
history.push(...prior.history);
|
|
2399
|
-
// Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
|
|
2400
|
-
// automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
|
|
2401
|
-
const src = sessionSourceFromEnv();
|
|
2402
|
-
meta = prior?.meta ?? {
|
|
2403
|
-
id: rid ?? newSessionId(),
|
|
2404
|
-
cwd,
|
|
2405
|
-
provider: cfg.provider,
|
|
2406
|
-
model: cfg.model,
|
|
2407
|
-
title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
|
|
2408
|
-
createdAt: new Date().toISOString(),
|
|
2409
|
-
updatedAt: "",
|
|
2410
|
-
...(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
|
+
}
|
|
2411
2623
|
};
|
|
2412
|
-
//
|
|
2413
|
-
// --
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
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;
|
|
2417
2635
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
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;
|
|
2425
2723
|
}
|
|
2426
|
-
catch { /* ignore */ }
|
|
2427
|
-
cfg.model = __fb;
|
|
2428
|
-
meta.model = __fb;
|
|
2429
|
-
const __rb = await buildProvider(cfg);
|
|
2430
|
-
if (__rb)
|
|
2431
|
-
provider = __rb;
|
|
2432
2724
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
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;
|
|
2438
2794
|
}
|
|
2795
|
+
headlessProvider = selected;
|
|
2439
2796
|
}
|
|
2440
2797
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
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
|
+
}
|
|
2460
2839
|
}
|
|
2461
|
-
|
|
2462
|
-
|
|
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
|
+
}
|
|
2463
2846
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
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;
|
|
2469
2857
|
}
|
|
2470
|
-
|
|
2471
|
-
|
|
2858
|
+
finally {
|
|
2859
|
+
if (headlessLockId)
|
|
2860
|
+
releaseSessionLock(headlessLockId);
|
|
2861
|
+
await closeMcp();
|
|
2472
2862
|
}
|
|
2473
|
-
await runAgent(history, {
|
|
2474
|
-
provider,
|
|
2475
|
-
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
|
|
2476
|
-
approval: "full-auto",
|
|
2477
|
-
confirm: async () => true,
|
|
2478
|
-
projectContext,
|
|
2479
|
-
memory: memoryDigest(cwd),
|
|
2480
|
-
stats,
|
|
2481
|
-
guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
|
|
2482
|
-
});
|
|
2483
|
-
if (meta) {
|
|
2484
|
-
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
2485
|
-
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
2486
|
-
await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
|
|
2487
|
-
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
2488
|
-
}
|
|
2489
|
-
if (stats.input || stats.output)
|
|
2490
|
-
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
2491
|
-
await closeMcp();
|
|
2492
|
-
return;
|
|
2493
2863
|
}
|
|
2494
2864
|
// interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
|
|
2495
2865
|
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
@@ -2568,20 +2938,43 @@ program.action(async (opts) => {
|
|
|
2568
2938
|
let projectContext = loadAgentsMd(cwd) || undefined;
|
|
2569
2939
|
const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
|
|
2570
2940
|
// session: --resume <id> / --continue (latest in this cwd) / new
|
|
2571
|
-
let
|
|
2941
|
+
let resumeId = null;
|
|
2572
2942
|
if (opts.resume) {
|
|
2573
2943
|
const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
|
|
2574
|
-
|
|
2575
|
-
if (!
|
|
2944
|
+
resumeId = rid;
|
|
2945
|
+
if (!resumeId)
|
|
2576
2946
|
out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
|
|
2577
2947
|
}
|
|
2578
2948
|
else if (opts.continue) {
|
|
2579
|
-
|
|
2580
|
-
if (!
|
|
2949
|
+
resumeId = latestForCwd(cwd)?.meta.id ?? null;
|
|
2950
|
+
if (!resumeId)
|
|
2581
2951
|
out(c.dim("(no prior session in this directory; starting fresh)\n"));
|
|
2582
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
|
+
}
|
|
2583
2976
|
const meta = resumed?.meta ?? {
|
|
2584
|
-
id:
|
|
2977
|
+
id: sessionId,
|
|
2585
2978
|
cwd,
|
|
2586
2979
|
provider: cfg.provider,
|
|
2587
2980
|
model: cfg.model,
|
|
@@ -2590,16 +2983,11 @@ program.action(async (opts) => {
|
|
|
2590
2983
|
updatedAt: "",
|
|
2591
2984
|
source: "interactive",
|
|
2592
2985
|
};
|
|
2593
|
-
//
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
out(c.red(`Session ${shortId(meta.id)} is already open in another hara process (pid ${lock.pid}).`) +
|
|
2599
|
-
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`));
|
|
2600
|
-
process.exit(1);
|
|
2601
|
-
}
|
|
2602
|
-
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
|
+
});
|
|
2603
2991
|
// Per-session model precedence on resume:
|
|
2604
2992
|
// 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
|
|
2605
2993
|
// 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
|
|
@@ -2929,7 +3317,22 @@ program.action(async (opts) => {
|
|
|
2929
3317
|
out(c.green(`(renamed → ${meta.title})\n`));
|
|
2930
3318
|
},
|
|
2931
3319
|
},
|
|
2932
|
-
{
|
|
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
|
+
},
|
|
2933
3336
|
{ name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
|
|
2934
3337
|
];
|
|
2935
3338
|
const byName = new Map();
|
|
@@ -3129,6 +3532,10 @@ program.action(async (opts) => {
|
|
|
3129
3532
|
recalledContext = "";
|
|
3130
3533
|
resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
|
|
3131
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);
|
|
3132
3539
|
return void h.sink.notice("(context cleared)");
|
|
3133
3540
|
}
|
|
3134
3541
|
if (nm === "undo") {
|
|
@@ -3324,6 +3731,7 @@ program.action(async (opts) => {
|
|
|
3324
3731
|
approval: "full-auto", // read-only via the tool filter, so nothing prompts
|
|
3325
3732
|
confirm: h.confirm,
|
|
3326
3733
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
3734
|
+
hooks: false,
|
|
3327
3735
|
systemOverride: REVIEW_SYSTEM,
|
|
3328
3736
|
memory: buildMemory(),
|
|
3329
3737
|
stats,
|
|
@@ -3422,6 +3830,7 @@ program.action(async (opts) => {
|
|
|
3422
3830
|
approval: "suggest",
|
|
3423
3831
|
confirm: h.confirm,
|
|
3424
3832
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
3833
|
+
hooks: false,
|
|
3425
3834
|
extraTools: [exitPlanTool],
|
|
3426
3835
|
systemOverride: PLAN_SYSTEM,
|
|
3427
3836
|
memory: buildMemory(),
|