@nanhara/hara 0.89.0 → 0.95.2

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/dist/index.js CHANGED
@@ -2,24 +2,26 @@
2
2
  import { Command } from "commander";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { emitKeypressEvents } from "node:readline";
5
- import { runTui } from "./tui/run.js";
5
+ import { runTui, askConfirm } from "./tui/run.js";
6
6
  import { readClipboardImage, mediaTypeFor } from "./images.js";
7
7
  import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
8
8
  import { setTheme } from "./tui/theme.js";
9
9
  import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
10
10
  import { nextMode as cycleMode } from "./tui/InputBox.js";
11
11
  import { stdin, stdout } from "node:process";
12
+ import { execFileSync } from "node:child_process";
12
13
  import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
13
14
  import { homedir, tmpdir } from "node:os";
14
15
  import { fileURLToPath } from "node:url";
15
- import { dirname, join } from "node:path";
16
- import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
16
+ import { dirname, join, relative } from "node:path";
17
+ import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
17
18
  import { runAgent } from "./agent/loop.js";
18
19
  import { notifyDone } from "./notify.js";
19
20
  import { startMcpServer, mcpServeToolNames } from "./mcp/server.js";
20
21
  import { completionScript } from "./completions.js";
21
22
  import { renderSessionMarkdown } from "./export.js";
22
- import { loadEnrollment, clearEnrollment, enrollDevice, heartbeat, gatewayBaseURL, syncOrgRoles } from "./org-fleet/enroll.js";
23
+ import { clearEnrollment, enrollDevice, heartbeat, syncOrgRoles } from "./org-fleet/enroll.js";
24
+ import { loadActiveProfile, listProfiles, useProfile, addProfile, upsertProfile, removeProfile, setModel as setProfileModel, resetModel as resetProfileModel, getProfile, effectiveModel, routingLabel, routeHost, activeId, resolveActive, setFlagOverride, writePin, removePin, pinFilePath, DEFAULT_ORG_ID, PERSONAL_ID, } from "./profile/profile.js";
23
25
  import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
24
26
  import { routingProvider } from "./agent/route.js";
25
27
  import { shouldAutoCompact } from "./agent/compact.js";
@@ -42,9 +44,10 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
42
44
  import { searchHybrid } from "./search/hybrid.js";
43
45
  import { expandMentions, fileCandidates } from "./context/mentions.js";
44
46
  import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
47
+ import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
45
48
  import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
46
49
  import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
47
- import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
50
+ import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
48
51
  import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
49
52
  import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
50
53
  import { connectMcpServers, closeMcp } from "./mcp/client.js";
@@ -65,6 +68,7 @@ import "./tools/skill.js"; // register the skill loader tool
65
68
  import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
66
69
  import "./tools/todo.js"; // register todo_write (inline task checklist)
67
70
  import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
71
+ import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
68
72
  import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
69
73
  const here = dirname(fileURLToPath(import.meta.url));
70
74
  // Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
@@ -82,24 +86,41 @@ const pkg = {
82
86
  };
83
87
  const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
84
88
  async function buildProvider(cfg) {
85
- if (cfg.provider === "qwen-oauth") {
89
+ // Identity-profile is the source of truth for routing. `cfg` is the *merged* HaraConfig (env +
90
+ // project + global) and still drives non-routing concerns (model overrides, baseURL fallbacks
91
+ // for things like vision/route/fallback sidecars). The active profile decides "where to send
92
+ // requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
93
+ const ap = loadActiveProfile();
94
+ // CFG-OVERRIDE PATH: when a sidecar (vision / route / fallback) calls buildProvider with a tweaked
95
+ // cfg that explicitly carries an apiKey + baseURL, honor those over the profile — they're the
96
+ // sidecar's intended target. Detected by "cfg.apiKey present + cfg.baseURL present and we're not
97
+ // routing to a gateway." This keeps `withRouting`/vision unchanged.
98
+ const isSidecarOverride = !!cfg.apiKey && !!cfg.baseURL && ap.kind === "byok" && cfg.apiKey !== ap.apiKey;
99
+ if (ap.kind === "gateway" && !isSidecarOverride) {
100
+ if (!ap.gatewayUrl || !ap.deviceToken)
101
+ return null;
102
+ const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
103
+ const model = cfg.model || effectiveModel(ap);
104
+ return createOpenAIProvider({ apiKey: ap.deviceToken, baseURL, model, label: "hara-gateway", reasoningEffort: cfg.reasoningEffort });
105
+ }
106
+ // BYOK paths — use the active profile's provider/key/baseURL by default, but let the merged cfg
107
+ // override (so `--profile`/`HARA_PROFILE`-overridden values flow + sidecar provider builds work).
108
+ const provider = (cfg.provider && cfg.provider !== "hara-gateway" ? cfg.provider : ap.provider) || "anthropic";
109
+ const apiKey = cfg.apiKey ?? ap.apiKey;
110
+ const baseURL = cfg.baseURL ?? ap.baseURL;
111
+ const model = cfg.model || effectiveModel(ap);
112
+ if (provider === "qwen-oauth") {
86
113
  const auth = await getValidQwenAuth();
87
114
  if (!auth)
88
115
  return null;
89
- return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model: cfg.model, label: "qwen-oauth" });
90
- }
91
- if (cfg.provider === "hara-gateway") {
92
- const e = loadEnrollment();
93
- if (!e)
94
- return null; // not enrolled → `hara enroll`
95
- return createOpenAIProvider({ apiKey: e.deviceToken, baseURL: gatewayBaseURL(e), model: cfg.model || e.model, label: "hara-gateway" });
116
+ return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model, label: "qwen-oauth", reasoningEffort: cfg.reasoningEffort });
96
117
  }
97
- if (!cfg.apiKey)
118
+ if (!apiKey)
98
119
  return null;
99
- if (cfg.provider === "anthropic") {
100
- return createAnthropicProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL });
120
+ if (provider === "anthropic") {
121
+ return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
101
122
  }
102
- return createOpenAIProvider({ apiKey: cfg.apiKey, model: cfg.model, baseURL: cfg.baseURL, label: cfg.provider });
123
+ return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
103
124
  }
104
125
  /** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
105
126
  * turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
@@ -111,9 +132,13 @@ async function withRouting(primary, cfg) {
111
132
  return alt ? routingProvider(primary, alt) : primary;
112
133
  }
113
134
  function authHint(cfg) {
114
- if (cfg.provider === "qwen-oauth")
135
+ const ap = loadActiveProfile();
136
+ if (ap.kind === "gateway")
137
+ return `Active profile '${ap.id}' is a gateway profile but is missing deviceToken — re-enroll with \`hara profile add ${ap.id} --gateway <url> --code <code>\`.`;
138
+ const provider = ap.provider ?? cfg.provider;
139
+ if (provider === "qwen-oauth")
115
140
  return `Run ${c.bold("hara login qwen")} to authenticate.`;
116
- return `Set ${c.bold(providerEnvKey(cfg.provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
141
+ return `Set ${c.bold(providerEnvKey(provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
117
142
  }
118
143
  const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
119
144
  /** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model → ~/.hara/config.json. */
@@ -236,8 +261,10 @@ async function runOrg(task, o) {
236
261
  }
237
262
  }
238
263
  out(c.dim(`→ ${role.id} owns this task\n`));
239
- const roleProvider = role.model && role.model !== o.cfg.model
240
- ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider)
264
+ // Role-model resolution: respect role.model by default; --force collapses everything to cfg.model.
265
+ const __roleModel = effectiveRoleModel(role.model, o.cfg.model);
266
+ const roleProvider = __roleModel
267
+ ? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
241
268
  : o.baseProvider;
242
269
  const toolFilter = role.allowTools
