@nanhara/hara 0.70.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/CHANGELOG.md +395 -0
- package/README.md +7 -2
- package/dist/agent/compact.js +10 -0
- package/dist/agent/context-report.js +33 -0
- package/dist/agent/failover.js +53 -0
- package/dist/agent/loop.js +120 -13
- package/dist/agent/rewind.js +20 -0
- package/dist/agent/route.js +47 -0
- package/dist/checkpoints.js +91 -0
- package/dist/config.js +33 -9
- package/dist/context/subdir-hints.js +76 -0
- package/dist/cron/runner.js +7 -4
- package/dist/exec/jobs.js +82 -0
- package/dist/gateway/dingtalk.js +209 -0
- package/dist/gateway/discord.js +164 -0
- package/dist/gateway/feishu.js +144 -0
- package/dist/gateway/matrix.js +188 -0
- package/dist/gateway/mattermost.js +206 -0
- package/dist/gateway/serve.js +339 -0
- package/dist/gateway/sessions.js +89 -0
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/slack.js +190 -0
- package/dist/gateway/telegram.js +115 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/tts.js +100 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/gateway/weixin.js +590 -0
- package/dist/index.js +1073 -120
- package/dist/org-fleet/enroll.js +28 -5
- package/dist/plugins/plugins.js +49 -1
- package/dist/profile/profile.js +436 -0
- package/dist/providers/anthropic.js +28 -1
- package/dist/providers/openai.js +16 -1
- package/dist/sandbox.js +15 -12
- package/dist/security/external-content.js +57 -0
- package/dist/security/permissions.js +211 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/builtin.js +49 -2
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/send.js +35 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tools/web.js +4 -3
- package/dist/tui/App.js +241 -30
- package/dist/tui/run.js +36 -1
- package/package.json +4 -2
package/dist/index.js
CHANGED
|
@@ -2,24 +2,32 @@
|
|
|
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";
|
|
6
|
-
import { readClipboardImage } from "./images.js";
|
|
5
|
+
import { runTui, askConfirm } from "./tui/run.js";
|
|
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 {
|
|
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";
|
|
25
|
+
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
26
|
+
import { routingProvider } from "./agent/route.js";
|
|
27
|
+
import { shouldAutoCompact } from "./agent/compact.js";
|
|
28
|
+
import { formatContextReport } from "./agent/context-report.js";
|
|
29
|
+
import { userTurnPreviews, rewindTo } from "./agent/rewind.js";
|
|
30
|
+
import { checkpoint, listCheckpoints, restoreCheckpoint } from "./checkpoints.js";
|
|
23
31
|
import { mapLimit, maxParallel } from "./concurrency.js";
|
|
24
32
|
import { parseVerdict, captureChanges, reviewPrompt, fixPrompt, REVIEWER_SYSTEM, isTreeClean, stripCommitFence } from "./org/review-chain.js";
|
|
25
33
|
import { parseSchedule, describeSchedule, nextRun } from "./cron/schedule.js";
|
|
@@ -36,9 +44,10 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
|
|
|
36
44
|
import { searchHybrid } from "./search/hybrid.js";
|
|
37
45
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
38
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";
|
|
39
48
|
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
40
49
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
41
|
-
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";
|
|
42
51
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
43
52
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
44
53
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
@@ -58,6 +67,8 @@ import "./tools/memory.js"; // register memory_search/get/write/forget/skill_cre
|
|
|
58
67
|
import "./tools/skill.js"; // register the skill loader tool
|
|
59
68
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
60
69
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
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)
|
|
61
72
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
62
73
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
63
74
|
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
@@ -75,29 +86,59 @@ const pkg = {
|
|
|
75
86
|
};
|
|
76
87
|
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
77
88
|
async function buildProvider(cfg) {
|
|
78
|
-
|
|
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") {
|
|
79
113
|
const auth = await getValidQwenAuth();
|
|
80
114
|
if (!auth)
|
|
81
115
|
return null;
|
|
82
|
-
return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model
|
|
83
|
-
}
|
|
84
|
-
if (cfg.provider === "hara-gateway") {
|
|
85
|
-
const e = loadEnrollment();
|
|
86
|
-
if (!e)
|
|
87
|
-
return null; // not enrolled → `hara enroll`
|
|
88
|
-
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 });
|
|
89
117
|
}
|
|
90
|
-
if (!
|
|
118
|
+
if (!apiKey)
|
|
91
119
|
return null;
|
|
92
|
-
if (
|
|
93
|
-
return createAnthropicProvider({ apiKey
|
|
120
|
+
if (provider === "anthropic") {
|
|
121
|
+
return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
|
|
94
122
|
}
|
|
95
|
-
return createOpenAIProvider({ apiKey
|
|
123
|
+
return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
|
|
124
|
+
}
|
|
125
|
+
/** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
|
|
126
|
+
* turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
|
|
127
|
+
* routeModel is unset or equals the primary model. routeBaseURL/routeApiKey default to the primary's. */
|
|
128
|
+
async function withRouting(primary, cfg) {
|
|
129
|
+
if (!primary || !cfg.routeModel || cfg.routeModel === cfg.model)
|
|
130
|
+
return primary;
|
|
131
|
+
const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
|
|
132
|
+
return alt ? routingProvider(primary, alt) : primary;
|
|
96
133
|
}
|
|
97
134
|
function authHint(cfg) {
|
|
98
|
-
|
|
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")
|
|
99
140
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
100
|
-
return `Set ${c.bold(providerEnvKey(
|
|
141
|
+
return `Set ${c.bold(providerEnvKey(provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
|
|
101
142
|
}
|
|
102
143
|
const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", "qwen-oauth": "coder-model" };
|
|
103
144
|
/** Interactive first-run setup: pick a provider, (optional) base URL, API key, and model → ~/.hara/config.json. */
|
|
@@ -220,8 +261,10 @@ async function runOrg(task, o) {
|
|
|
220
261
|
}
|
|
221
262
|
}
|
|
222
263
|
out(c.dim(`→ ${role.id} owns this task\n`));
|
|
223
|
-
|
|
224
|
-
|
|
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)
|
|
225
268
|
: o.baseProvider;
|
|
226
269
|
const toolFilter = role.allowTools
|
|
227
270
|
? (n) => role.allowTools.includes(n)
|
|
@@ -257,7 +300,8 @@ async function runOrg(task, o) {
|
|
|
257
300
|
}
|
|
258
301
|
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
259
302
|
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
260
|
-
const
|
|
303
|
+
const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
|
|
304
|
+
const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
|
|
261
305
|
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
262
306
|
const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
|
|
263
307
|
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
@@ -309,7 +353,8 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
309
353
|
atom.status = "running";
|
|
310
354
|
savePlan(o.cwd, plan);
|
|
311
355
|
const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
|
|
312
|
-
const
|
|
356
|
+
const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
|
|
357
|
+
const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
|
|
313
358
|
const toolFilter = role?.allowTools
|
|
314
359
|
? (n) => role.allowTools.includes(n)
|
|
315
360
|
: role?.denyTools
|
|
@@ -486,20 +531,65 @@ const MEMORY_DISTILL_SYSTEM = "You consolidate an agent's short-term daily memor
|
|
|
486
531
|
"conventions / user preferences from the logs that are NOT already captured, and persist each with " +
|
|
487
532
|
"memory_write (target=memory, or target=user for preferences; pick the right scope=project|global). " +
|
|
488
533
|
"Skip the ephemeral, the one-off, and anything already known. Be terse and de-duplicated. Then reply DONE.";
|
|
489
|
-
const COMPACT_SYSTEM = "Summarize the conversation so far into a
|
|
490
|
-
"
|
|
491
|
-
"
|
|
534
|
+
const COMPACT_SYSTEM = "Summarize the conversation so far into a structured, complete brief so the assistant can continue with NO " +
|
|
535
|
+
"loss of context. First think privately in a brief <analysis> scratchpad (what matters, what's in flight), " +
|
|
536
|
+
"then output ONLY the summary under these exact headings:\n" +
|
|
537
|
+
"1. Goal — the user's overall intent, in their own framing.\n" +
|
|
538
|
+
"2. Key decisions — choices made and why (so they aren't relitigated).\n" +
|
|
539
|
+
"3. Files & code — files created/changed and the important snippets, with why each matters.\n" +
|
|
540
|
+
"4. Errors & fixes — failures hit, how they were resolved, and any correction the user gave (quote pointed feedback verbatim).\n" +
|
|
541
|
+
"5. Current state — what works now / what is verified.\n" +
|
|
542
|
+
"6. Next step — the immediate next action, INCLUDING a direct verbatim quote of the user's most recent request so there is no drift.\n" +
|
|
543
|
+
"Be specific and concrete. Drop the <analysis>; output only the headed summary.";
|
|
492
544
|
const workingSetFromSummary = (s) => s
|
|
493
545
|
.split("\n")
|
|
494
546
|
.map((l) => l.replace(/^[-*\d.\s]+/, "").trim())
|
|
495
547
|
.filter((l) => l.length > 3)
|
|
496
548
|
.slice(0, 12)
|
|
497
549
|
.map((l) => l.slice(0, 140));
|
|
550
|
+
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
551
|
+
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
552
|
+
async function compactConversation(provider, history, meta, stats) {
|
|
553
|
+
if (history.length < 2)
|
|
554
|
+
return null;
|
|
555
|
+
const r = await provider.turn({
|
|
556
|
+
system: COMPACT_SYSTEM,
|
|
557
|
+
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
558
|
+
tools: [],
|
|
559
|
+
onText: () => { },
|
|
560
|
+
});
|
|
561
|
+
if (r.stop === "error")
|
|
562
|
+
return null;
|
|
563
|
+
const summary = r.text.trim();
|
|
564
|
+
if (!summary)
|
|
565
|
+
return null;
|
|
566
|
+
meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects into the next turns
|
|
567
|
+
history.length = 0;
|
|
568
|
+
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
569
|
+
stats.input += r.usage?.input ?? 0;
|
|
570
|
+
stats.output += r.usage?.output ?? 0;
|
|
571
|
+
stats.lastInput = r.usage?.input ?? 0; // ctx% now reflects the (small) summary, not the old full turn
|
|
572
|
+
saveSession(meta, history);
|
|
573
|
+
return summary;
|
|
574
|
+
}
|
|
575
|
+
/** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
|
|
576
|
+
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
577
|
+
* a one-line status. Returns true if it compacted. */
|
|
578
|
+
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
579
|
+
const pct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
580
|
+
if (!shouldAutoCompact(pct, history.length, cfg.autoCompact))
|
|
581
|
+
return false;
|
|
582
|
+
notify(`✻ Auto-compacting conversation (context ${pct}% full)…`);
|
|
583
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
584
|
+
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
585
|
+
return !!summary;
|
|
586
|
+
}
|
|
498
587
|
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
499
588
|
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
500
589
|
const roles = loadRoles(cwd);
|
|
501
590
|
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
502
|
-
const
|
|
591
|
+
const __subModel = effectiveRoleModel(role?.model, cfg.model);
|
|
592
|
+
const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
|
|
503
593
|
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
504
594
|
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
505
595
|
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
@@ -577,10 +667,27 @@ program
|
|
|
577
667
|
.option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
|
|
578
668
|
.option("-m, --model <model>", "model id (overrides config)")
|
|
579
669
|
.option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
|
|
580
|
-
.option("--profile <
|
|
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)")
|
|
581
672
|
.option("-c, --continue", "resume the most recent session in this directory")
|
|
582
673
|
.option("--resume <id>", "resume a specific session by id")
|
|
583
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
|
+
});
|
|
584
691
|
program
|
|
585
692
|
.command("init")
|
|
586
693
|
.description("analyze the project and (re)generate AGENTS.md")
|
|
@@ -604,8 +711,33 @@ program
|
|
|
604
711
|
return;
|
|
605
712
|
}
|
|
606
713
|
for (const m of metas) {
|
|
607
|
-
out(`${c.bold(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.provider
|
|
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`);
|
|
608
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
|
+
}
|
|
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" });
|
|
609
741
|
});
|
|
610
742
|
program
|
|
611
743
|
.command("org <task...>")
|
|
@@ -740,30 +872,407 @@ program
|
|
|
740
872
|
.command("setup")
|
|
741
873
|
.description("interactive first-run setup — pick a provider, API key, and model")
|
|
742
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.
|
|
743
1235
|
program
|
|
744
1236
|
.command("enroll [gateway-url]")
|
|
745
|
-
.description("
|
|
1237
|
+
.description("alias of `hara profile add default-org --gateway <url> --code <code>` (B-end: join a fleet)")
|
|
746
1238
|
.option("--code <code>", "enrollment code from your hara-control admin")
|
|
747
|
-
.option("--status", "
|
|
748
|
-
.option("--clear", "
|
|
1239
|
+
.option("--status", "alias of `hara whoami`")
|
|
1240
|
+
.option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
|
|
749
1241
|
.action(async (gatewayUrl, opts) => {
|
|
750
1242
|
if (opts.status) {
|
|
751
|
-
const
|
|
752
|
-
return void out(
|
|
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"));
|
|
753
1253
|
}
|
|
754
|
-
if (opts.clear)
|
|
755
|
-
return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
|
|
756
1254
|
if (!gatewayUrl)
|
|
757
1255
|
return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
|
|
758
1256
|
if (!opts.code)
|
|
759
1257
|
return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
|
|
760
1258
|
try {
|
|
761
1259
|
const e = await enrollDevice(gatewayUrl, opts.code);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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();
|
|
767
1276
|
if (nRoles > 0)
|
|
768
1277
|
out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
|
|
769
1278
|
}
|
|
@@ -771,6 +1280,102 @@ program
|
|
|
771
1280
|
out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
772
1281
|
}
|
|
773
1282
|
});
|
|
1283
|
+
program
|
|
1284
|
+
.command("permissions")
|
|
1285
|
+
.description("show or scaffold command permission rules (bash allow/ask/deny + read-only autorun)")
|
|
1286
|
+
.option("--init", "write a starter permissions.json")
|
|
1287
|
+
.option("--project", "with --init, write it in this project (.hara/permissions.json) instead of globally")
|
|
1288
|
+
.action((opts) => {
|
|
1289
|
+
if (opts.init) {
|
|
1290
|
+
const p = scaffoldPermissions(process.cwd(), opts.project ? "project" : "global");
|
|
1291
|
+
return void out(p ? c.green(`✓ wrote ${p}\n`) : c.dim("(permissions file already exists — edit it directly)\n"));
|
|
1292
|
+
}
|
|
1293
|
+
const r = loadPermissionRules(process.cwd());
|
|
1294
|
+
const pp = projectPermissionsPath(process.cwd());
|
|
1295
|
+
out(c.bold("Command permissions") +
|
|
1296
|
+
c.dim(" (bash) — deny blocks even in full-auto; allow / read-only auto-runs even in suggest\n") +
|
|
1297
|
+
` ${c.dim("global: ")} ${globalPermissionsPath()}\n` +
|
|
1298
|
+
` ${c.dim("project:")} ${pp ?? "(none)"}\n` +
|
|
1299
|
+
` ${c.dim("read-only autorun:")} ${r.readonlyAutorun ? c.green("on") : "off"}\n` +
|
|
1300
|
+
` ${c.green("allow")}: ${r.allow.length ? r.allow.join(", ") : c.dim("(none)")}\n` +
|
|
1301
|
+
` ${c.red("deny")} : ${r.deny.length ? r.deny.join(", ") : c.dim("(none)")}\n` +
|
|
1302
|
+
c.dim(" edit the JSON to customize, or `hara permissions --init` for a starter.\n"));
|
|
1303
|
+
});
|
|
1304
|
+
program
|
|
1305
|
+
.command("gateway")
|
|
1306
|
+
.description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
|
|
1307
|
+
.option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
|
|
1308
|
+
.option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
|
|
1309
|
+
.option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
|
|
1310
|
+
.action(async (opts) => {
|
|
1311
|
+
const mod = await import("./gateway/serve.js");
|
|
1312
|
+
if (opts.platform === "weixin" && opts.login) {
|
|
1313
|
+
await mod.weixinLogin();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
|
|
1317
|
+
await mod.runGateway({ cwd, platform: opts.platform });
|
|
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
|
+
});
|
|
774
1379
|
program
|
|
775
1380
|
.command("export [session]")
|
|
776
1381
|
.description("export a session to a Markdown transcript (default: the latest in this directory)")
|
|
@@ -1121,6 +1726,14 @@ pluginCmd
|
|
|
1121
1726
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
1122
1727
|
].filter(Boolean);
|
|
1123
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
|
+
}
|
|
1124
1737
|
// Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
|
|
1125
1738
|
// hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
|
|
1126
1739
|
const execs = [];
|
|
@@ -1193,6 +1806,10 @@ config
|
|
|
1193
1806
|
out(c.red(`Invalid sandbox mode. One of: ${SANDBOX_MODES.join(", ")}.\n`));
|
|
1194
1807
|
process.exit(1);
|
|
1195
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
|
+
}
|
|
1196
1813
|
writeConfigValue(key, value);
|
|
1197
1814
|
out(c.green(`Set ${key} → ${configPath()}\n`));
|
|
1198
1815
|
});
|
|
@@ -1220,10 +1837,16 @@ config
|
|
|
1220
1837
|
.action(() => out(configPath() + "\n"));
|
|
1221
1838
|
// default action (interactive REPL / one-shot)
|
|
1222
1839
|
program.action(async (opts) => {
|
|
1223
|
-
|
|
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 });
|
|
1224
1845
|
if (opts.model)
|
|
1225
1846
|
cfg.model = opts.model;
|
|
1226
|
-
const provider0 = await buildProvider(cfg);
|
|
1847
|
+
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1848
|
+
const fallbackProvider = provider0 && cfg.fallbackModel && cfg.fallbackModel !== cfg.model ? await buildProvider({ ...cfg, model: cfg.fallbackModel, baseURL: cfg.fallbackBaseURL ?? cfg.baseURL, apiKey: cfg.fallbackApiKey ?? cfg.apiKey }) : null;
|
|
1849
|
+
const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
|
|
1227
1850
|
if (!provider0) {
|
|
1228
1851
|
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1229
1852
|
if (stdin.isTTY && !opts.print) {
|
|
@@ -1240,7 +1863,18 @@ program.action(async (opts) => {
|
|
|
1240
1863
|
process.exit(1);
|
|
1241
1864
|
}
|
|
1242
1865
|
let provider = provider0;
|
|
1243
|
-
|
|
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") {
|
|
1244
1878
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
1245
1879
|
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
1246
1880
|
}
|
|
@@ -1261,16 +1895,112 @@ program.action(async (opts) => {
|
|
|
1261
1895
|
// one-shot
|
|
1262
1896
|
if (opts.print) {
|
|
1263
1897
|
const projectContext = loadAgentsMd(cwd) || undefined;
|
|
1264
|
-
|
|
1898
|
+
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
1899
|
+
// "configure a vision model" even when one is set, leaving a headless agent blind. Mirrors the interactive
|
|
1900
|
+
// describeScreenshot — a configured visionModel, else the main model if it's vision-capable.
|
|
1901
|
+
const describeImage = async (path, hint) => {
|
|
1902
|
+
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
1903
|
+
const vp = cfg.visionModel
|
|
1904
|
+
? ((await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
|
|
1905
|
+
: cap === "vision"
|
|
1906
|
+
? provider
|
|
1907
|
+
: null;
|
|
1908
|
+
if (!vp)
|
|
1909
|
+
return "";
|
|
1910
|
+
try {
|
|
1911
|
+
return await describeImages(vp, [{ path, mediaType: "image/png" }], { system: SCREENSHOT_SYSTEM, hint });
|
|
1912
|
+
}
|
|
1913
|
+
catch {
|
|
1914
|
+
return "";
|
|
1915
|
+
}
|
|
1916
|
+
};
|
|
1917
|
+
// Headless session continuity: --resume <id> / --continue loads the session, appends this prompt, and
|
|
1918
|
+
// saves it back — so `hara -p … --resume <id>` continues a thread (used by cron, scripts, the chat gateway).
|
|
1919
|
+
// Plain `hara -p` stays stateless. A --resume id with no match is created WITH that id (stable per caller).
|
|
1920
|
+
let meta = null;
|
|
1921
|
+
const history = [];
|
|
1922
|
+
if (opts.resume || opts.continue) {
|
|
1923
|
+
const rid = opts.resume ? (resolveSessionId(opts.resume) ?? opts.resume) : latestForCwd(cwd)?.meta.id;
|
|
1924
|
+
const prior = rid ? loadSession(rid) : null;
|
|
1925
|
+
if (prior?.history)
|
|
1926
|
+
history.push(...prior.history);
|
|
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
|
+
}
|
|
1957
|
+
}
|
|
1958
|
+
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
1959
|
+
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
1960
|
+
// visionModel sidecar and folded into the message (text-only models can't take image blocks).
|
|
1961
|
+
const userText = expandMentions(String(opts.print), cwd);
|
|
1962
|
+
const inboundImgs = (process.env.HARA_GATEWAY_IMAGES ?? "")
|
|
1963
|
+
.split("\n")
|
|
1964
|
+
.map((s) => s.trim())
|
|
1965
|
+
.filter((p) => p && existsSync(p))
|
|
1966
|
+
.map((p) => ({ path: p, mediaType: mediaTypeFor(p) ?? "image/jpeg" }));
|
|
1967
|
+
if (inboundImgs.length && classifyVision(cfg.provider, cfg.model, cfg.modelVision) === "vision") {
|
|
1968
|
+
history.push({ role: "user", content: userText, images: inboundImgs }); // native vision → inline
|
|
1969
|
+
}
|
|
1970
|
+
else if (inboundImgs.length && cfg.visionModel) {
|
|
1971
|
+
let desc = "";
|
|
1972
|
+
try {
|
|
1973
|
+
const vp = await buildProvider({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
|
|
1974
|
+
if (vp)
|
|
1975
|
+
desc = await describeImages(vp, inboundImgs);
|
|
1976
|
+
}
|
|
1977
|
+
catch {
|
|
1978
|
+
/* describe is best-effort — fall back to the marker-only text */
|
|
1979
|
+
}
|
|
1980
|
+
const n = inboundImgs.length;
|
|
1981
|
+
history.push({
|
|
1982
|
+
role: "user",
|
|
1983
|
+
content: desc ? `${userText}\n\n[${n} image${n > 1 ? "s" : ""} the user sent — described by ${cfg.visionModel}]\n${desc}` : userText,
|
|
1984
|
+
});
|
|
1985
|
+
}
|
|
1986
|
+
else {
|
|
1987
|
+
history.push({ role: "user", content: userText });
|
|
1988
|
+
}
|
|
1265
1989
|
await runAgent(history, {
|
|
1266
1990
|
provider,
|
|
1267
|
-
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role) },
|
|
1991
|
+
ctx: { cwd, sandbox, spawn: (t, role) => runSubagent(cfg, provider, cwd, sandbox, projectContext, stats, t, role), describeImage },
|
|
1268
1992
|
approval: "full-auto",
|
|
1269
1993
|
confirm: async () => true,
|
|
1270
1994
|
projectContext,
|
|
1271
1995
|
memory: memoryDigest(cwd),
|
|
1272
1996
|
stats,
|
|
1273
1997
|
});
|
|
1998
|
+
if (meta) {
|
|
1999
|
+
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
2000
|
+
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
2001
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, () => { });
|
|
2002
|
+
saveSession(meta, history); // persist when resuming/continuing; plain -p stays stateless
|
|
2003
|
+
}
|
|
1274
2004
|
if (stats.input || stats.output)
|
|
1275
2005
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n");
|
|
1276
2006
|
await closeMcp();
|
|
@@ -1312,7 +2042,10 @@ program.action(async (opts) => {
|
|
|
1312
2042
|
/* keypress unavailable; /approval still works */
|
|
1313
2043
|
}
|
|
1314
2044
|
}
|
|
1315
|
-
|
|
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) {
|
|
1316
2049
|
const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
1317
2050
|
if (ans === "" || ans.startsWith("y")) {
|
|
1318
2051
|
out(c.dim("Analyzing project…\n"));
|
|
@@ -1348,11 +2081,43 @@ program.action(async (opts) => {
|
|
|
1348
2081
|
createdAt: new Date().toISOString(),
|
|
1349
2082
|
updatedAt: "",
|
|
1350
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
|
+
}
|
|
1351
2116
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
1352
2117
|
const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
|
|
1353
2118
|
const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
|
|
1354
2119
|
if (resumed)
|
|
1355
|
-
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`));
|
|
1356
2121
|
// Vision describer state — shared by the `/vision` command (both REPLs) and the TUI image pipeline.
|
|
1357
2122
|
let visionProvider;
|
|
1358
2123
|
let remindedVision = false;
|
|
@@ -1415,24 +2180,48 @@ program.action(async (opts) => {
|
|
|
1415
2180
|
},
|
|
1416
2181
|
{
|
|
1417
2182
|
name: "model",
|
|
1418
|
-
desc: "show or switch model: /model [id]",
|
|
2183
|
+
desc: "show or switch model: /model [id [--force|all]]",
|
|
1419
2184
|
run: async (a) => {
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1428
|
-
|
|
1429
|
-
|
|
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)`));
|
|
1430
2195
|
}
|
|
1431
|
-
else
|
|
1432
|
-
|
|
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`));
|
|
1433
2222
|
}
|
|
1434
2223
|
else
|
|
1435
|
-
out(
|
|
2224
|
+
out(c.red("(could not rebuild provider)\n"));
|
|
1436
2225
|
},
|
|
1437
2226
|
},
|
|
1438
2227
|
{
|
|
@@ -1538,31 +2327,52 @@ program.action(async (opts) => {
|
|
|
1538
2327
|
out(c.green(`↩ reverted: ${r.files.join(", ")}\n`));
|
|
1539
2328
|
},
|
|
1540
2329
|
},
|
|
2330
|
+
{
|
|
2331
|
+
name: "context",
|
|
2332
|
+
desc: "show what's filling the context window (token breakdown by category)",
|
|
2333
|
+
run: () => void out(formatContextReport(history, cfg.model) + "\n"),
|
|
2334
|
+
},
|
|
2335
|
+
{
|
|
2336
|
+
name: "rewind",
|
|
2337
|
+
desc: "fork the conversation back to an earlier turn: /rewind (list) · /rewind <n> (files unchanged)",
|
|
2338
|
+
run: (a) => {
|
|
2339
|
+
const arg = (a ?? "").trim();
|
|
2340
|
+
if (!arg) {
|
|
2341
|
+
const turns = userTurnPreviews(history);
|
|
2342
|
+
return void out(turns.length ? "Recent turns (newest first) — `/rewind <n>` forks from before it (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") + "\n" : c.dim("(nothing to rewind)\n"));
|
|
2343
|
+
}
|
|
2344
|
+
const nh = rewindTo(history, Number(arg));
|
|
2345
|
+
if (!nh)
|
|
2346
|
+
return void out(c.dim(`(no such turn: ${arg})\n`));
|
|
2347
|
+
history.length = 0;
|
|
2348
|
+
history.push(...nh);
|
|
2349
|
+
saveSession(meta, history);
|
|
2350
|
+
out(c.green(`(rewound — dropped the last ${arg} turn(s); ${history.length} messages kept. Files are unchanged. Type your next message.)\n`));
|
|
2351
|
+
},
|
|
2352
|
+
},
|
|
2353
|
+
{
|
|
2354
|
+
name: "checkpoint",
|
|
2355
|
+
desc: "file-state checkpoints: /checkpoint (list) · /checkpoint restore <n> (revert files to a checkpoint)",
|
|
2356
|
+
run: (a) => {
|
|
2357
|
+
const parts = (a ?? "").trim().split(/\s+/);
|
|
2358
|
+
const cps = listCheckpoints(cwd);
|
|
2359
|
+
if (parts[0] !== "restore") {
|
|
2360
|
+
return void out(cps.length ? "File checkpoints (newest first) — `/checkpoint restore <n>` reverts files to it:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") + "\n" : c.dim("(no checkpoints yet — taken before each turn when fileCheckpoints is on)\n"));
|
|
2361
|
+
}
|
|
2362
|
+
const cp = cps[Number(parts[1]) - 1];
|
|
2363
|
+
if (!cp)
|
|
2364
|
+
return void out(c.dim(`(no checkpoint ${parts[1] ?? ""})\n`));
|
|
2365
|
+
const k = restoreCheckpoint(cwd, cp.sha);
|
|
2366
|
+
out(k == null ? c.red("(restore failed)\n") : c.green(`(restored ${k} file(s) to ${cp.sha} — '${cp.label}'; prior state snapshotted too)\n`));
|
|
2367
|
+
},
|
|
2368
|
+
},
|
|
1541
2369
|
{
|
|
1542
2370
|
name: "compact",
|
|
1543
2371
|
desc: "summarize the conversation so far to free up context",
|
|
1544
2372
|
run: async () => {
|
|
1545
|
-
if (history.length < 2)
|
|
1546
|
-
return void out(c.dim("(nothing to compact)\n"));
|
|
1547
2373
|
out(c.dim("Compacting…\n"));
|
|
1548
|
-
const
|
|
1549
|
-
|
|
1550
|
-
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1551
|
-
tools: [],
|
|
1552
|
-
onText: () => { },
|
|
1553
|
-
});
|
|
1554
|
-
if (r.stop === "error")
|
|
1555
|
-
return void out(c.red(`(compact failed: ${r.errorMsg})\n`));
|
|
1556
|
-
const summary = r.text.trim();
|
|
1557
|
-
if (!summary)
|
|
1558
|
-
return void out(c.dim("(compact produced nothing)\n"));
|
|
1559
|
-
meta.workingSet = workingSetFromSummary(summary); // survives the history wipe + injects next turns
|
|
1560
|
-
history.length = 0;
|
|
1561
|
-
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1562
|
-
stats.input += r.usage?.input ?? 0;
|
|
1563
|
-
stats.output += r.usage?.output ?? 0;
|
|
1564
|
-
saveSession(meta, history);
|
|
1565
|
-
out(c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`));
|
|
2374
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
2375
|
+
out(summary ? c.green(`(compacted — ${summary.length} chars; context replaced with the summary)\n`) : c.dim("(nothing to compact / compact failed)\n"));
|
|
1566
2376
|
},
|
|
1567
2377
|
},
|
|
1568
2378
|
{
|
|
@@ -1603,6 +2413,21 @@ program.action(async (opts) => {
|
|
|
1603
2413
|
}
|
|
1604
2414
|
if (useTui) {
|
|
1605
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
|
+
}
|
|
1606
2431
|
setTheme(cfg.theme);
|
|
1607
2432
|
// Vision: a text-only main model routes pasted images through a describer (`visionModel`); a
|
|
1608
2433
|
// vision-capable main model gets them inline (describer auto-suspended). Unknown models are asked
|
|
@@ -1690,19 +2515,48 @@ program.action(async (opts) => {
|
|
|
1690
2515
|
return { skip: true };
|
|
1691
2516
|
}
|
|
1692
2517
|
};
|
|
1693
|
-
|
|
1694
|
-
|
|
1695
|
-
|
|
1696
|
-
|
|
1697
|
-
|
|
1698
|
-
|
|
1699
|
-
|
|
1700
|
-
|
|
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;
|
|
1701
2542
|
await runTui({
|
|
1702
2543
|
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
1703
2544
|
model: cfg.model,
|
|
1704
2545
|
cwd,
|
|
1705
|
-
header: {
|
|
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,
|
|
1706
2560
|
cycleApproval: (m) => cycleMode(m),
|
|
1707
2561
|
onClipboardImage: readClipboardImage,
|
|
1708
2562
|
vim: cfg.vimMode,
|
|
@@ -1747,15 +2601,39 @@ program.action(async (opts) => {
|
|
|
1747
2601
|
return void h.sink.notice("error" in r ? `(${r.error})` : `↩ reverted: ${r.files.join(", ")}`);
|
|
1748
2602
|
}
|
|
1749
2603
|
if (nm === "model") {
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
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);
|
|
1753
2630
|
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
1754
2631
|
remindedVision = false;
|
|
1755
2632
|
const p = await buildProvider(cfg);
|
|
1756
2633
|
if (p) {
|
|
1757
2634
|
provider = p;
|
|
1758
|
-
|
|
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)" : ""})`);
|
|
1759
2637
|
}
|
|
1760
2638
|
return void h.sink.notice("(could not rebuild provider)");
|
|
1761
2639
|
}
|
|
@@ -1776,6 +2654,33 @@ program.action(async (opts) => {
|
|
|
1776
2654
|
saveSession(meta, history);
|
|
1777
2655
|
return void h.sink.notice(`(renamed → ${meta.title})`);
|
|
1778
2656
|
}
|
|
2657
|
+
if (nm === "context")
|
|
2658
|
+
return void h.sink.notice(formatContextReport(history, cfg.model));
|
|
2659
|
+
if (nm === "rewind") {
|
|
2660
|
+
if (!arg) {
|
|
2661
|
+
const turns = userTurnPreviews(history);
|
|
2662
|
+
return void h.sink.notice(turns.length ? "Recent turns (newest first) — /rewind <n> (files unchanged):\n" + turns.map((t) => ` ${t.n}. ${t.preview}`).join("\n") : "(nothing to rewind)");
|
|
2663
|
+
}
|
|
2664
|
+
const nh = rewindTo(history, Number(arg));
|
|
2665
|
+
if (!nh)
|
|
2666
|
+
return void h.sink.notice(`(no such turn: ${arg})`);
|
|
2667
|
+
history.length = 0;
|
|
2668
|
+
history.push(...nh);
|
|
2669
|
+
saveSession(meta, history);
|
|
2670
|
+
return void h.sink.notice(`(rewound — kept ${history.length} messages; files unchanged. Type your next message.)`);
|
|
2671
|
+
}
|
|
2672
|
+
if (nm === "checkpoint") {
|
|
2673
|
+
const parts = arg.split(/\s+/);
|
|
2674
|
+
const cps = listCheckpoints(cwd);
|
|
2675
|
+
if (parts[0] !== "restore") {
|
|
2676
|
+
return void h.sink.notice(cps.length ? "File checkpoints (newest first) — /checkpoint restore <n>:\n" + cps.map((cp, i) => ` ${i + 1}. ${cp.sha} ${cp.label}`).join("\n") : "(no checkpoints yet)");
|
|
2677
|
+
}
|
|
2678
|
+
const cp = cps[Number(parts[1]) - 1];
|
|
2679
|
+
if (!cp)
|
|
2680
|
+
return void h.sink.notice(`(no checkpoint ${parts[1] ?? ""})`);
|
|
2681
|
+
const k = restoreCheckpoint(cwd, cp.sha);
|
|
2682
|
+
return void h.sink.notice(k == null ? "(restore failed)" : `(restored ${k} file(s) to ${cp.sha} — '${cp.label}')`);
|
|
2683
|
+
}
|
|
1779
2684
|
if (nm === "compact") {
|
|
1780
2685
|
if (history.length < 2)
|
|
1781
2686
|
return void h.sink.notice("(nothing to compact)");
|
|
@@ -1799,25 +2704,8 @@ program.action(async (opts) => {
|
|
|
1799
2704
|
/* flush is best-effort */
|
|
1800
2705
|
}
|
|
1801
2706
|
}
|
|
1802
|
-
const
|
|
1803
|
-
|
|
1804
|
-
history: [...history, { role: "user", content: "Summarize our conversation so far per the instructions." }],
|
|
1805
|
-
tools: [],
|
|
1806
|
-
onText: () => { },
|
|
1807
|
-
});
|
|
1808
|
-
if (cr.stop === "error")
|
|
1809
|
-
return void h.sink.notice(`(compact failed: ${cr.errorMsg})`);
|
|
1810
|
-
const summary = cr.text.trim();
|
|
1811
|
-
if (!summary)
|
|
1812
|
-
return void h.sink.notice("(compact produced nothing)");
|
|
1813
|
-
meta.workingSet = workingSetFromSummary(summary);
|
|
1814
|
-
history.length = 0;
|
|
1815
|
-
history.push({ role: "user", content: `Summary of our conversation so far (continue from here):\n\n${summary}` });
|
|
1816
|
-
stats.input += cr.usage?.input ?? 0;
|
|
1817
|
-
stats.output += cr.usage?.output ?? 0;
|
|
1818
|
-
h.sink.usage(cr.usage?.input ?? 0, cr.usage?.output ?? 0);
|
|
1819
|
-
saveSession(meta, history);
|
|
1820
|
-
return void h.sink.notice(`(compacted — kept ${meta.workingSet.length} working-memory notes)`);
|
|
2707
|
+
const summary = await compactConversation(provider, history, meta, stats);
|
|
2708
|
+
return void h.sink.notice(summary ? `(compacted — kept ${meta.workingSet?.length ?? 0} working-memory notes)` : "(nothing to compact / compact failed)");
|
|
1821
2709
|
}
|
|
1822
2710
|
if (nm === "sessions") {
|
|
1823
2711
|
const ms = listSessions();
|
|
@@ -1899,6 +2787,37 @@ program.action(async (opts) => {
|
|
|
1899
2787
|
}
|
|
1900
2788
|
if (byName.has(nm))
|
|
1901
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
|
+
}
|
|
1902
2821
|
const near = nearest(nm, [...byName.keys()]);
|
|
1903
2822
|
return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
|
|
1904
2823
|
}
|
|
@@ -1982,6 +2901,8 @@ program.action(async (opts) => {
|
|
|
1982
2901
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd) + (ri.extraText ?? "");
|
|
1983
2902
|
recalledContext = "";
|
|
1984
2903
|
history.push({ role: "user", content: userContent, ...(ri.attach?.length ? { images: ri.attach } : {}) });
|
|
2904
|
+
if (cfg.fileCheckpoints)
|
|
2905
|
+
checkpoint(cwd, line.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
1985
2906
|
const beforeIn = stats.input;
|
|
1986
2907
|
const beforeOut = stats.output;
|
|
1987
2908
|
await runAgent(history, {
|
|
@@ -1995,6 +2916,7 @@ program.action(async (opts) => {
|
|
|
1995
2916
|
stats,
|
|
1996
2917
|
signal: h.signal,
|
|
1997
2918
|
pendingInput,
|
|
2919
|
+
fallback: fbOpt,
|
|
1998
2920
|
});
|
|
1999
2921
|
if (!meta.title) {
|
|
2000
2922
|
meta.title = await nameSession(provider, history);
|
|
@@ -2003,13 +2925,15 @@ program.action(async (opts) => {
|
|
|
2003
2925
|
h.sink.usage(stats.input - beforeIn, stats.output - beforeOut);
|
|
2004
2926
|
notifyDone(cfg.notify, { message: meta.title || "turn complete", elapsedMs: Date.now() - turnStart });
|
|
2005
2927
|
saveSession(meta, history);
|
|
2928
|
+
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
|
|
2006
2929
|
},
|
|
2007
2930
|
});
|
|
2931
|
+
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
2008
2932
|
await closeMcp();
|
|
2009
2933
|
process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
2010
2934
|
}
|
|
2011
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`));
|
|
2012
|
-
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 });
|
|
2013
2937
|
process.on("exit", () => {
|
|
2014
2938
|
try {
|
|
2015
2939
|
bar.uninstall();
|
|
@@ -2034,6 +2958,30 @@ program.action(async (opts) => {
|
|
|
2034
2958
|
const [name, ...rest] = line.slice(1).split(/\s+/);
|
|
2035
2959
|
const cmd = byName.get(name);
|
|
2036
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
|
+
}
|
|
2037
2985
|
const near = nearest(name, [...byName.keys()]);
|
|
2038
2986
|
const hint = near.length ? c.dim(` Did you mean ${near.map((n) => "/" + n).join(", ")}?`) : "";
|
|
2039
2987
|
out(c.red(`Unknown command /${name}.`) + hint + c.dim(" — /help for the list.\n"));
|
|
@@ -2047,10 +2995,12 @@ program.action(async (opts) => {
|
|
|
2047
2995
|
const userContent = (recalledContext ? `${recalledContext}\n\n---\n\n` : "") + expandMentions(line, cwd);
|
|
2048
2996
|
recalledContext = "";
|
|
2049
2997
|
history.push({ role: "user", content: userContent });
|
|
2998
|
+
if (cfg.fileCheckpoints)
|
|
2999
|
+
checkpoint(cwd, userContent.slice(0, 80)); // shadow-git snapshot before the turn mutates
|
|
2050
3000
|
currentTurn = new AbortController();
|
|
2051
3001
|
const t0 = Date.now();
|
|
2052
3002
|
try {
|
|
2053
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal });
|
|
3003
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
|
|
2054
3004
|
}
|
|
2055
3005
|
catch (e) {
|
|
2056
3006
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -2073,11 +3023,14 @@ program.action(async (opts) => {
|
|
|
2073
3023
|
out(statusLine(cfg.model, stats.input, stats.output) + "\n\n");
|
|
2074
3024
|
}
|
|
2075
3025
|
saveSession(meta, history);
|
|
2076
|
-
|
|
2077
|
-
|
|
2078
|
-
|
|
3026
|
+
if (!(await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => out(c.dim(`${m}\n`))))) {
|
|
3027
|
+
const ctxPct = bar.ctxPctFor(cfg.model, stats.lastInput ?? 0);
|
|
3028
|
+
if (ctxPct >= 80)
|
|
3029
|
+
out(c.yellow(` ⚠ context ${ctxPct}% full — /compact to summarize, or /clear to reset\n`));
|
|
3030
|
+
}
|
|
2079
3031
|
}
|
|
2080
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");
|
|
2081
3034
|
rl.close();
|
|
2082
3035
|
await closeMcp();
|
|
2083
3036
|
});
|