@nanhara/hara 0.121.1 → 0.122.1
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 +120 -0
- package/README.md +57 -10
- package/SECURITY.md +48 -9
- package/dist/agent/loop.js +169 -31
- 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 +24 -6
- package/dist/checkpoints.js +103 -17
- package/dist/cli.js +16 -0
- package/dist/config.js +173 -34
- package/dist/context/agents-md.js +44 -9
- package/dist/context/mentions.js +10 -4
- package/dist/context/subdir-hints.js +40 -7
- package/dist/cron/deliver.js +37 -3
- package/dist/cron/runner.js +372 -37
- package/dist/cron/store.js +11 -3
- package/dist/exec/jobs.js +88 -20
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +421 -12
- package/dist/fs-walk.js +8 -2
- package/dist/fs-write.js +433 -21
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -20
- package/dist/gateway/feishu.js +157 -58
- package/dist/gateway/flows-pending.js +727 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +81 -18
- package/dist/gateway/mattermost.js +44 -34
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/outbound-files.js +379 -0
- package/dist/gateway/serve.js +712 -169
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +31 -28
- package/dist/gateway/slack.js +28 -21
- package/dist/gateway/telegram.js +33 -21
- package/dist/gateway/tmux-routes.js +11 -3
- package/dist/gateway/wecom.js +38 -31
- package/dist/gateway/weixin.js +147 -59
- package/dist/hooks.js +41 -23
- package/dist/index.js +763 -273
- package/dist/mcp/client.js +164 -12
- package/dist/memory/store.js +68 -22
- package/dist/org/planner.js +36 -10
- package/dist/org/projects.js +347 -0
- package/dist/org/review-chain.js +360 -24
- package/dist/org/roles.js +42 -13
- package/dist/profile/profile.js +152 -27
- package/dist/recall.js +4 -2
- package/dist/runtime.js +37 -0
- package/dist/sandbox.js +142 -33
- package/dist/search/semindex.js +182 -53
- package/dist/search/zvec-store.js +121 -42
- package/dist/security/permissions.js +326 -19
- package/dist/security/private-state.js +299 -0
- package/dist/security/project-trust.js +6 -0
- package/dist/security/secrets.js +84 -9
- package/dist/security/sensitive-files.js +723 -0
- package/dist/security/subprocess-env.js +210 -0
- package/dist/serve/server.js +774 -318
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/skills/skills.js +16 -7
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +77 -49
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/cron.js +6 -0
- package/dist/tools/edit.js +22 -9
- package/dist/tools/external_agent.js +110 -16
- package/dist/tools/memory.js +38 -8
- package/dist/tools/patch.js +253 -34
- package/dist/tools/search.js +543 -73
- package/dist/tools/send.js +11 -5
- 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 +11 -10
- package/runtime-bootstrap.cjs +72 -0
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,14 +27,14 @@ 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";
|
|
33
35
|
import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
|
|
34
36
|
import { mapLimit, maxParallel } from "./concurrency.js";
|
|
35
|
-
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
37
|
+
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence, commitMessageInput, protectedStagedPaths, protectedTrackedWorkingTreePaths, protectedWorkingTreePaths, } from "./org/review-chain.js";
|
|
36
38
|
import { parseSchedule, describeSchedule, nextRun, validTz } from "./cron/schedule.js";
|
|
37
39
|
import { parseDeliver } from "./cron/deliver.js";
|
|
38
40
|
import { addJob, removeJob, setEnabled, resolveJob, loadJobs, recordRun, logPath } from "./cron/store.js";
|
|
@@ -48,6 +50,8 @@ import { resolvePlatform } from "./providers/registry.js";
|
|
|
48
50
|
import { levelsFor } from "./tui/model-picker.js";
|
|
49
51
|
import { listModels } from "./providers/models.js";
|
|
50
52
|
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
53
|
+
import { readModelContextFileSync } from "./fs-read.js";
|
|
54
|
+
import { MIN_NODE_VERSION, unsupportedNodeMessage } from "./runtime.js";
|
|
51
55
|
/** Render the background-job list for /jobs (user-facing view of what the agent has running in the
|
|
52
56
|
* background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
|
|
53
57
|
function renderBgJobs() {
|
|
@@ -68,9 +72,10 @@ import { getEmbedder } from "./search/embed.js";
|
|
|
68
72
|
import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists } from "./search/semindex.js";
|
|
69
73
|
import { searchHybrid } from "./search/hybrid.js";
|
|
70
74
|
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";
|
|
75
|
+
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
72
76
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
73
|
-
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
77
|
+
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
78
|
+
import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
|
|
74
79
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
75
80
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
|
|
76
81
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
@@ -92,6 +97,8 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
|
|
|
92
97
|
import "./tools/skill.js"; // register the skill loader tool
|
|
93
98
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
94
99
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
100
|
+
import { clearTodos, disposeTodoScope, restoreTodos, onTodosChange } from "./tools/todo.js"; // scoped session todo persistence
|
|
101
|
+
import "./tools/task.js"; // register task (project-level durable task pool)
|
|
95
102
|
import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
|
|
96
103
|
import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
|
|
97
104
|
import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
|
|
@@ -390,24 +397,31 @@ async function runInit(provider, cwd, sandbox = "off") {
|
|
|
390
397
|
/** Stage everything and commit with an AI-written message. Returns a one-line summary or "error: …".
|
|
391
398
|
* Used by `hara org --commit`; the caller guards on a clean start tree so this only captures the run's work. */
|
|
392
399
|
async function autoCommit(provider, cwd) {
|
|
400
|
+
const before = protectedWorkingTreePaths(cwd);
|
|
401
|
+
if (before.length)
|
|
402
|
+
return `error: refusing to stage protected path(s): ${before.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
393
403
|
try {
|
|
394
404
|
await runShell("git add -A", cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
395
405
|
}
|
|
396
406
|
catch {
|
|
397
407
|
/* fall through — empty diff is reported below */
|
|
398
408
|
}
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
409
|
+
const protectedAfterStage = protectedStagedPaths(cwd);
|
|
410
|
+
if (protectedAfterStage.length) {
|
|
411
|
+
return `error: refusing to inspect or commit protected staged path(s): ${protectedAfterStage.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
402
412
|
}
|
|
403
|
-
|
|
404
|
-
|
|
413
|
+
const staged = captureChanges(cwd, 120_000, { staged: true, includeUntracked: false });
|
|
414
|
+
if (staged.error)
|
|
415
|
+
return `error: git diff failed closed (${staged.error})`;
|
|
416
|
+
if (staged.skippedFiles.length) {
|
|
417
|
+
return `error: refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
405
418
|
}
|
|
406
|
-
|
|
419
|
+
const changeInput = commitMessageInput(staged);
|
|
420
|
+
if (!changeInput.trim())
|
|
407
421
|
return "nothing to commit";
|
|
408
422
|
const r = await provider.turn({
|
|
409
423
|
system: COMMIT_SYSTEM,
|
|
410
|
-
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n
|
|
424
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
|
|
411
425
|
tools: [],
|
|
412
426
|
onText: () => { },
|
|
413
427
|
});
|
|
@@ -417,6 +431,10 @@ async function autoCommit(provider, cwd) {
|
|
|
417
431
|
const tmp = join(tmpdir(), `hara-org-commit-${process.pid}.txt`);
|
|
418
432
|
writeFileSync(tmp, msg + "\n", "utf8");
|
|
419
433
|
try {
|
|
434
|
+
const protectedBeforeCommit = protectedStagedPaths(cwd);
|
|
435
|
+
if (protectedBeforeCommit.length) {
|
|
436
|
+
return `error: staged paths changed while writing the message; protected path(s) will not be committed: ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}`;
|
|
437
|
+
}
|
|
420
438
|
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
421
439
|
return (res.stdout || "").trim().split("\n")[0] || "committed";
|
|
422
440
|
}
|
|
@@ -443,18 +461,29 @@ async function commitStep(provider, cwd) {
|
|
|
443
461
|
out(c.green(`✓ committed · ${r.slice(0, 100)}\n`));
|
|
444
462
|
}
|
|
445
463
|
/** Dispatch a task to the owning role and run that role's agent (its persona + tool subset + model). */
|
|
464
|
+
function runFailureDetail(outcome) {
|
|
465
|
+
if (outcome.status === "completed")
|
|
466
|
+
return null;
|
|
467
|
+
if (outcome.error?.trim())
|
|
468
|
+
return outcome.error.trim();
|
|
469
|
+
if (outcome.status === "empty")
|
|
470
|
+
return "the model returned an empty response";
|
|
471
|
+
if (outcome.status === "halted")
|
|
472
|
+
return "the run was halted by safety controls";
|
|
473
|
+
return "the agent run failed";
|
|
474
|
+
}
|
|
446
475
|
async function runOrg(task, o) {
|
|
447
476
|
const roles = loadRoles(o.cwd);
|
|
448
477
|
if (!roles.length) {
|
|
449
478
|
out(c.yellow("No roles defined — run ") + c.bold("hara roles init") + c.yellow(" to scaffold some.\n"));
|
|
450
|
-
return;
|
|
479
|
+
return { status: "error", error: "no roles are defined" };
|
|
451
480
|
}
|
|
452
481
|
let role;
|
|
453
482
|
if (o.forceRole) {
|
|
454
483
|
role = roles.find((r) => r.id === o.forceRole);
|
|
455
484
|
if (!role) {
|
|
456
485
|
out(c.red(`No role '${o.forceRole}'. Available: ${roles.map((r) => r.id).join(", ")}\n`));
|
|
457
|
-
return;
|
|
486
|
+
return { status: "error", error: `no role '${o.forceRole}' is available` };
|
|
458
487
|
}
|
|
459
488
|
}
|
|
460
489
|
else {
|
|
@@ -478,23 +507,22 @@ async function runOrg(task, o) {
|
|
|
478
507
|
const roleProvider = __roleModel
|
|
479
508
|
? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
|
|
480
509
|
: o.baseProvider;
|
|
481
|
-
const toolFilter = role
|
|
482
|
-
? (n) => role.allowTools.includes(n)
|
|
483
|
-
: role.denyTools
|
|
484
|
-
? (n) => !role.denyTools.includes(n)
|
|
485
|
-
: undefined;
|
|
510
|
+
const toolFilter = roleToolFilter(role);
|
|
486
511
|
const history = [{ role: "user", content: expandMentions(task, o.cwd) }];
|
|
487
|
-
const runImplementer = () =>
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
512
|
+
const runImplementer = async () => {
|
|
513
|
+
return runAgent(history, {
|
|
514
|
+
provider: roleProvider,
|
|
515
|
+
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
516
|
+
approval: o.approval,
|
|
517
|
+
confirm: o.confirm,
|
|
518
|
+
projectContext: o.projectContext,
|
|
519
|
+
memory: memoryDigest(o.cwd),
|
|
520
|
+
stats: o.stats,
|
|
521
|
+
systemOverride: role.system,
|
|
522
|
+
toolFilter,
|
|
523
|
+
...(role.readOnly ? { hooks: false } : {}),
|
|
524
|
+
});
|
|
525
|
+
};
|
|
498
526
|
const wasClean = o.commit ? isTreeClean(o.cwd) : false; // capture BEFORE the implementer edits anything
|
|
499
527
|
const doCommit = async (ok) => {
|
|
500
528
|
if (!o.commit)
|
|
@@ -505,27 +533,34 @@ async function runOrg(task, o) {
|
|
|
505
533
|
return void out(c.yellow("(not auto-committing — the tree wasn't clean before this run; commit manually)\n"));
|
|
506
534
|
await commitStep(o.baseProvider, o.cwd);
|
|
507
535
|
};
|
|
508
|
-
await runImplementer();
|
|
536
|
+
let implementerOutcome = await runImplementer();
|
|
537
|
+
if (implementerOutcome.status !== "completed")
|
|
538
|
+
return implementerOutcome;
|
|
509
539
|
if (!o.review) {
|
|
510
540
|
await doCommit(true);
|
|
511
|
-
return;
|
|
541
|
+
return implementerOutcome;
|
|
512
542
|
}
|
|
513
543
|
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
514
544
|
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
515
545
|
const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
|
|
516
546
|
const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
|
|
517
547
|
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
518
|
-
const revTools = reviewer
|
|
548
|
+
const revTools = roleToolFilter(reviewer ? { ...reviewer, readOnly: true } : undefined) ?? ((n) => READONLY_TOOLS.has(n));
|
|
519
549
|
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
520
550
|
for (let round = 1; round <= maxRounds; round++) {
|
|
521
551
|
const changes = captureChanges(o.cwd);
|
|
522
|
-
if (
|
|
552
|
+
if (changes.error) {
|
|
553
|
+
out(c.red(`(review change capture failed closed: ${changes.error})\n`));
|
|
554
|
+
await doCommit(false);
|
|
555
|
+
return { status: "halted", error: `review change capture failed: ${changes.error}` };
|
|
556
|
+
}
|
|
557
|
+
if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
|
|
523
558
|
out(c.dim("(no changes to review)\n"));
|
|
524
|
-
return;
|
|
559
|
+
return implementerOutcome;
|
|
525
560
|
}
|
|
526
561
|
out(c.dim(`🔍 reviewer · round ${round}/${maxRounds}\n`));
|
|
527
562
|
const rHist = [{ role: "user", content: reviewPrompt(task, changes) }];
|
|
528
|
-
await runAgent(rHist, {
|
|
563
|
+
const reviewerOutcome = await runAgent(rHist, {
|
|
529
564
|
provider: revProvider,
|
|
530
565
|
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
531
566
|
approval: "full-auto", // reviewer is read-only via revTools, so nothing to confirm
|
|
@@ -535,22 +570,28 @@ async function runOrg(task, o) {
|
|
|
535
570
|
stats: o.stats,
|
|
536
571
|
systemOverride: revSystem,
|
|
537
572
|
toolFilter: revTools,
|
|
573
|
+
hooks: false,
|
|
538
574
|
});
|
|
575
|
+
if (reviewerOutcome.status !== "completed")
|
|
576
|
+
return reviewerOutcome;
|
|
539
577
|
const verdict = parseVerdict(lastAssistantText(rHist));
|
|
540
578
|
if (verdict.approved) {
|
|
541
579
|
out(c.green(`✓ reviewer approved after ${round} round(s)\n`));
|
|
542
580
|
await doCommit(true);
|
|
543
|
-
return;
|
|
581
|
+
return implementerOutcome;
|
|
544
582
|
}
|
|
545
583
|
if (round === maxRounds) {
|
|
546
584
|
out(c.yellow(`⚠ stopped after ${maxRounds} round(s) — reviewer still wants changes.\n`));
|
|
547
585
|
await doCommit(false);
|
|
548
|
-
return;
|
|
586
|
+
return { status: "halted", error: `reviewer did not approve after ${maxRounds} round(s)` };
|
|
549
587
|
}
|
|
550
588
|
out(c.yellow(`✗ changes requested — back to ${role.id} (round ${round})\n`));
|
|
551
589
|
history.push({ role: "user", content: fixPrompt(verdict.issues) });
|
|
552
|
-
await runImplementer();
|
|
590
|
+
implementerOutcome = await runImplementer();
|
|
591
|
+
if (implementerOutcome.status !== "completed")
|
|
592
|
+
return implementerOutcome;
|
|
553
593
|
}
|
|
594
|
+
return implementerOutcome;
|
|
554
595
|
}
|
|
555
596
|
function lastAssistantText(history) {
|
|
556
597
|
for (let i = history.length - 1; i >= 0; i--) {
|
|
@@ -563,18 +604,14 @@ function lastAssistantText(history) {
|
|
|
563
604
|
/** Run one atom (routed to its role if any), then gate it (its `check` command, else an LLM verify). */
|
|
564
605
|
async function executeAtom(atom, plan, done, roles, o) {
|
|
565
606
|
atom.status = "running";
|
|
566
|
-
savePlan(o.cwd, plan);
|
|
607
|
+
await savePlan(o.cwd, plan);
|
|
567
608
|
const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
|
|
568
609
|
const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
|
|
569
610
|
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;
|
|
611
|
+
const toolFilter = roleToolFilter(role);
|
|
575
612
|
const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
|
|
576
613
|
try {
|
|
577
|
-
await runAgent(history, {
|
|
614
|
+
const outcome = await runAgent(history, {
|
|
578
615
|
provider: roleProvider,
|
|
579
616
|
ctx: { cwd: o.cwd, sandbox: o.sandbox },
|
|
580
617
|
approval: o.approval,
|
|
@@ -584,20 +621,29 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
584
621
|
stats: o.stats,
|
|
585
622
|
systemOverride: role?.system,
|
|
586
623
|
toolFilter,
|
|
624
|
+
...(role?.readOnly ? { hooks: false } : {}),
|
|
587
625
|
quiet: o.parallel, // concurrent atoms would otherwise interleave their streamed output
|
|
588
626
|
});
|
|
627
|
+
const failure = runFailureDetail(outcome);
|
|
628
|
+
if (failure) {
|
|
629
|
+
atom.status = "failed";
|
|
630
|
+
atom.note = failure;
|
|
631
|
+
await savePlan(o.cwd, plan);
|
|
632
|
+
out(c.red(` ✗ ${atom.id} agent ${outcome.status}: ${failure}\n`));
|
|
633
|
+
return false;
|
|
634
|
+
}
|
|
589
635
|
}
|
|
590
636
|
catch (e) {
|
|
591
637
|
atom.status = "failed";
|
|
592
638
|
atom.note = e.message;
|
|
593
|
-
savePlan(o.cwd, plan);
|
|
639
|
+
await savePlan(o.cwd, plan);
|
|
594
640
|
out(c.red(` ✗ ${atom.id} errored: ${e.message}\n`));
|
|
595
641
|
return false;
|
|
596
642
|
}
|
|
597
643
|
const v = atom.check ? await runCheck(atom.check, o.cwd, o.sandbox) : await verify(o.baseProvider, atom, lastAssistantText(history));
|
|
598
644
|
atom.status = v.ok ? "done" : "failed";
|
|
599
645
|
atom.note = v.reason;
|
|
600
|
-
savePlan(o.cwd, plan);
|
|
646
|
+
await savePlan(o.cwd, plan);
|
|
601
647
|
out(v.ok ? c.green(` ✓ ${atom.id} verified\n`) : c.yellow(` ⚠ ${atom.id}: ${v.reason}\n`));
|
|
602
648
|
return v.ok;
|
|
603
649
|
}
|
|
@@ -606,10 +652,13 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
606
652
|
async function executePlan(plan, roles, o) {
|
|
607
653
|
const done = plan.atoms.filter((a) => a.status === "done");
|
|
608
654
|
const doneIds = new Set(done.map((a) => a.id));
|
|
655
|
+
let failed = false;
|
|
609
656
|
if (o.parallel) {
|
|
610
657
|
const waved = topoWaves(plan.atoms);
|
|
611
|
-
if ("error" in waved)
|
|
612
|
-
|
|
658
|
+
if ("error" in waved) {
|
|
659
|
+
out(c.red(`${waved.error}\n`));
|
|
660
|
+
return { status: "error", error: waved.error };
|
|
661
|
+
}
|
|
613
662
|
out(c.dim(`Parallel mode — ${waved.ok.length} wave(s).\n`));
|
|
614
663
|
for (const wave of waved.ok) {
|
|
615
664
|
const todo = wave.filter((a) => !doneIds.has(a.id));
|
|
@@ -624,6 +673,7 @@ async function executePlan(plan, roles, o) {
|
|
|
624
673
|
}
|
|
625
674
|
});
|
|
626
675
|
if (results.some((r) => !r)) {
|
|
676
|
+
failed = true;
|
|
627
677
|
out(c.dim("Stopping — a wave atom failed. Inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
628
678
|
break;
|
|
629
679
|
}
|
|
@@ -631,8 +681,10 @@ async function executePlan(plan, roles, o) {
|
|
|
631
681
|
}
|
|
632
682
|
else {
|
|
633
683
|
const ord = topoOrder(plan.atoms);
|
|
634
|
-
if ("error" in ord)
|
|
635
|
-
|
|
684
|
+
if ("error" in ord) {
|
|
685
|
+
out(c.red(`${ord.error}\n`));
|
|
686
|
+
return { status: "error", error: ord.error };
|
|
687
|
+
}
|
|
636
688
|
for (const atom of ord.ok) {
|
|
637
689
|
if (doneIds.has(atom.id))
|
|
638
690
|
continue; // resume: skip completed atoms
|
|
@@ -642,12 +694,21 @@ async function executePlan(plan, roles, o) {
|
|
|
642
694
|
doneIds.add(atom.id);
|
|
643
695
|
}
|
|
644
696
|
else {
|
|
697
|
+
failed = true;
|
|
645
698
|
out(c.dim("Stopping — inspect .hara/org/plan.json, then fix & `hara plan resume`.\n"));
|
|
646
699
|
break;
|
|
647
700
|
}
|
|
648
701
|
}
|
|
649
702
|
}
|
|
650
703
|
out(c.bold(`\nPlan: ${plan.atoms.filter((a) => a.status === "done").length}/${plan.atoms.length} atoms done.\n`));
|
|
704
|
+
if (failed || plan.atoms.some((atom) => atom.status === "failed")) {
|
|
705
|
+
const first = plan.atoms.find((atom) => atom.status === "failed");
|
|
706
|
+
return { status: "error", error: first ? `atom ${first.id} failed${first.note ? `: ${first.note}` : ""}` : "plan execution failed" };
|
|
707
|
+
}
|
|
708
|
+
if (plan.atoms.some((atom) => atom.status !== "done")) {
|
|
709
|
+
return { status: "halted", error: "plan stopped before every atom completed" };
|
|
710
|
+
}
|
|
711
|
+
return { status: "completed" };
|
|
651
712
|
}
|
|
652
713
|
/** Decompose a task into atoms, sequence them (DAG), and execute each with a verify gate.
|
|
653
714
|
* With `parallel`, independent atoms (the same dependency wave) run concurrently. */
|
|
@@ -657,12 +718,12 @@ async function runPlan(task, o) {
|
|
|
657
718
|
const plan = await decompose(o.baseProvider, task, roles);
|
|
658
719
|
if (!plan.atoms.length) {
|
|
659
720
|
out(c.red("Planner returned no atoms — try rephrasing the task.\n"));
|
|
660
|
-
return;
|
|
721
|
+
return { status: "error", error: "planner returned no atoms" };
|
|
661
722
|
}
|
|
662
723
|
const ord = topoOrder(plan.atoms);
|
|
663
724
|
if ("error" in ord) {
|
|
664
725
|
out(c.red(`${ord.error}\n`));
|
|
665
|
-
return;
|
|
726
|
+
return { status: "error", error: ord.error };
|
|
666
727
|
}
|
|
667
728
|
out(c.bold(`\nPlan (${ord.ok.length} atoms):\n`));
|
|
668
729
|
for (const a of ord.ok) {
|
|
@@ -670,41 +731,65 @@ async function runPlan(task, o) {
|
|
|
670
731
|
}
|
|
671
732
|
if (o.approval !== "full-auto") {
|
|
672
733
|
const ok = await o.confirm(`${c.yellow("▶")} Execute this ${ord.ok.length}-atom plan?`);
|
|
673
|
-
if (!ok)
|
|
674
|
-
|
|
734
|
+
if (!ok) {
|
|
735
|
+
out(c.dim("(cancelled)\n"));
|
|
736
|
+
return { status: "halted", error: "plan execution was cancelled" };
|
|
737
|
+
}
|
|
675
738
|
}
|
|
676
|
-
savePlan(o.cwd, plan);
|
|
677
|
-
|
|
739
|
+
await savePlan(o.cwd, plan);
|
|
740
|
+
return executePlan(plan, roles, o);
|
|
678
741
|
}
|
|
679
742
|
/** Resume the saved plan (.hara/org/plan.json): re-run atoms that aren't done; completed atoms are skipped. */
|
|
680
743
|
async function runResume(o) {
|
|
681
744
|
const roles = loadRoles(o.cwd);
|
|
682
745
|
const plan = loadPlan(o.cwd);
|
|
683
|
-
if (!plan)
|
|
684
|
-
|
|
746
|
+
if (!plan) {
|
|
747
|
+
out(c.red('No saved plan at .hara/org/plan.json — run `hara plan "<task>"` first.\n'));
|
|
748
|
+
return { status: "error", error: "no saved plan" };
|
|
749
|
+
}
|
|
685
750
|
const remaining = plan.atoms.filter((a) => a.status !== "done");
|
|
686
|
-
if (!remaining.length)
|
|
687
|
-
|
|
751
|
+
if (!remaining.length) {
|
|
752
|
+
out(c.green(`Plan already complete — ${plan.atoms.length}/${plan.atoms.length} done.\n`));
|
|
753
|
+
return { status: "completed" };
|
|
754
|
+
}
|
|
688
755
|
out(c.bold(`Resuming: ${plan.task}\n`) + c.dim(`${plan.atoms.length - remaining.length}/${plan.atoms.length} done · ${remaining.length} to go\n`));
|
|
689
756
|
for (const a of remaining)
|
|
690
757
|
out(` ${c.cyan(a.id)} ${a.title} ${c.dim("(" + a.status + ")")}\n`);
|
|
691
758
|
if (o.approval !== "full-auto") {
|
|
692
759
|
const ok = await o.confirm(`${c.yellow("▶")} Resume the ${remaining.length} remaining atom(s)?`);
|
|
693
|
-
if (!ok)
|
|
694
|
-
|
|
760
|
+
if (!ok) {
|
|
761
|
+
out(c.dim("(cancelled)\n"));
|
|
762
|
+
return { status: "halted", error: "plan resume was cancelled" };
|
|
763
|
+
}
|
|
695
764
|
}
|
|
696
765
|
for (const a of plan.atoms)
|
|
697
766
|
if (a.status === "failed" || a.status === "running")
|
|
698
767
|
a.status = "pending"; // retry interrupted
|
|
699
|
-
savePlan(o.cwd, plan);
|
|
700
|
-
|
|
768
|
+
await savePlan(o.cwd, plan);
|
|
769
|
+
return executePlan(plan, roles, o);
|
|
701
770
|
}
|
|
702
771
|
const READONLY_TOOLS = new Set(["read_file", "grep", "glob", "ls", "web_fetch", "web_search", "codebase_search", "todo_write"]);
|
|
703
|
-
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the
|
|
772
|
+
const REVIEW_SYSTEM = "You are a senior code reviewer. Review the safe Git status metadata the user provides for: correctness bugs, security " +
|
|
704
773
|
"issues, missing error handling, unclear naming, and missing/weak tests. You may read files (read-only) " +
|
|
705
|
-
"
|
|
774
|
+
"to inspect their current contents; historical patch lines are intentionally unavailable. Be concise and specific — cite file:line and the concrete fix. Group findings by severity: " +
|
|
706
775
|
"**Blocker**, **Should-fix**, **Nit**. If nothing material is wrong, say the diff looks good. Never edit files.";
|
|
707
|
-
|
|
776
|
+
function standaloneReviewPrompt(changes) {
|
|
777
|
+
const parts = ["Review these changes:"];
|
|
778
|
+
if (changes.diff)
|
|
779
|
+
parts.push(`Change metadata only (status + path; no historical patch contents):\n\`\`\`text\n${changes.diff}\n\`\`\``);
|
|
780
|
+
if (changes.newFiles.length)
|
|
781
|
+
parts.push(`New files (inspect with read_file): ${changes.newFiles.map((p) => JSON.stringify(p)).join(", ")}`);
|
|
782
|
+
if (changes.skippedFiles.length) {
|
|
783
|
+
parts.push("Protected paths were omitted and MUST NOT be opened during this review: " +
|
|
784
|
+
changes.skippedFiles.map((p) => JSON.stringify(p)).join(", "));
|
|
785
|
+
}
|
|
786
|
+
if (changes.omittedDeletions.length) {
|
|
787
|
+
parts.push("Tracked deletions were detected; their historical contents were omitted because old filesystem " +
|
|
788
|
+
"identity cannot be verified safely: " + changes.omittedDeletions.map((p) => JSON.stringify(p)).join(", "));
|
|
789
|
+
}
|
|
790
|
+
return parts.join("\n\n");
|
|
791
|
+
}
|
|
792
|
+
const COMMIT_SYSTEM = "Write a git commit message for the staged change metadata. A concise imperative subject (≤72 chars; an optional " +
|
|
708
793
|
"conventional-commits prefix like feat:/fix:/refactor:/docs:/test:/chore: is welcome). If the change is " +
|
|
709
794
|
"non-trivial, add a blank line then a short body (a few bullets or sentences) on what changed and why. " +
|
|
710
795
|
"Output ONLY the commit message — no code fences, no preamble, no surrounding quotes.";
|
|
@@ -783,7 +868,7 @@ async function compactConversation(provider, history, meta, stats) {
|
|
|
783
868
|
// most recently touched files (current on-disk state, byte-capped) so work continues without re-reads.
|
|
784
869
|
const restore = buildFileRestore(recentTouched(5), (p) => {
|
|
785
870
|
try {
|
|
786
|
-
return
|
|
871
|
+
return readModelContextFileSync(p, 32 * 1024);
|
|
787
872
|
}
|
|
788
873
|
catch {
|
|
789
874
|
return null;
|
|
@@ -818,29 +903,64 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
|
818
903
|
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
819
904
|
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
820
905
|
const roles = loadRoles(cwd);
|
|
821
|
-
const
|
|
906
|
+
const roleRef = roleId?.trim();
|
|
907
|
+
if (roleId !== undefined && !roleRef)
|
|
908
|
+
return "Error: sub-agent role cannot be blank.";
|
|
909
|
+
let role;
|
|
910
|
+
if (roleRef?.includes(":")) {
|
|
911
|
+
const hit = resolveAgent(roleRef, cwd);
|
|
912
|
+
if (hit && "ambiguous" in hit) {
|
|
913
|
+
return `Error: sub-agent role '${roleRef}' is ambiguous; use one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}.`;
|
|
914
|
+
}
|
|
915
|
+
if (hit?.project && resolve(hit.home) !== resolve(cwd)) {
|
|
916
|
+
return `Error: sub-agent role '${roleRef}' belongs to ${hit.home}; nested read-only agents stay in their parent home (${cwd}).`;
|
|
917
|
+
}
|
|
918
|
+
if (hit && !("ambiguous" in hit)) {
|
|
919
|
+
role = hit.project
|
|
920
|
+
? roles.find((candidate) => candidate.id === hit.name)
|
|
921
|
+
: loadGlobalRoles().find((candidate) => candidate.id === hit.name);
|
|
922
|
+
}
|
|
923
|
+
}
|
|
924
|
+
else if (roleRef) {
|
|
925
|
+
role = roles.find((candidate) => candidate.id === roleRef);
|
|
926
|
+
}
|
|
822
927
|
// Built-in explore persona: `agent(role:"explore")` works with ZERO user setup (a user-defined
|
|
823
928
|
// explore role still wins — it was found above and carries its own system).
|
|
824
|
-
const builtinSystem = !role &&
|
|
825
|
-
|
|
826
|
-
|
|
929
|
+
const builtinSystem = !role && roleRef === "explore" ? EXPLORE_SYSTEM : undefined;
|
|
930
|
+
if (roleRef && !role && !builtinSystem) {
|
|
931
|
+
return `Error: no sub-agent role '${roleRef}' is available in ${cwd}. Use a local role id, global:<name>, or role "explore".`;
|
|
932
|
+
}
|
|
933
|
+
const __subModel = effectiveRoleModel(role?.model, baseProvider.model);
|
|
934
|
+
const provider = __subModel && __subModel !== baseProvider.model
|
|
935
|
+
? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider)
|
|
936
|
+
: baseProvider;
|
|
827
937
|
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
828
938
|
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
829
939
|
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
830
940
|
const toolFilter = subagentToolFilter(role, (n) => READONLY_TOOLS.has(n));
|
|
831
941
|
const subHistory = [{ role: "user", content: task }];
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
942
|
+
const todoScope = `subagent:${randomUUID()}`;
|
|
943
|
+
try {
|
|
944
|
+
await runAgent(subHistory, {
|
|
945
|
+
provider,
|
|
946
|
+
ctx: { cwd, sandbox, todoScope }, // isolated checklist; no spawn → no recursion
|
|
947
|
+
approval: "full-auto", // read-only tools, so no prompts (can't prompt in parallel)
|
|
948
|
+
confirm: async () => true,
|
|
949
|
+
projectContext,
|
|
950
|
+
memory: memoryDigest(cwd),
|
|
951
|
+
stats,
|
|
952
|
+
systemOverride: role?.system ?? builtinSystem,
|
|
953
|
+
toolFilter,
|
|
954
|
+
hooks: false,
|
|
955
|
+
quiet: true,
|
|
956
|
+
});
|
|
957
|
+
}
|
|
958
|
+
finally {
|
|
959
|
+
disposeTodoScope(todoScope);
|
|
960
|
+
disposeReminderScope(todoScope);
|
|
961
|
+
resetRepeatGuard(todoScope);
|
|
962
|
+
clearTouched(todoScope);
|
|
963
|
+
}
|
|
844
964
|
for (let i = subHistory.length - 1; i >= 0; i--) {
|
|
845
965
|
const m = subHistory[i];
|
|
846
966
|
if (m.role === "assistant" && typeof m.text === "string" && m.text.trim())
|
|
@@ -852,7 +972,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
852
972
|
function runDoctor(cfg) {
|
|
853
973
|
const ok = (b) => (b ? c.green("✓") : c.red("✗"));
|
|
854
974
|
const dot = c.dim("·");
|
|
855
|
-
const
|
|
975
|
+
const nodeSupported = unsupportedNodeMessage() === null;
|
|
856
976
|
const hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
|
|
857
977
|
const oauthOk = cfg.provider === "qwen-oauth" && existsSync(join(homedir(), ".hara", "qwen-oauth.json"));
|
|
858
978
|
const authed = hasKey || oauthOk;
|
|
@@ -862,7 +982,7 @@ function runDoctor(cfg) {
|
|
|
862
982
|
const vdesc = vcap === "vision" ? c.dim("sees images (inline)") : vcap === "text" ? c.dim("text-only") : c.yellow("capability unknown — asks on first image");
|
|
863
983
|
const lines = [
|
|
864
984
|
c.bold("hara doctor"),
|
|
865
|
-
`${ok(
|
|
985
|
+
`${ok(nodeSupported)} node ${process.versions.node} ${c.dim(`(need ≥${MIN_NODE_VERSION})`)}`,
|
|
866
986
|
`${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
|
|
867
987
|
`${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
|
|
868
988
|
`${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
|
|
@@ -898,6 +1018,8 @@ program
|
|
|
898
1018
|
.description("A coding agent CLI that runs like an engineering org.")
|
|
899
1019
|
.version(pkg.version)
|
|
900
1020
|
.option("-p, --print <prompt>", "run a single prompt non-interactively, then exit")
|
|
1021
|
+
.option("--schema <json|file>", "(with -p) force a schema-shaped result: the model must call structured_output; stdout = that JSON")
|
|
1022
|
+
.option("--role <id>", "(with -p) run as a local, global:name, or project:name role (qualified projects run at their registered home)")
|
|
901
1023
|
.option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
|
|
902
1024
|
.option("-m, --model <model>", "model id (overrides config)")
|
|
903
1025
|
.option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
|
|
@@ -980,31 +1102,98 @@ program
|
|
|
980
1102
|
.option("--review", "after implementing, loop a reviewer role until it approves (implement → review → fix)")
|
|
981
1103
|
.option("--rounds <n>", "max review rounds with --review (default 3)", (v) => parseInt(v, 10))
|
|
982
1104
|
.option("--commit", "commit the result with an AI message (with --review: only after approval; needs a clean start tree)")
|
|
983
|
-
.action(async (taskParts,
|
|
984
|
-
|
|
1105
|
+
.action(async (taskParts, _localOpts, command) => {
|
|
1106
|
+
// The root headless command and `org` both expose --role. Commander stores that shared flag on the
|
|
1107
|
+
// parent command, while review/rounds/commit stay local; optsWithGlobals is the supported merged view.
|
|
1108
|
+
// Without it, flow-approved `hara org --role ...` children silently fell back to model dispatching.
|
|
1109
|
+
const opts2 = command.optsWithGlobals();
|
|
1110
|
+
let cfg = loadConfig();
|
|
1111
|
+
// Home dispatch (globally addressable, executes at home): a --role of "project:name" — or a bare name
|
|
1112
|
+
// that only exists in a REGISTERED project — resolves via the global agent index and runs with cwd =
|
|
1113
|
+
// that project (its AGENTS.md/data context), instead of failing or running context-blind here.
|
|
1114
|
+
let orgCwd = cfg.cwd;
|
|
1115
|
+
let forceRole = opts2.role;
|
|
1116
|
+
if (opts2.role && (opts2.role.includes(":") || !loadRoles(cfg.cwd).some((r) => r.id === opts2.role))) {
|
|
1117
|
+
const hit = resolveAgent(opts2.role);
|
|
1118
|
+
if (hit && "ambiguous" in hit) {
|
|
1119
|
+
out(c.yellow(`'${opts2.role}' exists in several projects — qualify it:\n`) + hit.ambiguous.map((e) => ` ${e.project}:${e.name}`).join("\n") + "\n");
|
|
1120
|
+
process.exit(1);
|
|
1121
|
+
}
|
|
1122
|
+
if (hit?.home) {
|
|
1123
|
+
orgCwd = hit.home;
|
|
1124
|
+
forceRole = hit.name;
|
|
1125
|
+
cfg = loadConfig({ cwd: hit.home });
|
|
1126
|
+
out(c.dim(`(dispatching to ${hit.project}:${hit.name} · home ${hit.home})\n`));
|
|
1127
|
+
}
|
|
1128
|
+
else if (hit) {
|
|
1129
|
+
// Explicit `global:name` is an address, not the literal role id. Global roles intentionally run
|
|
1130
|
+
// in the caller's current project so they retain that project's AGENTS.md and tool context.
|
|
1131
|
+
forceRole = hit.name;
|
|
1132
|
+
out(c.dim(`(dispatching to global:${hit.name} · current home ${orgCwd})\n`));
|
|
1133
|
+
}
|
|
1134
|
+
else if (!hit) {
|
|
1135
|
+
out(c.red(`No agent '${opts2.role}' was found here or in the registered project index.\n`));
|
|
1136
|
+
process.exit(1);
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
985
1139
|
const provider = await buildProvider(cfg);
|
|
986
1140
|
if (!provider) {
|
|
987
|
-
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
1141
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}' at ${orgCwd}.\n`) + authHint(cfg) + "\n");
|
|
988
1142
|
process.exit(1);
|
|
989
1143
|
}
|
|
990
1144
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
991
|
-
await runOrg(taskParts.join(" "), {
|
|
1145
|
+
const outcome = await runOrg(taskParts.join(" "), {
|
|
992
1146
|
cfg,
|
|
993
1147
|
baseProvider: provider,
|
|
994
|
-
cwd:
|
|
1148
|
+
cwd: orgCwd,
|
|
995
1149
|
sandbox: cfg.sandbox,
|
|
996
1150
|
approval: "full-auto",
|
|
997
1151
|
confirm: async () => true,
|
|
998
|
-
projectContext: loadAgentsMd(
|
|
1152
|
+
projectContext: loadAgentsMd(orgCwd) || undefined,
|
|
999
1153
|
stats,
|
|
1000
|
-
forceRole
|
|
1154
|
+
forceRole,
|
|
1001
1155
|
review: opts2.review,
|
|
1002
1156
|
rounds: opts2.rounds,
|
|
1003
1157
|
commit: opts2.commit,
|
|
1004
1158
|
});
|
|
1159
|
+
const failure = runFailureDetail(outcome);
|
|
1160
|
+
if (failure) {
|
|
1161
|
+
process.stderr.write(`hara: org run failed (${outcome.status}) — ${failure}\n`);
|
|
1162
|
+
process.exitCode = 2;
|
|
1163
|
+
}
|
|
1005
1164
|
if (stats.input || stats.output)
|
|
1006
1165
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1007
1166
|
});
|
|
1167
|
+
program
|
|
1168
|
+
.command("agents")
|
|
1169
|
+
.description("global agent index — every addressable agent (global roles + registered projects) and its home")
|
|
1170
|
+
.action(() => {
|
|
1171
|
+
const idx = buildAgentsIndex();
|
|
1172
|
+
if (!idx.length) {
|
|
1173
|
+
out(c.dim("(no agents — add roles to ~/.hara/roles, or register projects: hara projects add <name> <path>)\n"));
|
|
1174
|
+
return;
|
|
1175
|
+
}
|
|
1176
|
+
for (const e of idx) {
|
|
1177
|
+
out(c.bold(e.project ? `${e.project}:${e.name}` : `global:${e.name}`) + c.dim(e.home ? ` · ${e.home}` : " · current project") + "\n");
|
|
1178
|
+
if (e.description)
|
|
1179
|
+
out(c.dim(` ${e.description.slice(0, 120)}\n`));
|
|
1180
|
+
}
|
|
1181
|
+
out(c.dim(`\n${idx.length} agent(s). Run one: hara org --role <global:name|project:name> "<task>"\n`));
|
|
1182
|
+
});
|
|
1183
|
+
const projectsCmd = program.command("projects").description("register project homes for the global agent index (see `hara agents`)");
|
|
1184
|
+
projectsCmd.command("list").action(() => {
|
|
1185
|
+
const list = loadProjects();
|
|
1186
|
+
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"));
|
|
1187
|
+
});
|
|
1188
|
+
projectsCmd.command("add <name> <path>").action((name, path) => {
|
|
1189
|
+
const err = addProject(name, resolve(path));
|
|
1190
|
+
out(err ? c.red(`${err}\n`) : c.green(`✓ registered ${name} → ${resolve(path)}\n`));
|
|
1191
|
+
if (err)
|
|
1192
|
+
process.exit(1);
|
|
1193
|
+
});
|
|
1194
|
+
projectsCmd.command("remove <name>").action((name) => {
|
|
1195
|
+
out(removeProject(name) ? c.green(`✓ removed ${name}\n`) : c.yellow(`no project '${name}'\n`));
|
|
1196
|
+
});
|
|
1008
1197
|
program
|
|
1009
1198
|
.command("plan [task...]")
|
|
1010
1199
|
.description("decompose a task into atoms, sequence them (DAG), and execute each with a verify gate")
|
|
@@ -1029,12 +1218,20 @@ program
|
|
|
1029
1218
|
parallel: opts.parallel,
|
|
1030
1219
|
};
|
|
1031
1220
|
const task = (taskParts ?? []).join(" ").trim();
|
|
1221
|
+
let outcome;
|
|
1032
1222
|
if (task === "resume")
|
|
1033
|
-
await runResume(o);
|
|
1223
|
+
outcome = await runResume(o);
|
|
1034
1224
|
else if (!task)
|
|
1035
1225
|
out(c.dim('usage: hara plan "<task>" (or: hara plan resume)\n'));
|
|
1036
1226
|
else
|
|
1037
|
-
await runPlan(task, o);
|
|
1227
|
+
outcome = await runPlan(task, o);
|
|
1228
|
+
if (outcome) {
|
|
1229
|
+
const failure = runFailureDetail(outcome);
|
|
1230
|
+
if (failure) {
|
|
1231
|
+
process.stderr.write(`hara: plan run failed (${outcome.status}) — ${failure}\n`);
|
|
1232
|
+
process.exitCode = 2;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1038
1235
|
if (stats.input || stats.output)
|
|
1039
1236
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1040
1237
|
});
|
|
@@ -1387,14 +1584,14 @@ profileCmd
|
|
|
1387
1584
|
profileCmd
|
|
1388
1585
|
.command("pin [id]")
|
|
1389
1586
|
.description("write `.hara-profile` in this dir to lock the active profile here (omit id = pin current active)")
|
|
1390
|
-
.action((id) => {
|
|
1587
|
+
.action(async (id) => {
|
|
1391
1588
|
const target = (id && id.trim()) || activeId();
|
|
1392
1589
|
if (!getProfile(target)) {
|
|
1393
1590
|
out(c.red(`No profile '${target}'.\n`) + c.dim("List: `hara profile list`\n"));
|
|
1394
1591
|
process.exit(1);
|
|
1395
1592
|
}
|
|
1396
1593
|
try {
|
|
1397
|
-
const { file } = writePin(process.cwd(), target);
|
|
1594
|
+
const { file } = await writePin(process.cwd(), target);
|
|
1398
1595
|
out(c.green(`✓ pinned ${target} to ${relPath(file)}\n`));
|
|
1399
1596
|
// .hara-profile carries personal identity (which org you're as), unlike .nvmrc which
|
|
1400
1597
|
// is project-level. Nudge user toward GLOBAL gitignore so they don't accidentally
|
|
@@ -1577,21 +1774,37 @@ program
|
|
|
1577
1774
|
// `hara serve` is persistent, but config.json is user-editable at any time. Re-read it for every
|
|
1578
1775
|
// new/resumed session and model operation so a repaired/rotated key takes effect without restarting
|
|
1579
1776
|
// the desktop server (and, critically, never ask for a key that is already on disk).
|
|
1580
|
-
buildSessionProvider: async () => {
|
|
1581
|
-
const live = loadConfig();
|
|
1777
|
+
buildSessionProvider: async (targetCwd) => {
|
|
1778
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1582
1779
|
return withRouting(await buildProvider(live), live);
|
|
1583
1780
|
},
|
|
1584
|
-
buildProviderFor: async (model, effort) => {
|
|
1585
|
-
const live = loadConfig();
|
|
1781
|
+
buildProviderFor: async (model, effort, targetCwd) => {
|
|
1782
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1586
1783
|
return withRouting(await buildProvider({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
|
|
1587
1784
|
},
|
|
1588
|
-
listModels: () => {
|
|
1589
|
-
const live = loadConfig();
|
|
1785
|
+
listModels: (targetCwd) => {
|
|
1786
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1590
1787
|
return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
|
|
1591
1788
|
},
|
|
1592
1789
|
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1593
|
-
|
|
1790
|
+
runtimeInfo: (targetCwd) => {
|
|
1791
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1792
|
+
return {
|
|
1793
|
+
providerId: live.provider,
|
|
1794
|
+
model: live.model,
|
|
1795
|
+
effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
|
|
1796
|
+
};
|
|
1797
|
+
},
|
|
1798
|
+
spawnSubagent: (provider, scwd, projectContext, stats, task, role) => {
|
|
1799
|
+
const live = loadConfig({ cwd: scwd });
|
|
1800
|
+
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role);
|
|
1801
|
+
},
|
|
1594
1802
|
guardian: guardianOpt,
|
|
1803
|
+
buildGuardian: async (targetCwd) => {
|
|
1804
|
+
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1805
|
+
const base = await withRouting(await buildProvider(live), live);
|
|
1806
|
+
return buildGuardian(live, base);
|
|
1807
|
+
},
|
|
1595
1808
|
sandbox,
|
|
1596
1809
|
approval,
|
|
1597
1810
|
});
|
|
@@ -1707,7 +1920,7 @@ program
|
|
|
1707
1920
|
});
|
|
1708
1921
|
program
|
|
1709
1922
|
.command("review")
|
|
1710
|
-
.description("review your uncommitted changes
|
|
1923
|
+
.description("review your uncommitted changes for bugs, security, and missing tests")
|
|
1711
1924
|
.option("--staged", "review only staged changes")
|
|
1712
1925
|
.option("--base <ref>", "review against a base ref (e.g. main) instead of just the working tree")
|
|
1713
1926
|
.action(async (opts) => {
|
|
@@ -1717,25 +1930,29 @@ program
|
|
|
1717
1930
|
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
1718
1931
|
process.exit(1);
|
|
1719
1932
|
}
|
|
1720
|
-
const
|
|
1721
|
-
|
|
1722
|
-
|
|
1723
|
-
|
|
1933
|
+
const changes = captureChanges(cfg.cwd, 120_000, {
|
|
1934
|
+
staged: opts.staged,
|
|
1935
|
+
base: opts.base,
|
|
1936
|
+
includeUntracked: !opts.staged && !opts.base,
|
|
1937
|
+
});
|
|
1938
|
+
if (changes.error)
|
|
1939
|
+
return void out(c.red(`Git change capture failed closed: ${changes.error}\n`) + c.dim("(is this a git repo / valid base ref?)\n"));
|
|
1940
|
+
if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
|
|
1941
|
+
return void out(c.dim("No changes to review.\n"));
|
|
1724
1942
|
}
|
|
1725
|
-
|
|
1726
|
-
|
|
1943
|
+
if (changes.skippedFiles.length) {
|
|
1944
|
+
out(c.yellow(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
1727
1945
|
}
|
|
1728
|
-
|
|
1729
|
-
return void out(c.dim(`No changes to review (${cmd}).\n`));
|
|
1730
|
-
out(c.dim(`Reviewing \`${cmd}\` (${diff.split("\n").length} diff lines)…\n\n`));
|
|
1946
|
+
out(c.dim(`Reviewing ${changes.diff ? changes.diff.split("\n").length : 0} safe change entries…\n\n`));
|
|
1731
1947
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
1732
|
-
await runAgent([{ role: "user", content:
|
|
1948
|
+
await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
|
|
1733
1949
|
provider,
|
|
1734
1950
|
ctx: { cwd: cfg.cwd, sandbox: cfg.sandbox },
|
|
1735
1951
|
approval: "full-auto",
|
|
1736
1952
|
confirm: async () => true,
|
|
1737
1953
|
systemOverride: REVIEW_SYSTEM,
|
|
1738
1954
|
toolFilter: (n) => READONLY_TOOLS.has(n), // read-only: the reviewer can inspect, never edit
|
|
1955
|
+
hooks: false,
|
|
1739
1956
|
projectContext: loadAgentsMd(cfg.cwd) || undefined,
|
|
1740
1957
|
memory: memoryDigest(cfg.cwd),
|
|
1741
1958
|
stats,
|
|
@@ -1756,6 +1973,10 @@ program
|
|
|
1756
1973
|
process.exit(1);
|
|
1757
1974
|
}
|
|
1758
1975
|
if (opts.all) {
|
|
1976
|
+
const protectedTracked = protectedTrackedWorkingTreePaths(cfg.cwd);
|
|
1977
|
+
if (protectedTracked.length) {
|
|
1978
|
+
return void out(c.red(`Refusing to stage protected path(s): ${protectedTracked.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
1979
|
+
}
|
|
1759
1980
|
try {
|
|
1760
1981
|
await runShell("git add -u", cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
1761
1982
|
}
|
|
@@ -1763,19 +1984,23 @@ program
|
|
|
1763
1984
|
/* report below if nothing is staged */
|
|
1764
1985
|
}
|
|
1765
1986
|
}
|
|
1766
|
-
|
|
1767
|
-
|
|
1768
|
-
|
|
1987
|
+
const protectedStaged = protectedStagedPaths(cfg.cwd);
|
|
1988
|
+
if (protectedStaged.length) {
|
|
1989
|
+
return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${protectedStaged.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
1769
1990
|
}
|
|
1770
|
-
|
|
1771
|
-
|
|
1991
|
+
const staged = captureChanges(cfg.cwd, 120_000, { staged: true, includeUntracked: false });
|
|
1992
|
+
if (staged.error)
|
|
1993
|
+
return void out(c.red(`git diff failed closed: ${staged.error}\n`) + c.dim("(is this a git repo?)\n"));
|
|
1994
|
+
if (staged.skippedFiles.length) {
|
|
1995
|
+
return void out(c.red(`Refusing to inspect or commit protected staged path(s): ${staged.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
1772
1996
|
}
|
|
1773
|
-
|
|
1997
|
+
const changeInput = commitMessageInput(staged);
|
|
1998
|
+
if (!changeInput.trim())
|
|
1774
1999
|
return void out(c.dim("Nothing staged. Stage changes with `git add`, or use `hara commit -a`.\n"));
|
|
1775
2000
|
out(c.dim("Writing a commit message…\n"));
|
|
1776
2001
|
const r = await provider.turn({
|
|
1777
2002
|
system: COMMIT_SYSTEM,
|
|
1778
|
-
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n
|
|
2003
|
+
history: [{ role: "user", content: `Write a commit message for these staged changes:\n\n${changeInput.slice(0, 120_000)}` }],
|
|
1779
2004
|
tools: [],
|
|
1780
2005
|
onText: () => { },
|
|
1781
2006
|
});
|
|
@@ -1795,6 +2020,10 @@ program
|
|
|
1795
2020
|
const tmp = join(tmpdir(), `hara-commit-${process.pid}.txt`);
|
|
1796
2021
|
writeFileSync(tmp, msg + "\n", "utf8");
|
|
1797
2022
|
try {
|
|
2023
|
+
const protectedBeforeCommit = protectedStagedPaths(cfg.cwd);
|
|
2024
|
+
if (protectedBeforeCommit.length) {
|
|
2025
|
+
return void out(c.red(`Staged paths changed; refusing to commit protected path(s): ${protectedBeforeCommit.map((p) => JSON.stringify(p)).join(", ")}\n`));
|
|
2026
|
+
}
|
|
1798
2027
|
const res = await runShell(`git commit -F ${JSON.stringify(tmp)}`, cfg.cwd, "off", { timeout: 30_000, maxBuffer: 1_000_000 });
|
|
1799
2028
|
out(c.green("✓ committed ") + c.dim(((res.stdout || "").trim().split("\n")[0] || "").slice(0, 100)) + "\n");
|
|
1800
2029
|
}
|
|
@@ -1943,8 +2172,8 @@ memoryCmd.command("show").description("print the memory digest injected at sessi
|
|
|
1943
2172
|
const d = memoryDigest(process.cwd());
|
|
1944
2173
|
out(d ? d + "\n" : c.dim("(memory is empty — `hara memory init`, or let the agent write via memory_write)\n"));
|
|
1945
2174
|
});
|
|
1946
|
-
memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(() => {
|
|
1947
|
-
const w = scaffoldMemory(process.cwd());
|
|
2175
|
+
memoryCmd.command("init").description("scaffold the memory dirs + seed files (global + project)").action(async () => {
|
|
2176
|
+
const w = await scaffoldMemory(process.cwd());
|
|
1948
2177
|
out(w.length ? c.green(`Scaffolded: ${w.join(", ")}\n`) : c.dim("Memory already scaffolded.\n"));
|
|
1949
2178
|
});
|
|
1950
2179
|
memoryCmd
|
|
@@ -2140,8 +2369,8 @@ const skillsCmd = program.command("skills").description("manage skills (.hara/sk
|
|
|
2140
2369
|
skillsCmd
|
|
2141
2370
|
.command("init")
|
|
2142
2371
|
.description("scaffold an example skill")
|
|
2143
|
-
.action(() => {
|
|
2144
|
-
const written = scaffoldSkills(process.cwd());
|
|
2372
|
+
.action(async () => {
|
|
2373
|
+
const written = await scaffoldSkills(process.cwd());
|
|
2145
2374
|
out(written.length
|
|
2146
2375
|
? c.green(`Created an example skill: ${written.join(", ")}\n`)
|
|
2147
2376
|
: c.dim("Skills already exist in .hara/skills/.\n"));
|
|
@@ -2286,7 +2515,48 @@ program.action(async (opts) => {
|
|
|
2286
2515
|
// hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
|
|
2287
2516
|
// pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
|
|
2288
2517
|
// in the priority chain than --profile).
|
|
2289
|
-
|
|
2518
|
+
// Resolve addressable headless roles BEFORE loading config/provider/MCP. A qualified project role is
|
|
2519
|
+
// an execution-home selection, so every downstream route (credentials, model, AGENTS.md, tools) must be
|
|
2520
|
+
// constructed from that home rather than from the shell directory that happened to launch hara.
|
|
2521
|
+
let requestedHeadlessAgent;
|
|
2522
|
+
if (opts.print && opts.role) {
|
|
2523
|
+
const ref = String(opts.role).trim();
|
|
2524
|
+
const isLocalRole = !ref.includes(":") && loadRoles(process.cwd()).some((role) => role.id === ref);
|
|
2525
|
+
if (!isLocalRole) {
|
|
2526
|
+
const hit = resolveAgent(ref, process.cwd());
|
|
2527
|
+
if (hit && "ambiguous" in hit) {
|
|
2528
|
+
process.stderr.write(`hara: role '${ref}' is ambiguous; choose one of: ${hit.ambiguous.map((entry) => `${entry.project}:${entry.name}`).join(", ")}\n`);
|
|
2529
|
+
process.exitCode = 2;
|
|
2530
|
+
return;
|
|
2531
|
+
}
|
|
2532
|
+
if (!hit) {
|
|
2533
|
+
process.stderr.write(`hara: no agent '${ref}' was found locally, globally, or in the registered project index.\n`);
|
|
2534
|
+
process.exitCode = 2;
|
|
2535
|
+
return;
|
|
2536
|
+
}
|
|
2537
|
+
requestedHeadlessAgent = hit;
|
|
2538
|
+
}
|
|
2539
|
+
}
|
|
2540
|
+
const cfg = loadConfig({ overlay: opts.overlay, ...(requestedHeadlessAgent?.home ? { cwd: requestedHeadlessAgent.home } : {}) });
|
|
2541
|
+
const cwd = cfg.cwd;
|
|
2542
|
+
// Resolve the concrete role before constructing any user/plugin MCP transport. MCP servers are arbitrary
|
|
2543
|
+
// stdio subprocesses, so a read-only persona must not start them merely by launching a turn. Reusing this
|
|
2544
|
+
// object later also closes the resolve→connect→re-resolve race where a role could disappear or change policy.
|
|
2545
|
+
let requestedHeadlessRole;
|
|
2546
|
+
if (opts.print && opts.role) {
|
|
2547
|
+
const requestedRole = String(opts.role).trim();
|
|
2548
|
+
requestedHeadlessRole = requestedHeadlessAgent?.project
|
|
2549
|
+
? loadRoles(requestedHeadlessAgent.home).find((candidate) => candidate.id === requestedHeadlessAgent.name)
|
|
2550
|
+
: requestedHeadlessAgent
|
|
2551
|
+
? loadGlobalRoles().find((candidate) => candidate.id === requestedHeadlessAgent.name)
|
|
2552
|
+
: loadRoles(cwd).find((candidate) => candidate.id === requestedRole);
|
|
2553
|
+
if (!requestedHeadlessRole) {
|
|
2554
|
+
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`);
|
|
2555
|
+
process.exitCode = 2;
|
|
2556
|
+
return;
|
|
2557
|
+
}
|
|
2558
|
+
}
|
|
2559
|
+
const machineOutput = !!opts.print && !!opts.schema;
|
|
2290
2560
|
if (opts.model)
|
|
2291
2561
|
cfg.model = opts.model;
|
|
2292
2562
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
@@ -2331,7 +2601,11 @@ program.action(async (opts) => {
|
|
|
2331
2601
|
process.exit(0);
|
|
2332
2602
|
}
|
|
2333
2603
|
}
|
|
2334
|
-
|
|
2604
|
+
const message = `Not authenticated for provider '${cfg.provider}'.\n${authHint(cfg)}\n`;
|
|
2605
|
+
if (machineOutput)
|
|
2606
|
+
process.stderr.write(message);
|
|
2607
|
+
else
|
|
2608
|
+
out(c.red(`Not authenticated for provider '${cfg.provider}'.\n`) + authHint(cfg) + "\n");
|
|
2335
2609
|
process.exit(1);
|
|
2336
2610
|
}
|
|
2337
2611
|
let provider = provider0;
|
|
@@ -2350,146 +2624,323 @@ program.action(async (opts) => {
|
|
|
2350
2624
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
2351
2625
|
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
2352
2626
|
}
|
|
2353
|
-
const cwd = cfg.cwd;
|
|
2354
2627
|
let approval = opts.yes ? "full-auto" : (opts.approval || cfg.approval);
|
|
2355
2628
|
let currentTurn = null; // set during a running turn so Esc can abort it
|
|
2356
2629
|
const autoApprove = new Set(); // tools the user chose "don't ask again" for, this session
|
|
2357
2630
|
let recalledContext = ""; // snippets queued by /recall, prepended to the next message
|
|
2358
2631
|
const sandbox = opts.sandbox || cfg.sandbox;
|
|
2359
2632
|
if (sandbox !== "off" && !sandboxSupported()) {
|
|
2360
|
-
|
|
2633
|
+
const message = `(sandbox '${sandbox}' is macOS-only; shell runs unsandboxed here)\n`;
|
|
2634
|
+
if (machineOutput)
|
|
2635
|
+
process.stderr.write(message);
|
|
2636
|
+
else
|
|
2637
|
+
out(c.yellow(message));
|
|
2361
2638
|
}
|
|
2362
2639
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
2640
|
+
// Connecting to MCP executes configured external commands before an MCP tool is selected, so the ordinary
|
|
2641
|
+
// per-tool confirmation is too late. Obtain one explicit startup grant for the named server set. askConfirm
|
|
2642
|
+
// uses a short-lived Ink prompt and restores stdin, making it safe before either the main TUI or readline REPL
|
|
2643
|
+
// owns the terminal. Non-interactive runs remain closed unless the user opted in before process launch.
|
|
2363
2644
|
const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
|
|
2364
|
-
|
|
2365
|
-
|
|
2645
|
+
const mcpNames = Object.keys(mcpAll);
|
|
2646
|
+
if (!requestedHeadlessRole?.readOnly && mcpNames.length) {
|
|
2647
|
+
const trustedOptIn = process.env.HARA_ALLOW_TRUSTED_EXTENSIONS === "1";
|
|
2648
|
+
const interactiveTerminal = !opts.print && !!stdin.isTTY && !!stdout.isTTY;
|
|
2649
|
+
const approved = trustedOptIn ||
|
|
2650
|
+
(interactiveTerminal &&
|
|
2651
|
+
(await askConfirm(`Start configured MCP server${mcpNames.length === 1 ? "" : "s"} ${mcpNames
|
|
2652
|
+
.slice(0, 8)
|
|
2653
|
+
.map((name) => `'${name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80)}'`)
|
|
2654
|
+
.join(", ")}${mcpNames.length > 8 ? ` and ${mcpNames.length - 8} more` : ""}? ` +
|
|
2655
|
+
"They execute external code outside Hara's protected-file boundary.", false)));
|
|
2656
|
+
if (approved) {
|
|
2657
|
+
await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")), { approved: true });
|
|
2658
|
+
}
|
|
2659
|
+
else {
|
|
2660
|
+
const message = interactiveTerminal
|
|
2661
|
+
? "hara: MCP server startup declined; no configured MCP command was executed.\n"
|
|
2662
|
+
: "hara: MCP servers skipped because this run has no interactive startup approval. " +
|
|
2663
|
+
"Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only for reviewed servers.\n";
|
|
2664
|
+
if (machineOutput || !interactiveTerminal)
|
|
2665
|
+
process.stderr.write(message);
|
|
2666
|
+
else
|
|
2667
|
+
out(c.dim(message));
|
|
2668
|
+
}
|
|
2366
2669
|
}
|
|
2367
2670
|
// one-shot
|
|
2368
2671
|
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" }),
|
|
2672
|
+
let headlessLockId = null;
|
|
2673
|
+
try {
|
|
2674
|
+
const projectContext = loadAgentsMd(cwd) || undefined;
|
|
2675
|
+
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
2676
|
+
// "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
|
|
2677
|
+
// describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
|
|
2678
|
+
const describeImage = async (path, hint) => {
|
|
2679
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2680
|
+
const vp = cfg.visionModel
|
|
2681
|
+
? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
|
|
2682
|
+
: cap === "vision"
|
|
2683
|
+
? provider
|
|
2684
|
+
: null;
|
|
2685
|
+
if (!vp)
|
|
2686
|
+
return "";
|
|
2687
|
+
try {
|
|
2688
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
2689
|
+
}
|
|
2690
|
+
catch {
|
|
2691
|
+
return "";
|
|
2692
|
+
}
|
|
2411
2693
|
};
|
|
2412
|
-
//
|
|
2413
|
-
// --
|
|
2414
|
-
|
|
2415
|
-
|
|
2416
|
-
|
|
2694
|
+
// Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
|
|
2695
|
+
// saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
|
|
2696
|
+
// Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
|
|
2697
|
+
let meta = null;
|
|
2698
|
+
const history = [];
|
|
2699
|
+
if (opts.resume || opts.continue) {
|
|
2700
|
+
const resumeArg = opts.resume ? String(opts.resume) : undefined;
|
|
2701
|
+
if (resumeArg && !validSessionId(resumeArg)) {
|
|
2702
|
+
process.stderr.write("hara: --resume contains an invalid session id. Use an id shown by `hara sessions`.\n");
|
|
2703
|
+
process.exitCode = 2;
|
|
2704
|
+
return;
|
|
2417
2705
|
}
|
|
2418
|
-
|
|
2419
|
-
|
|
2420
|
-
|
|
2421
|
-
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
2706
|
+
const resolvedResume = resumeArg ? resolveSessionId(resumeArg) : null;
|
|
2707
|
+
if (resumeArg && !resolvedResume) {
|
|
2708
|
+
const prefixMatches = listSessions().filter((session) => session.id.startsWith(resumeArg));
|
|
2709
|
+
if (prefixMatches.length > 1) {
|
|
2710
|
+
process.stderr.write(`hara: session prefix '${resumeArg}' is ambiguous; use more characters.\n`);
|
|
2711
|
+
process.exitCode = 2;
|
|
2712
|
+
return;
|
|
2713
|
+
}
|
|
2714
|
+
}
|
|
2715
|
+
const rid = (resumeArg ? (resolvedResume ?? resumeArg) : latestForCwd(cwd)?.meta.id) ?? newSessionId();
|
|
2716
|
+
const lock = acquireSessionLock(rid);
|
|
2717
|
+
if (!lock.ok) {
|
|
2718
|
+
process.stderr.write(`hara: session ${shortId(rid)} is already open in another process (pid ${lock.pid ?? "unknown"}); refusing a concurrent headless resume.\n`);
|
|
2719
|
+
process.exitCode = 1;
|
|
2720
|
+
await closeMcp();
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
headlessLockId = rid;
|
|
2724
|
+
// Re-read only after acquiring the single-writer lock. Loading first would leave a race window where
|
|
2725
|
+
// another gateway/cron process appends history that this stale snapshot later overwrites.
|
|
2726
|
+
const prior = loadSession(rid);
|
|
2727
|
+
if (sessionFileExists(rid) && !prior) {
|
|
2728
|
+
process.stderr.write(`hara: session ${shortId(rid)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${rid}.json.\n`);
|
|
2729
|
+
process.exitCode = 2;
|
|
2730
|
+
return;
|
|
2731
|
+
}
|
|
2732
|
+
if (prior && canonicalProjectPath(prior.meta.cwd) !== canonicalProjectPath(cwd)) {
|
|
2733
|
+
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`);
|
|
2734
|
+
process.exitCode = 2;
|
|
2735
|
+
return;
|
|
2736
|
+
}
|
|
2737
|
+
if (prior && requestedHeadlessAgent?.project) {
|
|
2738
|
+
const priorHome = canonicalProjectPath(prior.meta.cwd);
|
|
2739
|
+
if (priorHome !== requestedHeadlessAgent.home) {
|
|
2740
|
+
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`);
|
|
2741
|
+
process.exitCode = 2;
|
|
2742
|
+
return;
|
|
2743
|
+
}
|
|
2744
|
+
}
|
|
2745
|
+
if (prior?.history)
|
|
2746
|
+
history.push(...prior.history);
|
|
2747
|
+
// Stamp who created this session (cron runner sets HARA_CRON, gateway sets HARA_GATEWAY) and give
|
|
2748
|
+
// automated sessions a "name · time" title UP FRONT — the raw prompt must never become the title.
|
|
2749
|
+
const src = sessionSourceFromEnv();
|
|
2750
|
+
meta = prior?.meta ?? {
|
|
2751
|
+
id: rid,
|
|
2752
|
+
cwd,
|
|
2753
|
+
provider: cfg.provider,
|
|
2754
|
+
model: cfg.model,
|
|
2755
|
+
title: src.source === "interactive" ? "" : automatedTitle(src.source, src.sourceName),
|
|
2756
|
+
createdAt: new Date().toISOString(),
|
|
2757
|
+
updatedAt: "",
|
|
2758
|
+
...(src.source !== "interactive" ? { source: src.source, sourceName: src.sourceName } : { source: "interactive" }),
|
|
2759
|
+
};
|
|
2760
|
+
// Task-state continuity: restore the persisted checklist, then mirror every change back onto meta
|
|
2761
|
+
// live — all existing saveSession() sites persist it for free (no per-site threading).
|
|
2762
|
+
restoreTodos(meta.todos);
|
|
2763
|
+
onTodosChange((list) => {
|
|
2764
|
+
if (meta)
|
|
2765
|
+
meta.todos = [...list];
|
|
2766
|
+
});
|
|
2767
|
+
// Apply per-session pinned model on headless resume (mirrors the interactive path).
|
|
2768
|
+
// --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
|
|
2769
|
+
if (prior) {
|
|
2770
|
+
if (opts.model) {
|
|
2771
|
+
meta.model = cfg.model;
|
|
2772
|
+
}
|
|
2773
|
+
else if (meta.model && meta.model !== cfg.model) {
|
|
2774
|
+
const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
|
|
2775
|
+
if (__allowed && !__activeP.availableModels.includes(meta.model)) {
|
|
2776
|
+
const __fb = __activeP.defaultModel || cfg.model;
|
|
2777
|
+
// headless: log to stderr so it doesn't pollute the captured stdout reply
|
|
2778
|
+
try {
|
|
2779
|
+
process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
|
|
2780
|
+
}
|
|
2781
|
+
catch { /* ignore */ }
|
|
2782
|
+
cfg.model = __fb;
|
|
2783
|
+
meta.model = __fb;
|
|
2784
|
+
const __rb = await buildProvider(cfg);
|
|
2785
|
+
if (__rb)
|
|
2786
|
+
provider = __rb;
|
|
2787
|
+
}
|
|
2788
|
+
else {
|
|
2789
|
+
cfg.model = meta.model;
|
|
2790
|
+
const __rb = await buildProvider(cfg);
|
|
2791
|
+
if (__rb)
|
|
2792
|
+
provider = __rb;
|
|
2425
2793
|
}
|
|
2426
|
-
catch { /* ignore */ }
|
|
2427
|
-
cfg.model = __fb;
|
|
2428
|
-
meta.model = __fb;
|
|
2429
|
-
const __rb = await buildProvider(cfg);
|
|
2430
|
-
if (__rb)
|
|
2431
|
-
provider = __rb;
|
|
2432
2794
|
}
|
|
2433
|
-
|
|
2434
|
-
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2795
|
+
}
|
|
2796
|
+
}
|
|
2797
|
+
// --schema: schema-enforced structured output. The schema (inline JSON or a file path) becomes a run-scoped
|
|
2798
|
+
// structured_output tool the model MUST call; stdout is then exactly that JSON (streaming suppressed), so
|
|
2799
|
+
// scripts / gateway flows / cron parse a guaranteed shape instead of regex-fishing prose.
|
|
2800
|
+
let schemaObj = null;
|
|
2801
|
+
if (opts.schema) {
|
|
2802
|
+
const rawArg = String(opts.schema);
|
|
2803
|
+
let raw = rawArg;
|
|
2804
|
+
if (existsSync(rawArg)) {
|
|
2805
|
+
try {
|
|
2806
|
+
raw = readModelContextFileSync(rawArg, 1024 * 1024);
|
|
2807
|
+
}
|
|
2808
|
+
catch (error) {
|
|
2809
|
+
process.stderr.write(`hara: refusing unsafe schema file: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
2810
|
+
process.exitCode = 2;
|
|
2811
|
+
await closeMcp();
|
|
2812
|
+
return;
|
|
2438
2813
|
}
|
|
2439
2814
|
}
|
|
2815
|
+
const parsed = parseSchemaArg(raw);
|
|
2816
|
+
if ("error" in parsed) {
|
|
2817
|
+
process.stderr.write(`hara: ${parsed.error}\n`);
|
|
2818
|
+
process.exitCode = 2;
|
|
2819
|
+
await closeMcp();
|
|
2820
|
+
return;
|
|
2821
|
+
}
|
|
2822
|
+
schemaObj = parsed;
|
|
2440
2823
|
}
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
.
|
|
2451
|
-
|
|
2452
|
-
history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
|
|
2453
|
-
}
|
|
2454
|
-
else if (inboundImgs.length && cfg.visionModel) {
|
|
2455
|
-
let desc = "";
|
|
2456
|
-
try {
|
|
2457
|
-
const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
2458
|
-
if (vp)
|
|
2459
|
-
desc = await describeImages(vp, inboundImgs);
|
|
2824
|
+
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
2825
|
+
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
2826
|
+
// visionModel sidecar and folded into the message (text-only models can't take image blocks).
|
|
2827
|
+
const userText = expandMentions(String(opts.print), cwd) + (schemaObj ? STRUCTURED_INSTRUCTION : "");
|
|
2828
|
+
const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
|
|
2829
|
+
.split("\n")
|
|
2830
|
+
.map((s) => s.trim())
|
|
2831
|
+
.filter((p) => p && existsSync(p))
|
|
2832
|
+
.map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
|
|
2833
|
+
if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
|
|
2834
|
+
history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
|
|
2460
2835
|
}
|
|
2461
|
-
|
|
2462
|
-
|
|
2836
|
+
else if (inboundImgs.length && cfg.visionModel) {
|
|
2837
|
+
let desc = "";
|
|
2838
|
+
try {
|
|
2839
|
+
const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
2840
|
+
if (vp)
|
|
2841
|
+
desc = await describeImages(vp, inboundImgs);
|
|
2842
|
+
}
|
|
2843
|
+
catch {
|
|
2844
|
+
/* describe is best-effort — fall back to the marker-only text */
|
|
2845
|
+
}
|
|
2846
|
+
const n = inboundImgs.length;
|
|
2847
|
+
history.push({
|
|
2848
|
+
role: "user",
|
|
2849
|
+
content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
|
|
2850
|
+
});
|
|
2463
2851
|
}
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2852
|
+
else {
|
|
2853
|
+
history.push({ role: "user", content: userText });
|
|
2854
|
+
}
|
|
2855
|
+
// --role: run this headless turn AS an org role/agent persona (the gateway's /agent switch lands here).
|
|
2856
|
+
// Local roles resolve at cwd; qualified project agents were resolved before config/provider startup and
|
|
2857
|
+
// cwd is already their registered home. Explicit global roles remain portable in the current project.
|
|
2858
|
+
let roleOverride;
|
|
2859
|
+
let headlessProvider = provider;
|
|
2860
|
+
let headlessToolFilter;
|
|
2861
|
+
let headlessHooks = true;
|
|
2862
|
+
if (requestedHeadlessRole) {
|
|
2863
|
+
roleOverride = requestedHeadlessRole.system;
|
|
2864
|
+
headlessToolFilter = roleToolFilter(requestedHeadlessRole);
|
|
2865
|
+
if (requestedHeadlessRole.readOnly)
|
|
2866
|
+
headlessHooks = false;
|
|
2867
|
+
const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
|
|
2868
|
+
if (roleModel && roleModel !== provider.model) {
|
|
2869
|
+
const selected = await buildProvider({ ...cfg, model: roleModel });
|
|
2870
|
+
if (!selected) {
|
|
2871
|
+
process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
|
|
2872
|
+
process.exitCode = 2;
|
|
2873
|
+
await closeMcp();
|
|
2874
|
+
return;
|
|
2875
|
+
}
|
|
2876
|
+
headlessProvider = selected;
|
|
2877
|
+
}
|
|
2878
|
+
}
|
|
2879
|
+
let structured;
|
|
2880
|
+
let structuredSet = false;
|
|
2881
|
+
const printRunOpts = {
|
|
2882
|
+
provider: headlessProvider,
|
|
2883
|
+
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role), describeImage },
|
|
2884
|
+
approval: "full-auto",
|
|
2885
|
+
confirm: async () => true,
|
|
2886
|
+
projectContext,
|
|
2887
|
+
memory: memoryDigest(cwd),
|
|
2888
|
+
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
2889
|
+
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
2890
|
+
hooks: headlessHooks,
|
|
2891
|
+
stats,
|
|
2892
|
+
guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
|
|
2893
|
+
...(schemaObj
|
|
2894
|
+
? {
|
|
2895
|
+
extraTools: [structuredOutputTool(schemaObj, (v) => ((structured = v), (structuredSet = true)))],
|
|
2896
|
+
quiet: true, // stdout must be exactly the JSON — no streamed prose
|
|
2897
|
+
}
|
|
2898
|
+
: {}),
|
|
2899
|
+
};
|
|
2900
|
+
let runOutcome = await runAgent(history, printRunOpts);
|
|
2901
|
+
if (schemaObj) {
|
|
2902
|
+
// The tool call IS the answer — if the model finished without it, nudge and retry (bounded).
|
|
2903
|
+
for (let attempt = 0; attempt < 2 && !structuredSet && runOutcome.status === "completed"; attempt++) {
|
|
2904
|
+
history.push({ role: "user", content: STRUCTURED_NUDGE });
|
|
2905
|
+
runOutcome = await runAgent(history, printRunOpts);
|
|
2906
|
+
}
|
|
2907
|
+
const failure = runFailureDetail(runOutcome);
|
|
2908
|
+
if (failure) {
|
|
2909
|
+
// A valid structured_output call is provisional until the whole agent run completes. A later provider
|
|
2910
|
+
// error or safety halt must never be hidden behind stale-looking success JSON on stdout.
|
|
2911
|
+
process.stderr.write(`hara: structured run failed (${runOutcome.status}) — ${failure}\n`);
|
|
2912
|
+
process.exitCode = 2;
|
|
2913
|
+
}
|
|
2914
|
+
else if (structuredSet)
|
|
2915
|
+
out(JSON.stringify(structured) + "\n");
|
|
2916
|
+
else {
|
|
2917
|
+
process.stderr.write("hara: model never called structured_output — no result.\n");
|
|
2918
|
+
process.exitCode = 2;
|
|
2919
|
+
}
|
|
2920
|
+
}
|
|
2921
|
+
else {
|
|
2922
|
+
const failure = runFailureDetail(runOutcome);
|
|
2923
|
+
if (failure) {
|
|
2924
|
+
process.stderr.write(`hara: headless run failed (${runOutcome.status}) — ${failure}\n`);
|
|
2925
|
+
process.exitCode = 2;
|
|
2926
|
+
}
|
|
2927
|
+
}
|
|
2928
|
+
if (meta) {
|
|
2929
|
+
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
2930
|
+
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
2931
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { });
|
|
2932
|
+
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
2933
|
+
}
|
|
2934
|
+
if (!schemaObj && runOutcome.status === "completed" && (stats.input || stats.output))
|
|
2935
|
+
out(statusLine(headlessProvider.model, stats.input, stats.output) + "\n");
|
|
2936
|
+
await closeMcp();
|
|
2937
|
+
return;
|
|
2469
2938
|
}
|
|
2470
|
-
|
|
2471
|
-
|
|
2939
|
+
finally {
|
|
2940
|
+
if (headlessLockId)
|
|
2941
|
+
releaseSessionLock(headlessLockId);
|
|
2942
|
+
await closeMcp();
|
|
2472
2943
|
}
|
|
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
2944
|
}
|
|
2494
2945
|
// interactive REPL — ink TUI by default on a real terminal; HARA_TUI=0 forces the classic readline path
|
|
2495
2946
|
const useTui = stdin.isTTY && stdout.isTTY && process.env.HARA_TUI !== "0";
|
|
@@ -2568,20 +3019,43 @@ program.action(async (opts) => {
|
|
|
2568
3019
|
let projectContext = loadAgentsMd(cwd) || undefined;
|
|
2569
3020
|
const spawn = (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role);
|
|
2570
3021
|
// session: --resume <id> / --continue (latest in this cwd) / new
|
|
2571
|
-
let
|
|
3022
|
+
let resumeId = null;
|
|
2572
3023
|
if (opts.resume) {
|
|
2573
3024
|
const rid = resolveSessionId(opts.resume); // accept a full UUID or a unique prefix (short id)
|
|
2574
|
-
|
|
2575
|
-
if (!
|
|
3025
|
+
resumeId = rid;
|
|
3026
|
+
if (!resumeId)
|
|
2576
3027
|
out(c.yellow(`(no session '${opts.resume}'; starting fresh)\n`));
|
|
2577
3028
|
}
|
|
2578
3029
|
else if (opts.continue) {
|
|
2579
|
-
|
|
2580
|
-
if (!
|
|
3030
|
+
resumeId = latestForCwd(cwd)?.meta.id ?? null;
|
|
3031
|
+
if (!resumeId)
|
|
2581
3032
|
out(c.dim("(no prior session in this directory; starting fresh)\n"));
|
|
2582
3033
|
}
|
|
3034
|
+
// Single-writer guard: two hara processes on the SAME session race writes to its append-only history and
|
|
3035
|
+
// corrupt it. Acquire BEFORE reading the history, then re-read under the lock so no stale snapshot can
|
|
3036
|
+
// overwrite a turn appended between load and lock.
|
|
3037
|
+
const sessionId = resumeId ?? newSessionId();
|
|
3038
|
+
const lock = acquireSessionLock(sessionId);
|
|
3039
|
+
if (!lock.ok) {
|
|
3040
|
+
out(c.red(`Session ${shortId(sessionId)} is already open in another hara process (pid ${lock.pid}).`) +
|
|
3041
|
+
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`));
|
|
3042
|
+
process.exit(1);
|
|
3043
|
+
}
|
|
3044
|
+
process.on("exit", () => releaseSessionLock(sessionId));
|
|
3045
|
+
const resumed = resumeId ? loadSession(resumeId) : null;
|
|
3046
|
+
if (resumeId && sessionFileExists(resumeId) && !resumed) {
|
|
3047
|
+
releaseSessionLock(sessionId);
|
|
3048
|
+
out(c.red(`Session ${shortId(resumeId)} exists but is unreadable or corrupt; refusing to overwrite it. Inspect ~/.hara/sessions/${resumeId}.json.\n`));
|
|
3049
|
+
process.exit(2);
|
|
3050
|
+
}
|
|
3051
|
+
if (resumed && canonicalProjectPath(resumed.meta.cwd) !== canonicalProjectPath(cwd)) {
|
|
3052
|
+
releaseSessionLock(sessionId);
|
|
3053
|
+
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`) +
|
|
3054
|
+
c.dim(`Run hara from the session's project directory instead.\n`));
|
|
3055
|
+
process.exit(2);
|
|
3056
|
+
}
|
|
2583
3057
|
const meta = resumed?.meta ?? {
|
|
2584
|
-
id:
|
|
3058
|
+
id: sessionId,
|
|
2585
3059
|
cwd,
|
|
2586
3060
|
provider: cfg.provider,
|
|
2587
3061
|
model: cfg.model,
|
|
@@ -2590,16 +3064,11 @@ program.action(async (opts) => {
|
|
|
2590
3064
|
updatedAt: "",
|
|
2591
3065
|
source: "interactive",
|
|
2592
3066
|
};
|
|
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));
|
|
3067
|
+
// Task-state continuity (interactive twin of the -p path): restore the checklist, mirror changes onto meta.
|
|
3068
|
+
restoreTodos(meta.todos);
|
|
3069
|
+
onTodosChange((list) => {
|
|
3070
|
+
meta.todos = [...list];
|
|
3071
|
+
});
|
|
2603
3072
|
// Per-session model precedence on resume:
|
|
2604
3073
|
// 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
|
|
2605
3074
|
// 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
|
|
@@ -2929,7 +3398,22 @@ program.action(async (opts) => {
|
|
|
2929
3398
|
out(c.green(`(renamed → ${meta.title})\n`));
|
|
2930
3399
|
},
|
|
2931
3400
|
},
|
|
2932
|
-
{
|
|
3401
|
+
{
|
|
3402
|
+
name: "reset",
|
|
3403
|
+
aliases: ["clear"],
|
|
3404
|
+
desc: "clear conversation context",
|
|
3405
|
+
run: () => {
|
|
3406
|
+
history.length = 0;
|
|
3407
|
+
recalledContext = "";
|
|
3408
|
+
clearTodos();
|
|
3409
|
+
meta.todos = [];
|
|
3410
|
+
resetReachability();
|
|
3411
|
+
resetRepeatGuard();
|
|
3412
|
+
clearTouched();
|
|
3413
|
+
saveSession(meta, history);
|
|
3414
|
+
out(c.dim("(context cleared)\n"));
|
|
3415
|
+
},
|
|
3416
|
+
},
|
|
2933
3417
|
{ name: "exit", aliases: ["quit"], desc: "leave", run: () => "exit" },
|
|
2934
3418
|
];
|
|
2935
3419
|
const byName = new Map();
|
|
@@ -3129,6 +3613,10 @@ program.action(async (opts) => {
|
|
|
3129
3613
|
recalledContext = "";
|
|
3130
3614
|
resetReachability(); // fresh start — drop any "host unreachable" marks (network may be fixed)
|
|
3131
3615
|
resetRepeatGuard(); // …and the repeated-failure streaks (the user may have fixed the cause)
|
|
3616
|
+
clearTouched();
|
|
3617
|
+
clearTodos();
|
|
3618
|
+
meta.todos = [];
|
|
3619
|
+
saveSession(meta, history);
|
|
3132
3620
|
return void h.sink.notice("(context cleared)");
|
|
3133
3621
|
}
|
|
3134
3622
|
if (nm === "undo") {
|
|
@@ -3306,24 +3794,25 @@ program.action(async (opts) => {
|
|
|
3306
3794
|
return void h.sink.notice(r.startsWith("error:") ? `✗ ${r}` : r === "nothing to commit" ? "(nothing to commit — make or stage changes first)" : `✓ committed · ${r.slice(0, 100)}`);
|
|
3307
3795
|
}
|
|
3308
3796
|
if (nm === "review") {
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3797
|
+
const changes = captureChanges(cwd, 120_000, { includeUntracked: true });
|
|
3798
|
+
if (changes.error)
|
|
3799
|
+
return void h.sink.notice(`(review capture failed closed: ${changes.error})`);
|
|
3800
|
+
if (!changes.diff && !changes.newFiles.length && !changes.skippedFiles.length && !changes.omittedDeletions.length) {
|
|
3801
|
+
return void h.sink.notice("(nothing to review — no changes vs HEAD)");
|
|
3312
3802
|
}
|
|
3313
|
-
|
|
3314
|
-
|
|
3803
|
+
if (changes.skippedFiles.length) {
|
|
3804
|
+
h.sink.notice(`Protected paths omitted: ${changes.skippedFiles.map((p) => JSON.stringify(p)).join(", ")}`);
|
|
3315
3805
|
}
|
|
3316
|
-
if (!diff.trim())
|
|
3317
|
-
return void h.sink.notice("(nothing to review — no changes vs HEAD)");
|
|
3318
3806
|
const rui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
3319
3807
|
const xin = stats.input;
|
|
3320
3808
|
const xout = stats.output;
|
|
3321
|
-
await runAgent([{ role: "user", content:
|
|
3809
|
+
await runAgent([{ role: "user", content: standaloneReviewPrompt(changes) }], {
|
|
3322
3810
|
provider,
|
|
3323
3811
|
ctx: { cwd, sandbox, ui: rui },
|
|
3324
3812
|
approval: "full-auto", // read-only via the tool filter, so nothing prompts
|
|
3325
3813
|
confirm: h.confirm,
|
|
3326
3814
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
3815
|
+
hooks: false,
|
|
3327
3816
|
systemOverride: REVIEW_SYSTEM,
|
|
3328
3817
|
memory: buildMemory(),
|
|
3329
3818
|
stats,
|
|
@@ -3422,6 +3911,7 @@ program.action(async (opts) => {
|
|
|
3422
3911
|
approval: "suggest",
|
|
3423
3912
|
confirm: h.confirm,
|
|
3424
3913
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
3914
|
+
hooks: false,
|
|
3425
3915
|
extraTools: [exitPlanTool],
|
|
3426
3916
|
systemOverride: PLAN_SYSTEM,
|
|
3427
3917
|
memory: buildMemory(),
|