243
270
  ? (n) => role.allowTools.includes(n)
@@ -273,7 +300,8 @@ async function runOrg(task, o) {
273
300
  }
274
301
  // Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
275
302
  const reviewer = roles.find((r) => r.id === "reviewer");
276
- const revProvider = reviewer?.model && reviewer.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: reviewer.model })) ?? o.baseProvider) : o.baseProvider;
303
+ const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
304
+ const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
277
305
  const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
278
306
  const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
279
307
  const maxRounds = Math.max(1, o.rounds ?? 3);
@@ -325,7 +353,8 @@ async function executeAtom(atom, plan, done, roles, o) {
325
353
  atom.status = "running";
326
354
  savePlan(o.cwd, plan);
327
355
  const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
328
- const roleProvider = role?.model && role.model !== o.cfg.model ? ((await buildProvider({ ...o.cfg, model: role.model })) ?? o.baseProvider) : o.baseProvider;
356
+ const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
357
+ const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
329
358
  const toolFilter = role?.allowTools
330
359
  ? (n) => role.allowTools.includes(n)
331
360
  : role?.denyTools
@@ -559,7 +588,8 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
559
588
  async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
560
589
  const roles = loadRoles(cwd);
561
590
  const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
562
- const provider = role?.model && role.model !== cfg.model ? ((await buildProvider({ ...cfg, model: role.model })) ?? baseProvider) : baseProvider;
591
+ const __subModel = effectiveRoleModel(role?.model, cfg.model);
592
+ const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
563
593
  // A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
564
594
  // further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
565
595
  // Write-capable roles run in the main loop via `hara org`, behind the user's gate.
@@ -637,10 +667,27 @@ program
637
667
  .option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
638
668
  .option("-m, --model <model>", "model id (overrides config)")
639
669
  .option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
640
- .option("--profile <name>", "use a named profile from ~/.hara/config.json")
670
+ .option("--profile <id>", "use this identity profile for this run (personal / org id) — see `hara profile list`")
671
+ .option("--overlay <name>", "apply a named config overlay from ~/.hara/config.json (legacy: --profile)")
641
672
  .option("-c, --continue", "resume the most recent session in this directory")
642
673
  .option("--resume <id>", "resume a specific session by id")
643
674
  .option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
675
+ // Wire the global `--profile <id>` flag into the resolution chain BEFORE any subcommand
676
+ // action runs. resolveActive() consults setFlagOverride() at the top of the priority chain,
677
+ // so this single hook covers `hara whoami`, `hara profile list`, `hara model …`, and the
678
+ // default REPL action — without each subcommand having to reach into program.opts() itself.
679
+ // Validation: unknown id is a hard fail (don't silently fall through to default; the user
680
+ // asked for a specific identity, surface the mistake).
681
+ program.hook("preAction", (thisCmd) => {
682
+ const flag = thisCmd.opts().profile;
683
+ if (!flag)
684
+ return;
685
+ if (!getProfile(flag)) {
686
+ out(c.red(`No identity profile '${flag}'.\n`) + c.dim("List: `hara profile list`\n"));
687
+ process.exit(1);
688
+ }
689
+ setFlagOverride(flag);
690
+ });
644
691
  program
645
692
  .command("init")
646
693
  .description("analyze the project and (re)generate AGENTS.md")
@@ -664,8 +711,33 @@ program
664
711
  return;
665
712
  }
666
713
  for (const m of metas) {
667
- out(`${c.bold(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.provider}:${m.model} ${m.title}\n`);
714
+ out(`${c.bold(shortId(m.id))} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${c.dim(m.provider + ":" + m.model)} ${m.title || c.dim("(untitled)")}\n`);
715
+ }
716
+ out(c.dim("\nResume: hara resume <id>\n"));
717
+ });
718
+ program
719
+ .command("resume [id]")
720
+ .description("resume a session — no id resumes the most recent here (list ids with `hara sessions`)")
721
+ .action((id) => {
722
+ let full;
723
+ if (id) {
724
+ full = resolveSessionId(id) ?? undefined;
725
+ if (!full) {
726
+ out(c.red(`No session matching '${id}'.`) + c.dim(" Run `hara sessions` to list.\n"));
727
+ process.exit(1);
728
+ }
668
729
  }
730
+ else {
731
+ const latest = latestForCwd(process.cwd());
732
+ if (!latest) {
733
+ out(c.dim("No sessions for this directory yet — `hara sessions` lists all.\n"));
734
+ process.exit(0);
735
+ }
736
+ full = latest.meta.id;
737
+ }
738
+ out(c.dim(`↩ resuming ${shortId(full)}…\n`));
739
+ // reuse the existing --resume path exactly (one engine), inheriting this terminal
740
+ execFileSync(process.execPath, [process.argv[1], "--resume", full], { stdio: "inherit" });
669
741
  });
670
742
  program
671
743
  .command("org <task...>")
@@ -800,30 +872,407 @@ program
800
872
  .command("setup")
801
873
  .description("interactive first-run setup — pick a provider, API key, and model")
802
874
  .action(runSetup);
875
+ // ────────────────────────────────────────────────────────────────────────────────
876
+ // Identity profiles — the single switch for "who am I as right now" (personal vs each
877
+ // org I belong to). Switching a profile flips provider, key/token, base URL, AND the
878
+ // default model the gateway / setup chose. See src/profile/profile.ts.
879
+ // ────────────────────────────────────────────────────────────────────────────────
880
+ function fmtProfile(p, mark = "") {
881
+ const kindBadge = p.kind === "gateway" ? c.bold(c.cyan("ORG")) : c.bold(c.dim("PERSONAL"));
882
+ const label = p.label ? `${c.bold(p.label)} ` : "";
883
+ const model = effectiveModel(p) || c.dim("(unset)");
884
+ const route = routingLabel(p);
885
+ return `${mark} ${kindBadge} ${label}${c.dim("[" + p.id + "]")} ${c.dim("· model")} ${model} ${c.dim("· →")} ${route}`;
886
+ }
887
+ /** Human-readable suffix for the active row: "(active · <where it came from>)".
888
+ * pin gets a relative file path; flag/env/default each get their own tag. */
889
+ function activeSuffix(r) {
890
+ switch (r.source) {
891
+ case "flag":
892
+ return c.dim("(active · ") + c.bold("--profile flag") + c.dim(")");
893
+ case "env":
894
+ return c.dim("(active · ") + c.bold("HARA_PROFILE env") + c.dim(")");
895
+ case "pin": {
896
+ const rel = r.pinFile ? relPath(r.pinFile) : ".hara-profile";
897
+ return c.dim("(active · ") + c.bold("pinned by " + rel) + c.dim(")");
898
+ }
899
+ case "default":
900
+ return c.dim("(active · ") + c.bold("global default") + c.dim(")");
901
+ case "fallback":
902
+ return c.dim("(active · fallback)");
903
+ }
904
+ }
905
+ /** Render an absolute path relative to cwd. Same-dir paths get `./` for clarity
906
+ * ("pinned by ./.hara-profile" reads better than "pinned by .hara-profile" because
907
+ * the leading `.` of the filename is otherwise hard to spot). Parent-dir pins keep
908
+ * their relative form (`../../.hara-profile`) — still way more readable than absolute. */
909
+ function relPath(abs) {
910
+ try {
911
+ const r = relative(process.cwd(), abs);
912
+ if (!r)
913
+ return ".";
914
+ // r could be: ".hara-profile", "sub/.hara-profile", "../.hara-profile".
915
+ // For the same-cwd hit we want `./.hara-profile` — start with "./" unless it already
916
+ // navigates with ".." (which speaks for itself).
917
+ if (r.startsWith(".."))
918
+ return r;
919
+ return "./" + r;
920
+ }
921
+ catch {
922
+ return abs;
923
+ }
924
+ }
925
+ /** Stable "▶ active" line — first thing printed at startup so the user always sees where requests
926
+ * are going. Tests look for this prefix; keep the format. */
927
+ export function activeProfileLine(p) {
928
+ const route = routingLabel(p);
929
+ const model = effectiveModel(p) || "(unset)";
930
+ return `▶ ${p.label || p.id} · ${model} · ${route}`;
931
+ }
932
+ /** Shared whoami body so the `profile current` alias reuses the same output exactly. */
933
+ function printWhoami() {
934
+ const r = resolveActive();
935
+ const p = loadActiveProfile();
936
+ out(c.bold("active profile") + " " + activeSuffix(r) + "\n" + fmtProfile(p, " ") + "\n");
937
+ if (p.kind === "gateway") {
938
+ out(c.dim(` gateway: ${p.gatewayUrl}\n`));
939
+ if (p.deviceId)
940
+ out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
941
+ if (p.availableModels?.length)
942
+ out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
943
+ }
944
+ else {
945
+ out(c.dim(` provider: ${p.provider}\n`));
946
+ if (p.baseURL)
947
+ out(c.dim(` baseURL: ${p.baseURL}\n`));
948
+ out(c.dim(` key: ${p.apiKey ? maskKey(p.apiKey) : "(env / unset)"}\n`));
949
+ }
950
+ }
951
+ program
952
+ .command("whoami")
953
+ .description("show the active identity profile (label · model · routing target · source)")
954
+ .action(printWhoami);
955
+ const profileCmd = program.command("profile").description("manage identity profiles (personal / org A / org B…)");
956
+ // `profile current` — nvm muscle-memory ("nvm current" → "hara profile current"). Same as `hara whoami`.
957
+ profileCmd
958
+ .command("current")
959
+ .description("alias of `hara whoami` — print the active identity profile (with source)")
960
+ .action(printWhoami);
961
+ // ── `profile list` (alias `ls`) ────────────────────────────────────────────────
962
+ // Layout: profiles grouped by kind (PERSONAL above ORG), one line per profile, columns
963
+ // aligned across the whole table (so id/model/routing visually stack). Active row is
964
+ // prefixed with `→ *` (so you can read it at a glance even in copy-pasted output) and
965
+ // suffixed with the source tag. Footer is a 2-line hint pointing at the two switching
966
+ // gestures: `profile use <id>` (write the default), `profile pin <id>` (lock this dir).
967
+ function renderProfileList() {
968
+ const r = resolveActive();
969
+ const ps = listProfiles();
970
+ const lines = [];
971
+ // Group by kind so the "where am I in the world" stratification is visible.
972
+ const groups = [
973
+ { kind: "byok", title: "PERSONAL", rows: ps.filter((p) => p.kind === "byok") },
974
+ { kind: "gateway", title: "ORG", rows: ps.filter((p) => p.kind === "gateway") },
975
+ ];
976
+ // Column widths from raw (un-styled) strings — styling never participates in padding.
977
+ const idW = Math.max(2, ...ps.map((p) => p.id.length));
978
+ const labelW = Math.max(0, ...ps.map((p) => (p.label || "").length));
979
+ const modelW = Math.max(5, ...ps.map((p) => (effectiveModel(p) || "(unset)").length));
980
+ for (const g of groups) {
981
+ if (!g.rows.length)
982
+ continue;
983
+ if (lines.length)
984
+ lines.push(""); // blank between groups
985
+ lines.push(c.dim(g.title));
986
+ for (const p of g.rows) {
987
+ const isActive = p.id === r.id;
988
+ const mark = isActive ? c.green("→ *") : " ";
989
+ const id = p.id.padEnd(idW, " ");
990
+ const label = (p.label || "").padEnd(labelW, " ");
991
+ const model = (effectiveModel(p) || "(unset)").padEnd(modelW, " ");
992
+ const route = routingLabel(p);
993
+ const tail = isActive ? " " + activeSuffix(r) : "";
994
+ const cols = `${mark} ${c.dim("[")}${c.bold(id)}${c.dim("]")} ${label} ${c.dim("· model")} ${model} ${c.dim("· →")} ${route}${tail}`;
995
+ lines.push(cols);
996
+ }
997
+ }
998
+ // Tail hint — nudge users toward the two everyday gestures.
999
+ lines.push("");
1000
+ lines.push(c.dim("💡 use ") + "`hara profile use <id>`" + c.dim(" to switch · ") + "`hara profile pin <id>`" + c.dim(" to lock to this dir"));
1001
+ return lines.join("\n");
1002
+ }
1003
+ profileCmd
1004
+ .command("list")
1005
+ .alias("ls")
1006
+ .description("list all profiles (active marked with → *) — alias: `ls`")
1007
+ .action(() => {
1008
+ out(renderProfileList() + "\n");
1009
+ });
1010
+ profileCmd
1011
+ .command("use <id>")
1012
+ .description("switch the active profile (echoes the diff: profile / model / routing)")
1013
+ .option("-y, --yes", "skip confirmation when switching INTO a gateway profile from BYOK")
1014
+ .action(async (id, opts) => {
1015
+ const before = loadActiveProfile();
1016
+ const target = getProfile(id);
1017
+ if (!target) {
1018
+ out(c.red(`No profile '${id}'.\n`) + c.dim("List: `hara profile list`\n"));
1019
+ process.exit(1);
1020
+ }
1021
+ // Safety: BYOK → gateway is the direction that changes where your traffic goes (from your own
1022
+ // key to a controlled gateway). Confirm unless -y. The reverse direction is allowed silently
1023
+ // but the diff is still echoed.
1024
+ if (before.kind === "byok" && target.kind === "gateway" && !opts.yes) {
1025
+ const ok = await askConfirm(`Switch to gateway profile '${id}' (${target.gatewayUrl})? Traffic will route through the org gateway.`);
1026
+ if (!ok) {
1027
+ out(c.dim("(unchanged)\n"));
1028
+ return;
1029
+ }
1030
+ }
1031
+ const r = useProfile(id);
1032
+ if (!r.ok) {
1033
+ out(c.red(r.reason + "\n"));
1034
+ process.exit(1);
1035
+ }
1036
+ const after = r.profile;
1037
+ const modelBefore = effectiveModel(before) || "(unset)";
1038
+ const modelAfter = effectiveModel(after) || "(unset)";
1039
+ const routeBefore = routingLabel(before);
1040
+ const routeAfter = routingLabel(after);
1041
+ out(c.green("✓ switched\n"));
1042
+ out(` profile: ${c.dim(before.id)} ${c.dim("→")} ${c.bold(after.id)}\n`);
1043
+ out(` model: ${c.dim(modelBefore)} ${c.dim("→")} ${c.bold(modelAfter)}\n`);
1044
+ out(` routing: ${c.dim(routeBefore)} ${c.dim("→")} ${c.bold(routeAfter)}\n`);
1045
+ });
1046
+ profileCmd
1047
+ .command("add <id>")
1048
+ .description("add a new identity profile (gateway = `hara enroll`; byok = your own key)")
1049
+ .option("--gateway <url>", "(gateway) join this hara-control gateway")
1050
+ .option("--code <code>", "(gateway) enrollment code from your admin")
1051
+ .option("--label <label>", "human-friendly label for the profile")
1052
+ .option("--byok", "(byok) BYOK profile — bring your own provider key")
1053
+ .option("--provider <id>", "(byok) anthropic | qwen | openai | qwen-oauth")
1054
+ .option("--key <key>", "(byok) API key (else read from the provider's env var at use-time)")
1055
+ .option("--base-url <url>", "(byok) override the provider base URL (OpenAI-compatible endpoints)")
1056
+ .option("--model <model>", "(byok) default model for this profile")
1057
+ .action(async (id, opts) => {
1058
+ if (opts.gateway) {
1059
+ if (!opts.code)
1060
+ return void out(c.red("gateway profile add needs --code <code> from your hara-control admin\n"));
1061
+ try {
1062
+ const e = await enrollDevice(opts.gateway, opts.code);
1063
+ const p = {
1064
+ id,
1065
+ kind: "gateway",
1066
+ label: opts.label || id,
1067
+ gatewayUrl: e.gatewayUrl,
1068
+ deviceId: e.deviceId,
1069
+ deviceToken: e.deviceToken,
1070
+ baseURL: e.baseURL,
1071
+ defaultModel: e.model || "",
1072
+ availableModels: e.model ? [e.model] : [],
1073
+ enrolledAt: e.enrolledAt,
1074
+ };
1075
+ upsertProfile(p); // upsert: re-enrolling the same id rotates the token
1076
+ const r = useProfile(id);
1077
+ if (r.ok) {
1078
+ out(c.green(`✓ enrolled and switched to '${id}' (${e.gatewayUrl})`) + c.dim(` · model ${p.defaultModel || "(gateway default)"}\n`));
1079
+ const nRoles = await syncOrgRoles();
1080
+ if (nRoles > 0)
1081
+ out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
1082
+ }
1083
+ }
1084
+ catch (err) {
1085
+ out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
1086
+ process.exit(1);
1087
+ }
1088
+ return;
1089
+ }
1090
+ if (opts.byok || opts.provider) {
1091
+ const provider = (opts.provider || "anthropic");
1092
+ if (provider === "hara-gateway")
1093
+ return void out(c.red("`--provider hara-gateway` is retired — use --gateway <url> --code <code> instead.\n"));
1094
+ const p = {
1095
+ id,
1096
+ kind: "byok",
1097
+ label: opts.label || id,
1098
+ provider,
1099
+ apiKey: opts.key,
1100
+ baseURL: opts.baseUrl,
1101
+ defaultModel: opts.model,
1102
+ };
1103
+ const r = addProfile(p);
1104
+ if (!r.ok) {
1105
+ out(c.red(r.reason + "\n"));
1106
+ process.exit(1);
1107
+ }
1108
+ out(c.green(`✓ added BYOK profile '${id}'`) + c.dim(` · provider ${provider}${opts.model ? " · model " + opts.model : ""}\n`));
1109
+ out(c.dim(`Switch to it with \`hara profile use ${id}\`.\n`));
1110
+ return;
1111
+ }
1112
+ out(c.red("usage:\n") + c.dim(" hara profile add <id> --gateway <url> --code <code> [--label …]\n") + c.dim(" hara profile add <id> --byok --provider anthropic|qwen|openai|qwen-oauth [--key … --base-url … --model …]\n"));
1113
+ process.exit(1);
1114
+ });
1115
+ profileCmd
1116
+ .command("remove <id>")
1117
+ .alias("rm")
1118
+ .alias("uninstall")
1119
+ .description("remove a profile (active falls back to personal) — aliases: `rm`, `uninstall`")
1120
+ .action((id) => {
1121
+ // Capture the profile before removal so we can mention the gateway host in the token-hint
1122
+ // line (5 below). After removeProfile, getProfile(id) is gone.
1123
+ const before = getProfile(id);
1124
+ const r = removeProfile(id);
1125
+ if (!r.ok) {
1126
+ out(c.red(r.reason + "\n"));
1127
+ process.exit(1);
1128
+ }
1129
+ if (r.activeChanged) {
1130
+ // Single line that reads naturally: "removed 'X' · active → personal".
1131
+ out(c.green(`✓ removed '${id}'`) + c.dim(` · active → ${PERSONAL_ID}\n`));
1132
+ }
1133
+ else {
1134
+ out(c.green(`✓ removed '${id}'\n`));
1135
+ }
1136
+ // For gateway profiles: we deliberately do NOT phone the control plane to revoke the device
1137
+ // token (that's a privileged operation that needs admin auth + we don't want a stale CLI
1138
+ // calling production). Print a one-line hint so the user knows the *server-side* identity
1139
+ // outlives this local removal — and who to ask if they want it gone there too.
1140
+ if (r.removedKind === "gateway") {
1141
+ const host = (() => {
1142
+ try {
1143
+ return before?.gatewayUrl ? new URL(before.gatewayUrl).host : (before?.gatewayUrl || "the gateway");
1144
+ }
1145
+ catch {
1146
+ return before?.gatewayUrl || "the gateway";
1147
+ }
1148
+ })();
1149
+ out(c.dim(`💡 token left registered at ${host}; ask your admin to revoke if needed\n`));
1150
+ }
1151
+ });
1152
+ // ── `.hara-profile` project pin (like .nvmrc but personal — keep it out of repos) ─────
1153
+ profileCmd
1154
+ .command("pin [id]")
1155
+ .description("write `.hara-profile` in this dir to lock the active profile here (omit id = pin current active)")
1156
+ .action((id) => {
1157
+ const target = (id && id.trim()) || activeId();
1158
+ if (!getProfile(target)) {
1159
+ out(c.red(`No profile '${target}'.\n`) + c.dim("List: `hara profile list`\n"));
1160
+ process.exit(1);
1161
+ }
1162
+ try {
1163
+ const { file } = writePin(process.cwd(), target);
1164
+ out(c.green(`✓ pinned ${target} to ${relPath(file)}\n`));
1165
+ // .hara-profile carries personal identity (which org you're as), unlike .nvmrc which
1166
+ // is project-level. Nudge user toward GLOBAL gitignore so they don't accidentally
1167
+ // commit it. We intentionally do NOT modify .gitignore — that's user space.
1168
+ out(c.dim("💡 .hara-profile is personal identity — add it to your global gitignore (unlike .nvmrc, don't commit it)\n"));
1169
+ }
1170
+ catch (err) {
1171
+ out(c.red(`pin failed: ${err instanceof Error ? err.message : String(err)}\n`));
1172
+ process.exit(1);
1173
+ }
1174
+ });
1175
+ profileCmd
1176
+ .command("unpin")
1177
+ .description("remove `.hara-profile` from this dir")
1178
+ .action(() => {
1179
+ const file = pinFilePath(process.cwd());
1180
+ const ok = removePin(process.cwd());
1181
+ if (ok)
1182
+ out(c.green(`✓ unpinned`) + c.dim(` · removed ${relPath(file)}\n`));
1183
+ else
1184
+ out(c.dim(`(no ${relPath(file)} here — nothing to unpin)\n`));
1185
+ });
1186
+ // ── per-profile model switching ──────────────────────────────────────────────────
1187
+ const modelCmd = program.command("model").description("manage the model on the active profile");
1188
+ modelCmd
1189
+ .command("list")
1190
+ .description("list models for the active profile (gateway profiles list what the control plane advertised)")
1191
+ .action(() => {
1192
+ const p = loadActiveProfile();
1193
+ const cur = effectiveModel(p);
1194
+ if (p.kind === "gateway") {
1195
+ const list = p.availableModels?.length ? p.availableModels : (p.defaultModel ? [p.defaultModel] : []);
1196
+ if (!list.length) {
1197
+ out(c.dim("(gateway didn't advertise any models — use the gateway default; `hara model use <id>` to override locally)\n"));
1198
+ return;
1199
+ }
1200
+ for (const m of list)
1201
+ out(`${m === cur ? c.green("*") : " "} ${m}\n`);
1202
+ }
1203
+ else {
1204
+ // BYOK has no constrained list — show the current effective + suggestion.
1205
+ out(`${c.green("*")} ${cur || c.dim("(unset)")}\n`);
1206
+ out(c.dim("(BYOK profiles accept any model id the provider supports — `hara model use <id>` to switch)\n"));
1207
+ }
1208
+ });
1209
+ modelCmd
1210
+ .command("use <model>")
1211
+ .description("override the model on the active profile (validated against availableModels on gateway profiles)")
1212
+ .action((model) => {
1213
+ const id = activeId();
1214
+ const r = setProfileModel(id, model);
1215
+ if (!r.ok) {
1216
+ out(c.red(r.reason + "\n"));
1217
+ process.exit(1);
1218
+ }
1219
+ out(c.green(`✓ model → ${model}`) + c.dim(` (profile ${id})\n`));
1220
+ });
1221
+ modelCmd
1222
+ .command("reset")
1223
+ .description("clear the per-profile model override → fall back to defaultModel")
1224
+ .action(() => {
1225
+ const id = activeId();
1226
+ const r = resetProfileModel(id);
1227
+ if (!r.ok) {
1228
+ out(c.red(r.reason + "\n"));
1229
+ process.exit(1);
1230
+ }
1231
+ const p = loadActiveProfile();
1232
+ out(c.green(`✓ reset`) + c.dim(` · effective model → ${effectiveModel(p) || "(unset)"}\n`));
1233
+ });
1234
+ // ── `hara enroll` — kept as a convenience alias mapping to the default-org gateway profile.
803
1235
  program
804
1236
  .command("enroll [gateway-url]")
805
- .description("B-end: join a fleet trade a one-time code for a device token (routes hara through your org's gateway; no provider key on this device)")
1237
+ .description("alias of `hara profile add default-org --gateway <url> --code <code>` (B-end: join a fleet)")
806
1238
  .option("--code <code>", "enrollment code from your hara-control admin")
807
- .option("--status", "show the current enrollment")
808
- .option("--clear", "remove the enrollment (revert to your own provider config)")
1239
+ .option("--status", "alias of `hara whoami`")
1240
+ .option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
809
1241
  .action(async (gatewayUrl, opts) => {
810
1242
  if (opts.status) {
811
- const e = loadEnrollment();
812
- return void out(e ? c.green("enrolled") + c.dim(` · ${e.gatewayUrl} · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · since ${e.enrolledAt}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
1243
+ const p = loadActiveProfile();
1244
+ return void out(p.kind === "gateway" ? c.green("enrolled") + c.dim(` · ${p.gatewayUrl} · device ${p.deviceId || "?"} · model ${effectiveModel(p) || "(gateway default)"} · since ${p.enrolledAt || "?"}\n`) : c.dim("Not enrolled — `hara enroll <gateway-url> --code <code>`.\n"));
1245
+ }
1246
+ if (opts.clear) {
1247
+ // Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
1248
+ // just switch active back to personal. Legacy clearEnrollment() also called to remove any
1249
+ // stray org.json file from pre-migration installs.
1250
+ clearEnrollment();
1251
+ const r = useProfile(PERSONAL_ID);
1252
+ return void out(r.ok ? c.green("✓ active → personal") + c.dim(" — gateway profile preserved (remove with `hara profile remove default-org`)\n") : c.dim("(no change)\n"));
813
1253
  }
814
- if (opts.clear)
815
- return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
816
1254
  if (!gatewayUrl)
817
1255
  return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
818
1256
  if (!opts.code)
819
1257
  return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
820
1258
  try {
821
1259
  const e = await enrollDevice(gatewayUrl, opts.code);
822
- writeConfigValue("provider", "hara-gateway");
823
- if (e.model)
824
- writeConfigValue("model", e.model);
825
- out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
826
- const nRoles = await syncOrgRoles(); // pull this device's governed digital-employee bundle (B3)
1260
+ const p = {
1261
+ id: DEFAULT_ORG_ID,
1262
+ kind: "gateway",
1263
+ label: "Default Org",
1264
+ gatewayUrl: e.gatewayUrl,
1265
+ deviceId: e.deviceId,
1266
+ deviceToken: e.deviceToken,
1267
+ baseURL: e.baseURL,
1268
+ defaultModel: e.model || "",
1269
+ availableModels: e.model ? [e.model] : [],
1270
+ enrolledAt: e.enrolledAt,
1271
+ };
1272
+ upsertProfile(p);
1273
+ useProfile(DEFAULT_ORG_ID);
1274
+ out(c.green(`✓ enrolled with ${e.gatewayUrl}`) + c.dim(` · device ${e.deviceId || "?"} · model ${e.model || "(gateway default)"} · profile ${DEFAULT_ORG_ID}\n`) + c.dim("hara routes through the gateway now — the real provider key stays server-side.\n"));
1275
+ const nRoles = await syncOrgRoles();
827
1276
  if (nRoles > 0)
828
1277
  out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
829
1278
  }
@@ -855,7 +1304,7 @@ program
855
1304
  program
856
1305
  .command("gateway")
857
1306
  .description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
858
- .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk", "telegram")
1307
+ .option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
859
1308
  .option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
860
1309
  .option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
861
1310
  .action(async (opts) => {
@@ -867,6 +1316,66 @@ program
867
1316
  const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
868
1317
  await mod.runGateway({ cwd, platform: opts.platform });
869
1318
  });
1319
+ program
1320
+ .command("remote [action] [text]")
1321
+ .description("drive THIS tmux session from chat: register the pane so WeChat replies inject back into it. actions: ask \"<q>\" | bind | back | status")
1322
+ .action(async (action = "status", text) => {
1323
+ const { registerTmuxRoute, unbindPane, listRoutes } = await import("./gateway/tmux-routes.js");
1324
+ const pane = process.env.TMUX_PANE; // set by tmux inside every pane
1325
+ const needPane = () => {
1326
+ if (!pane) {
1327
+ out(c.red("`hara remote` must run inside tmux ($TMUX_PANE unset) — it injects chat replies into a tmux pane.\n"));
1328
+ process.exit(2);
1329
+ }
1330
+ };
1331
+ if (action === "status") {
1332
+ const rs = listRoutes();
1333
+ out(rs.length ? rs.map((r) => `${r.pane} [${r.mode ?? "once"}] ${r.cwd ?? ""}`).join("\n") + "\n" : "(no panes registered)\n");
1334
+ return;
1335
+ }
1336
+ if (action === "unbind" || action === "back") {
1337
+ needPane();
1338
+ out(unbindPane(pane) ? `✓ ${action === "back" ? "back from remote — unbound" : "unbound"} ${pane}\n` : `${pane} was not registered\n`);
1339
+ return;
1340
+ }
1341
+ if (action === "bind") {
1342
+ needPane();
1343
+ registerTmuxRoute(pane, undefined, process.cwd(), "bind");
1344
+ out(c.green(`🔗 bound ${pane}`) + ` — every WeChat reply now injects here until \`hara remote unbind\` (or send /detach in chat). Daemon must be running.\n`);
1345
+ return;
1346
+ }
1347
+ if (action === "ask") {
1348
+ needPane();
1349
+ if (!text)
1350
+ return void out(c.red('usage: hara remote ask "<question>"\n'));
1351
+ registerTmuxRoute(pane, undefined, process.cwd(), "once"); // register first — inbound inject works even if the push is throttled
1352
+ try {
1353
+ const wx = await import("./gateway/weixin.js");
1354
+ const creds = wx.loadWeixinCreds();
1355
+ if (!creds)
1356
+ return void out(c.yellow(`↩ ${pane} registered, but no WeChat login (run \`hara gateway --platform weixin --login\`). Your next reply to the bot still injects here.\n`));
1357
+ let peer = process.env.HARA_WX_PEER;
1358
+ if (!peer) {
1359
+ try {
1360
+ const f = join(homedir(), ".hara", "weixin", `${creds.account_id}.context-tokens.json`);
1361
+ const keys = Object.keys(JSON.parse(readFileSync(f, "utf8")));
1362
+ peer = keys.find((k) => k.endsWith("@im.wechat")) || keys[0];
1363
+ }
1364
+ catch {
1365
+ /* no peer file */
1366
+ }
1367
+ }
1368
+ if (peer)
1369
+ await wx.weixinAdapter(creds).send(peer, text);
1370
+ out(c.green(`↩ asked on WeChat + registered ${pane}`) + ` — reply on WeChat and it'll be injected here. Daemon must be running.\n`);
1371
+ }
1372
+ catch (e) {
1373
+ out(c.yellow(`↩ ${pane} registered; WeChat push failed (${e.message}) — your next reply to the bot still injects here.\n`));
1374
+ }
1375
+ return;
1376
+ }
1377
+ out(c.red(`unknown action '${action}'. use: ask "<q>" | bind | back | status\n`));
1378
+ });
870
1379
  program
871
1380
  .command("export [session]")
872
1381
  .description("export a session to a Markdown transcript (default: the latest in this directory)")
@@ -1217,6 +1726,14 @@ pluginCmd
1217
1726
  m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
1218
1727
  ].filter(Boolean);
1219
1728
  out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
1729
+ // A plugin can ship CLI commands (manifest `bin`); they're linked into ~/.hara/bin. Tell the user to PATH it.
1730
+ const bins = Object.keys(m.bin ?? {});
1731
+ if (bins.length) {
1732
+ const onPath = (process.env.PATH ?? "").split(":").includes(haraBinDir());
1733
+ out(c.green(`Linked command(s): ${bins.join(", ")} → ${c.dim(haraBinDir())}\n`));
1734
+ if (!onPath)
1735
+ out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
1736
+ }
1220
1737
  // Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
1221
1738
  // hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
1222
1739
  const execs = [];
@@ -1289,6 +1806,10 @@ config
1289
1806
  out(c.red(`Invalid sandbox mode. One of: ${SANDBOX_MODES.join(", ")}.\n`));
1290
1807
  process.exit(1);
1291
1808
  }
1809
+ if (key === "reasoningEffort" && !REASONING_EFFORTS.includes(value)) {
1810
+ out(c.red(`Invalid reasoning effort. One of: ${REASONING_EFFORTS.join(", ")}.\n`));
1811
+ process.exit(1);
1812
+ }
1292
1813
  writeConfigValue(key, value);
1293
1814
  out(c.green(`Set ${key} → ${configPath()}\n`));
1294
1815
  });
@@ -1316,7 +1837,11 @@ config
1316
1837
  .action(() => out(configPath() + "\n"));
1317
1838
  // default action (interactive REPL / one-shot)
1318
1839
  program.action(async (opts) => {
1319
- const cfg = loadConfig({ profile: opts.profile });
1840
+ // Identity-profile selection (--profile flag) is now handled by the program-level preAction
1841
+ // hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
1842
+ // pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
1843
+ // in the priority chain than --profile).
1844
+ const cfg = loadConfig({ overlay: opts.overlay });
1320
1845
  if (opts.model)
1321
1846
  cfg.model = opts.model;
1322
1847
  const provider0 = await withRouting(await buildProvider(cfg), cfg);
@@ -1338,7 +1863,18 @@ program.action(async (opts) => {
1338
1863
  process.exit(1);
1339
1864
  }
1340
1865
  let provider = provider0;
1341
- if (cfg.provider === "hara-gateway") {
1866
+ // Active profile is the source of truth for gateway-side concerns (heartbeat / role sync).
1867
+ // Legacy: cfg.provider==='hara-gateway' kept for users still pointing config.json at the old
1868
+ // sentinel — but profile.kind is what the rest of the CLI now reasons about.
1869
+ const __activeP = loadActiveProfile();
1870
+ // Safety UX: first line of stdout = "where am I sending requests right now". Stable, scriptable,
1871
+ // and reassuring at the start of every session. Suppressed in pure -p print mode to keep that
1872
+ // path clean stdout-only (the user wants the model output, not banner noise). Set HARA_QUIET=1
1873
+ // to suppress everywhere.
1874
+ if (!opts.print && process.env.HARA_QUIET !== "1") {
1875
+ out(c.dim(activeProfileLine(__activeP)) + "\n");
1876
+ }
1877
+ if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
1342
1878
  void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
1343
1879
  void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
1344
1880
  }
@@ -1389,6 +1925,35 @@ program.action(async (opts) => {
1389
1925
  if (prior?.history)
1390
1926
  history.push(...prior.history);
1391
1927
  meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
1928
+ // Apply per-session pinned model on headless resume (mirrors the interactive path).
1929
+ // --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
1930
+ if (prior) {
1931
+ if (opts.model) {
1932
+ meta.model = cfg.model;
1933
+ }
1934
+ else if (meta.model && meta.model !== cfg.model) {
1935
+ const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
1936
+ if (__allowed && !__activeP.availableModels.includes(meta.model)) {
1937
+ const __fb = __activeP.defaultModel || cfg.model;
1938
+ // headless: log to stderr so it doesn't pollute the captured stdout reply
1939
+ try {
1940
+ process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
1941
+ }
1942
+ catch { /* ignore */ }
1943
+ cfg.model = __fb;
1944
+ meta.model = __fb;
1945
+ const __rb = await buildProvider(cfg);
1946
+ if (__rb)
1947
+ provider = __rb;
1948
+ }
1949
+ else {
1950
+ cfg.model = meta.model;
1951
+ const __rb = await buildProvider(cfg);
1952
+ if (__rb)
1953
+ provider = __rb;
1954
+ }
1955
+ }
1956
+ }
1392
1957
  }
1393
1958
  // Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
1394
1959
  // Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
@@ -1477,7 +2042,10 @@ program.action(async (opts) => {
1477
2042
  /* keypress unavailable; /approval still works */
1478
2043
  }
1479
2044
  }
1480
- if (!hasAgentsMd(cwd)) {
2045
+ // First-run AGENTS.md offer — classic REPL only. In TUI mode we must NOT call rl.question before ink
2046
+ // mounts: a readline question puts stdin in a state ink can't read from, leaving the input box dead
2047
+ // (the TUI shows a `/init` tip instead, below). See the `tip` in the runTui header.
2048
+ if (!hasAgentsMd(cwd) && !useTui) {
1481
2049
  const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
1482
2050
  if (ans === "" || ans.startsWith("y")) {
1483
2051
  out(c.dim("Analyzing project…\n"));
@@ -1513,11 +2081,43 @@ program.action(async (opts) => {
1513
2081
  createdAt: new Date().toISOString(),
1514
2082
  updatedAt: "",
1515
2083
  };
2084
+ // Per-session model precedence on resume:
2085
+ // 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
2086
+ // 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
2087
+ // 3. otherwise leave cfg.model as the profile-resolved default.
2088
+ // Safety: if we're on a gateway profile with a finite availableModels list and the resumed
2089
+ // meta.model isn't in it (e.g. user switched profiles between sessions), warn and degrade to
2090
+ // profile.defaultModel — a stale pinned model shouldn't brick the resume.
2091
+ if (resumed) {
2092
+ if (opts.model) {
2093
+ // explicit --model on the command line wins; persist it onto the session.
2094
+ meta.model = cfg.model;
2095
+ }
2096
+ else if (meta.model && meta.model !== cfg.model) {
2097
+ const __ap = __activeP;
2098
+ const __allowed = __ap.kind === "gateway" && __ap.availableModels && __ap.availableModels.length > 0;
2099
+ if (__allowed && !__ap.availableModels.includes(meta.model)) {
2100
+ const __fallback = __ap.defaultModel || cfg.model;
2101
+ out(c.yellow(`⚠ resumed session was pinned to '${meta.model}', which isn't in this profile's availableModels (${__ap.availableModels.join(", ")}). Falling back to '${__fallback}'.\n`));
2102
+ cfg.model = __fallback;
2103
+ meta.model = __fallback;
2104
+ const __rebuilt = await buildProvider(cfg);
2105
+ if (__rebuilt)
2106
+ provider = __rebuilt;
2107
+ }
2108
+ else {
2109
+ cfg.model = meta.model;
2110
+ const __rebuilt = await buildProvider(cfg);
2111
+ if (__rebuilt)
2112
+ provider = __rebuilt;
2113
+ }
2114
+ }
2115
+ }
1516
2116
  const history = resumed?.history ? [...resumed.history] : [];
1517
2117
  const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
1518
2118
  const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
1519
2119
  if (resumed)
1520
- out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs)\n`));
2120
+ out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs · model = ${cfg.model})\n`));
1521
2121
  // Vision describer state — shared by the `/vision` command (both REPLs) and the TUI image pipeline.
1522
2122
  let visionProvider;
1523
2123
  let remindedVision = false;
@@ -1580,24 +2180,48 @@ program.action(async (opts) => {
1580
2180
  },
1581
2181
  {
1582
2182
  name: "model",
1583
- desc: "show or switch model: /model [id]",
2183
+ desc: "show or switch model: /model [id [--force|all]]",
1584
2184
  run: async (a) => {
1585
- if (a) {
1586
- cfg.model = a;
1587
- visionProvider = undefined;
1588
- remindedVision = false;
1589
- const p = await buildProvider(cfg);
1590
- if (p) {
1591
- provider = p;
1592
- if (bar.isActive())
1593
- bar.update({ model: a });
1594
- out(c.dim(`(model ${cfg.provider}:${a})\n`));
2185
+ const parts = (a || "").trim().split(/\s+/).filter(Boolean);
2186
+ const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
2187
+ const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
2188
+ if (!id) {
2189
+ // Bare /model: pinned model + per-role overrides table, so the user sees what's pinned now
2190
+ // and which roles deviate from it.
2191
+ const __force = isSessionForceModel();
2192
+ const __lines = [`${cfg.provider}:${cfg.model}`];
2193
+ if (meta.model && meta.model !== cfg.model) {
2194
+ __lines.push(c.dim(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`));
1595
2195
  }
1596
- else
1597
- out(c.red("(could not rebuild provider)\n"));
2196
+ else {
2197
+ __lines.push(c.dim(`session pinned: ${meta.model || "(none)"}${__force ? c.yellow(" · forced (all roles use session model)") : ""}`));
2198
+ }
2199
+ const __roles = loadRoles(cwd);
2200
+ if (__roles.length) {
2201
+ __lines.push(c.dim("roles:"));
2202
+ for (const r of __roles) {
2203
+ const eff = __force ? cfg.model : (r.model || cfg.model);
2204
+ const tag = __force && r.model && r.model !== cfg.model ? c.yellow(" (overridden by --force)") : r.model ? c.dim(" (role pin)") : c.dim(" (session)");
2205
+ __lines.push(` ${r.id}: ${eff}${tag}`);
2206
+ }
2207
+ }
2208
+ return void out(__lines.join("\n") + "\n");
2209
+ }
2210
+ cfg.model = id;
2211
+ meta.model = id;
2212
+ setSessionForceModel(force);
2213
+ visionProvider = undefined;
2214
+ remindedVision = false;
2215
+ const p = await buildProvider(cfg);
2216
+ if (p) {
2217
+ provider = p;
2218
+ if (bar.isActive())
2219
+ bar.update({ model: id });
2220
+ saveSession(meta, history); // persist the session-pinned model so resume restores it
2221
+ out(c.dim(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})\n`));
1598
2222
  }
1599
2223
  else
1600
- out(`${cfg.provider}:${cfg.model}\n`);
2224
+ out(c.red("(could not rebuild provider)\n"));
1601
2225
  },
1602
2226
  },
1603
2227
  {
@@ -1789,6 +2413,21 @@ program.action(async (opts) => {
1789
2413
  }
1790
2414
  if (useTui) {
1791
2415
  rl.close(); // hand stdin over to ink
2416
+ // First-run AGENTS.md offer — via a tiny ink prompt, NOT readline. A readline question before the
2417
+ // main TUI leaves stdin unreadable by ink (dead input box); ink cleans up on unmount, so the TUI
2418
+ // mounted right after gets working input. Runs before mount, like the classic path.
2419
+ if (!hasAgentsMd(cwd)) {
2420
+ if (await askConfirm("No AGENTS.md here — analyze this project and create one?")) {
2421
+ out(c.dim("Analyzing project…\n"));
2422
+ try {
2423
+ await runInit(provider, cwd, sandbox);
2424
+ }
2425
+ catch (e) {
2426
+ out(c.red(`[init error] ${e.message}\n`));
2427
+ }
2428
+ projectContext = loadAgentsMd(cwd) || undefined;
2429
+ }
2430
+ }
1792
2431
  setTheme(cfg.theme);
1793
2432
  // Vision: a text-only main model routes pasted images through a describer (`visionModel`); a
1794
2433
  // vision-capable main model gets them inline (describer auto-suspended). Unknown models are asked
@@ -1876,19 +2515,48 @@ program.action(async (opts) => {
1876
2515
  return { skip: true };
1877
2516
  }
1878
2517
  };
1879
- const mainCap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
1880
- const visionLine = mainCap === "vision"
1881
- ? `${cfg.model} reads images directly`
1882
- : cfg.visionModel
1883
- ? `${cfg.model} is text-only images read by ${cfg.visionModel}`
1884
- : mainCap === "text"
1885
- ? `${cfg.model} is text-only/vision <model> to read pasted images`
1886
- : `${cfg.model} image support unknown asked on first paste`;
2518
+ // ── Header (rebuilt per 顾雅 spec, 2026-06):
2519
+ // • Single-line logo + tagline (no ASCII banner block).
2520
+ // • Identity line branches on profile kind: personal collapses to `personal <provider>:<model>`
2521
+ // (route host only when baseURL is custom); org spreads to `org <label> · <id> → <host>`
2522
+ // plus its own `model` line annotated with the source (org default / user override).
2523
+ // • cwd line silently appends "· AGENTS.md" when loaded — we never show a negative noise line.
2524
+ // • Vision routing is NOT in the header anymore App emits a one-shot inline notice via
2525
+ // `visionNotice` the first time an image lands in the session.
2526
+ const __mainCap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
2527
+ const __routeForHeader = routeHost(__activeP);
2528
+ // Model-source label (org only). `loadConfig` already merges env > project > overlay > globals,
2529
+ // so cfg.model is whatever the runtime will actually use. If it equals the profile's defaultModel
2530
+ // we treat it as "org default"; otherwise it's a user override (per-profile setModel, env, or flag).
2531
+ const __modelSource = __activeP.kind === "gateway"
2532
+ ? cfg.model && __activeP.defaultModel && cfg.model === __activeP.defaultModel
2533
+ ? "org default"
2534
+ : "user override"
2535
+ : undefined;
2536
+ // Lazy vision notice: only set it for the "describer in use" path (header used to always-on it).
2537
+ // Native-vision models stay silent (the routing IS direct, nothing to say). "Unknown" stays silent
2538
+ // too — the existing per-image picker (resolveImages) handles that on first paste.
2539
+ const __visionNotice = __mainCap === "text" && cfg.visionModel
2540
+ ? `${cfg.model} is text-only — images read by ${cfg.visionModel}`
2541
+ : undefined;
1887
2542
  await runTui({
1888
2543
  initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
1889
2544
  model: cfg.model,
1890
2545
  cwd,
1891
- header: { version: pkg.version, model: `${cfg.provider}:${cfg.model}`, cwd, vision: visionLine, session: meta.id, tip: `/help · @file attaches · shift+tab cycles modes · esc interrupts${projectContext ? " · AGENTS.md loaded" : ""}` },
2546
+ header: {
2547
+ version: pkg.version,
2548
+ modelLabel: `${cfg.provider}:${cfg.model}`,
2549
+ cwd,
2550
+ agentsMdLoaded: !!projectContext,
2551
+ session: meta.id,
2552
+ kind: __activeP.kind === "gateway" ? "org" : "personal",
2553
+ profileId: __activeP.kind === "gateway" || __activeP.id === PERSONAL_ID ? undefined : __activeP.id,
2554
+ orgLabel: __activeP.kind === "gateway" ? __activeP.label : undefined,
2555
+ orgId: __activeP.kind === "gateway" ? __activeP.deviceId || __activeP.id : undefined,
2556
+ routeHost: __routeForHeader?.host,
2557
+ modelSource: __modelSource,
2558
+ },
2559
+ visionNotice: __visionNotice,
1892
2560
  cycleApproval: (m) => cycleMode(m),
1893
2561
  onClipboardImage: readClipboardImage,
1894
2562
  vim: cfg.vimMode,
@@ -1933,15 +2601,39 @@ program.action(async (opts) => {
1933
2601
  return void h.sink.notice("error" in r ? `(${r.error})` : `↩ reverted: ${r.files.join(", ")}`);
1934
2602
  }
1935
2603
  if (nm === "model") {
1936
- if (!arg)
1937
- return void h.sink.notice(`model: ${cfg.provider}:${cfg.model}`);
1938
- cfg.model = arg;
2604
+ const parts = (arg || "").trim().split(/\s+/).filter(Boolean);
2605
+ const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
2606
+ const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
2607
+ if (!id) {
2608
+ const __force = isSessionForceModel();
2609
+ const __lines = [`model: ${cfg.provider}:${cfg.model}`];
2610
+ if (meta.model && meta.model !== cfg.model) {
2611
+ __lines.push(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`);
2612
+ }
2613
+ else {
2614
+ __lines.push(`session pinned: ${meta.model || "(none)"}${__force ? " · forced (all roles use session model)" : ""}`);
2615
+ }
2616
+ const __roles = loadRoles(cwd);
2617
+ if (__roles.length) {
2618
+ __lines.push("roles:");
2619
+ for (const r of __roles) {
2620
+ const eff = __force ? cfg.model : (r.model || cfg.model);
2621
+ const tag = __force && r.model && r.model !== cfg.model ? " (overridden by --force)" : r.model ? " (role pin)" : " (session)";
2622
+ __lines.push(` ${r.id}: ${eff}${tag}`);
2623
+ }
2624
+ }
2625
+ return void h.sink.notice(__lines.join("\n"));
2626
+ }
2627
+ cfg.model = id;
2628
+ meta.model = id;
2629
+ setSessionForceModel(force);
1939
2630
  visionProvider = undefined; // new model may resolve a different describer / capability
1940
2631
  remindedVision = false;
1941
2632
  const p = await buildProvider(cfg);
1942
2633
  if (p) {
1943
2634
  provider = p;
1944
- return void h.sink.notice(`(model ${cfg.provider}:${arg})`);
2635
+ saveSession(meta, history); // persist the session-pinned model so resume restores it
2636
+ return void h.sink.notice(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})`);
1945
2637
  }
1946
2638
  return void h.sink.notice("(could not rebuild provider)");
1947
2639
  }
@@ -2095,6 +2787,37 @@ program.action(async (opts) => {
2095
2787
  }
2096
2788
  if (byName.has(nm))
2097
2789
  return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
2790
+ // /<skill> — a user-invocable skill (built-in/global/plugin). ENTER it: load the skill + run a kickoff
2791
+ // turn so the agent acts at once (e.g. design mode opens its live workspace + surfaces prior progress).
2792
+ {
2793
+ const sk = loadSkillIndex(cwd).find((s) => s.id === nm && s.userInvocable);
2794
+ if (sk) {
2795
+ h.sink.notice(`↗ entering ${sk.id}…`);
2796
+ history.push({
2797
+ role: "user",
2798
+ content: `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${arg ? ` — request: ${arg}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`,
2799
+ });
2800
+ const skin = stats.input;
2801
+ const skout = stats.output;
2802
+ // `h.approval` is the TUI-level union (includes "plan"); runAgent wants the config-level
2803
+ // ApprovalMode (no "plan"). Inside a /<skill> kickoff "plan" wouldn't make sense anyway —
2804
+ // fall back to "suggest" so we keep the user's confirm gate without crashing the type check.
2805
+ const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
2806
+ try {
2807
+ await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt });
2808
+ }
2809
+ catch (e) {
2810
+ h.sink.notice(`[error] ${e?.message ?? e}`);
2811
+ }
2812
+ if (!meta.title) {
2813
+ meta.title = await nameSession(provider, history);
2814
+ h.sink.session(meta.title);
2815
+ }
2816
+ h.sink.usage(stats.input - skin, stats.output - skout);
2817
+ saveSession(meta, history);
2818
+ return;
2819
+ }
2820
+ }
2098
2821
  const near = nearest(nm, [...byName.keys()]);
2099
2822
  return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
2100
2823
  }
@@ -2205,11 +2928,12 @@ program.action(async (opts) => {
2205
2928
  await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
2206
2929
  },
2207
2930
  });
2931
+ out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
2208
2932
  await closeMcp();
2209
2933
  process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
2210
2934
  }
2211
2935
  out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${projectContext ? " (AGENTS.md loaded)" : ""}\n\n`));
2212
- bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output });
2936
+ bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
2213
2937
  process.on("exit", () => {
2214
2938
  try {
2215
2939
  bar.uninstall();
@@ -2234,6 +2958,30 @@ program.action(async (opts) => {
2234
2958
  const [name, ...rest] = line.slice(1).split(/\s+/);
2235
2959
  const cmd = byName.get(name);
2236
2960
  if (!cmd) {
2961
+ const sk = loadSkillIndex(cwd).find((s) => s.id === name && s.userInvocable);
2962
+ if (sk) {
2963
+ // ENTER the mode: load the skill + run a kickoff turn now (mirrors the TUI path) so e.g. /design
2964
+ // opens its workspace + surfaces prior progress immediately, instead of just staging context.
2965
+ out(c.dim(`↗ entering ${sk.id}…\n`));
2966
+ history.push({
2967
+ role: "user",
2968
+ content: `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${rest.length ? ` — request: ${rest.join(" ")}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`,
2969
+ });
2970
+ currentTurn = new AbortController();
2971
+ try {
2972
+ await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
2973
+ }
2974
+ catch (e) {
2975
+ out(c.red(`\n[error] ${e.message}\n`));
2976
+ }
2977
+ finally {
2978
+ currentTurn = null;
2979
+ }
2980
+ if (!meta.title)
2981
+ meta.title = await nameSession(provider, history);
2982
+ saveSession(meta, history);
2983
+ continue;
2984
+ }
2237
2985
  const near = nearest(name, [...byName.keys()]);
2238
2986
  const hint = near.length ? c.dim(` Did you mean ${near.map((n) => "/" + n).join(", ")}?`) : "";
2239
2987
  out(c.red(`Unknown command /${name}.`) + hint + c.dim(" — /help for the list.\n"));
@@ -2282,6 +3030,7 @@ program.action(async (opts) => {
2282
3030
  }
2283
3031
  }
2284
3032
  bar.uninstall();
3033
+ out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
2285
3034
  rl.close();
2286
3035
  await closeMcp();
2287
3036
  });