@nanhara/hara 0.89.0 → 0.98.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +74 -0
- package/README.md +4 -1
- package/dist/agent/loop.js +113 -8
- package/dist/config.js +51 -9
- package/dist/gateway/serve.js +58 -1
- package/dist/gateway/signal.js +220 -0
- package/dist/gateway/tmux-routes.js +135 -0
- package/dist/gateway/wecom.js +383 -0
- package/dist/index.js +1021 -82
- 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/security/guardian.js +261 -0
- package/dist/session/session-model.js +36 -0
- package/dist/statusbar.js +12 -3
- package/dist/tools/ask_user.js +64 -0
- package/dist/tools/external_agent.js +118 -0
- package/dist/tools/skill.js +6 -2
- package/dist/tools/todo.js +65 -8
- package/dist/tui/App.js +280 -33
- package/dist/tui/run.js +36 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -2,24 +2,26 @@
|
|
|
2
2
|
import { Command } from "commander";
|
|
3
3
|
import { createInterface } from "node:readline/promises";
|
|
4
4
|
import { emitKeypressEvents } from "node:readline";
|
|
5
|
-
import { runTui } from "./tui/run.js";
|
|
5
|
+
import { runTui, askConfirm } from "./tui/run.js";
|
|
6
6
|
import { readClipboardImage, mediaTypeFor } from "./images.js";
|
|
7
7
|
import { describeImages, locateImage, classifyVision, SCREENSHOT_SYSTEM } from "./vision.js";
|
|
8
8
|
import { setTheme } from "./tui/theme.js";
|
|
9
9
|
import { memoryDigest, memoryDir, readRecentLogs, scaffoldMemory } from "./memory/store.js";
|
|
10
10
|
import { nextMode as cycleMode } from "./tui/InputBox.js";
|
|
11
11
|
import { stdin, stdout } from "node:process";
|
|
12
|
+
import { execFileSync } from "node:child_process";
|
|
12
13
|
import { readFileSync, existsSync, writeFileSync, rmSync } from "node:fs";
|
|
13
14
|
import { homedir, tmpdir } from "node:os";
|
|
14
15
|
import { fileURLToPath } from "node:url";
|
|
15
|
-
import { dirname, join } from "node:path";
|
|
16
|
-
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, } from "./config.js";
|
|
16
|
+
import { dirname, join, relative } from "node:path";
|
|
17
|
+
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, 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";
|
|
23
25
|
import { loadPermissionRules, scaffoldPermissions, globalPermissionsPath, projectPermissionsPath } from "./security/permissions.js";
|
|
24
26
|
import { routingProvider } from "./agent/route.js";
|
|
25
27
|
import { shouldAutoCompact } from "./agent/compact.js";
|
|
@@ -42,9 +44,10 @@ import { collectRepoChunks, collectDirChunks, buildIndex, indexPath, indexExists
|
|
|
42
44
|
import { searchHybrid } from "./search/hybrid.js";
|
|
43
45
|
import { expandMentions, fileCandidates } from "./context/mentions.js";
|
|
44
46
|
import { newSessionId, shortId, resolveSessionId, saveSession, loadSession, listSessions, latestForCwd, titleFrom, slugify, } from "./session/store.js";
|
|
47
|
+
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
45
48
|
import { loadRoles, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
46
49
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
47
|
-
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks } from "./plugins/plugins.js";
|
|
50
|
+
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
|
|
48
51
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
49
52
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
50
53
|
import { connectMcpServers, closeMcp } from "./mcp/client.js";
|
|
@@ -65,6 +68,8 @@ import "./tools/skill.js"; // register the skill loader tool
|
|
|
65
68
|
import "./tools/codebase.js"; // register codebase_search (repo as a knowledge base)
|
|
66
69
|
import "./tools/todo.js"; // register todo_write (inline task checklist)
|
|
67
70
|
import "./tools/send.js"; // register send_file (self-gates on HARA_GATEWAY — pushes a file to the chat)
|
|
71
|
+
import "./tools/external_agent.js"; // register external_agent (delegate to claude-code / codex headless)
|
|
72
|
+
import "./tools/ask_user.js"; // register ask_user (pause mid-turn to ask the user a structured question)
|
|
68
73
|
import { computerBackends } from "./tools/computer.js"; // register the computer tool + expose the backend probe
|
|
69
74
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
70
75
|
// Version: from a build-time define in the compiled single-binary (no package.json on its virtual FS),
|
|
@@ -82,24 +87,44 @@ const pkg = {
|
|
|
82
87
|
};
|
|
83
88
|
const maskKey = (v) => (v ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
|
|
84
89
|
async function buildProvider(cfg) {
|
|
85
|
-
|
|
90
|
+
// Identity-profile is the source of truth for routing. `cfg` is the *merged* HaraConfig (env +
|
|
91
|
+
// project + global) and still drives non-routing concerns (model overrides, baseURL fallbacks
|
|
92
|
+
// for things like vision/route/fallback sidecars). The active profile decides "where to send
|
|
93
|
+
// requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
|
|
94
|
+
const ap = loadActiveProfile();
|
|
95
|
+
// CFG-OVERRIDE PATH: when a sidecar (vision / route / fallback) calls buildProvider with a tweaked
|
|
96
|
+
// cfg that explicitly carries an apiKey + baseURL, honor those over the profile — they're the
|
|
97
|
+
// sidecar's intended target. Detected by "cfg.apiKey present + cfg.baseURL present and we're not
|
|
98
|
+
// routing to a gateway." This keeps `withRouting`/vision unchanged.
|
|
99
|
+
const isSidecarOverride = !!cfg.apiKey && !!cfg.baseURL && ap.kind === "byok" && cfg.apiKey !== ap.apiKey;
|
|
100
|
+
if (ap.kind === "gateway" && !isSidecarOverride) {
|
|
101
|
+
if (!ap.gatewayUrl || !ap.deviceToken)
|
|
102
|
+
return null;
|
|
103
|
+
const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
104
|
+
const model = cfg.model || effectiveModel(ap);
|
|
105
|
+
return createOpenAIProvider({ apiKey: ap.deviceToken, baseURL, model, label: "hara-gateway", reasoningEffort: cfg.reasoningEffort });
|
|
106
|
+
}
|
|
107
|
+
// BYOK paths — use the active profile's provider/key/baseURL by default, but let the merged cfg
|
|
108
|
+
// override (so `--profile`/`HARA_PROFILE`-overridden values flow + sidecar provider builds work).
|
|
109
|
+
const provider = (cfg.provider && cfg.provider !== "hara-gateway" ? cfg.provider : ap.provider) || "anthropic";
|
|
110
|
+
const apiKey = cfg.apiKey ?? ap.apiKey;
|
|
111
|
+
// Resolve base URL: explicit (cfg → profile) wins; otherwise fall back to the provider's preset
|
|
112
|
+
// (GLM/DeepSeek/OpenRouter). This keeps `profile add --byok --provider glm` working even when the
|
|
113
|
+
// user didn't pass --base-url (anthropic/openai stay undefined → their SDK defaults).
|
|
114
|
+
const baseURL = cfg.baseURL ?? ap.baseURL ?? providerDefaultBaseURL(provider);
|
|
115
|
+
const model = cfg.model || effectiveModel(ap);
|
|
116
|
+
if (provider === "qwen-oauth") {
|
|
86
117
|
const auth = await getValidQwenAuth();
|
|
87
118
|
if (!auth)
|
|
88
119
|
return null;
|
|
89
|
-
return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model
|
|
90
|
-
}
|
|
91
|
-
if (cfg.provider === "hara-gateway") {
|
|
92
|
-
const e = loadEnrollment();
|
|
93
|
-
if (!e)
|
|
94
|
-
return null; // not enrolled → `hara enroll`
|
|
95
|
-
return createOpenAIProvider({ apiKey: e.deviceToken, baseURL: gatewayBaseURL(e), model: cfg.model || e.model, label: "hara-gateway" });
|
|
120
|
+
return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model, label: "qwen-oauth", reasoningEffort: cfg.reasoningEffort });
|
|
96
121
|
}
|
|
97
|
-
if (!
|
|
122
|
+
if (!apiKey)
|
|
98
123
|
return null;
|
|
99
|
-
if (
|
|
100
|
-
return createAnthropicProvider({ apiKey
|
|
124
|
+
if (provider === "anthropic") {
|
|
125
|
+
return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
|
|
101
126
|
}
|
|
102
|
-
return createOpenAIProvider({ apiKey
|
|
127
|
+
return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
|
|
103
128
|
}
|
|
104
129
|
/** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
|
|
105
130
|
* turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
|
|
@@ -110,13 +135,143 @@ async function withRouting(primary, cfg) {
|
|
|
110
135
|
const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
|
|
111
136
|
return alt ? routingProvider(primary, alt) : primary;
|
|
112
137
|
}
|
|
138
|
+
/** Guardian veto model: the CHEAP tier if `routeModel` is configured (a small classifier call, not real
|
|
139
|
+
* work), else the primary provider. Never blocks startup — any build failure just yields the fallback (and
|
|
140
|
+
* the guardian fails open when even that is absent). Returns the `{ provider, enabled }` shape runAgent wants,
|
|
141
|
+
* or undefined when guardian is off in config/env. */
|
|
142
|
+
async function buildGuardian(cfg, primary) {
|
|
143
|
+
if (cfg.guardian === "off")
|
|
144
|
+
return undefined;
|
|
145
|
+
let gp = primary;
|
|
146
|
+
if (cfg.routeModel && cfg.routeModel !== cfg.model) {
|
|
147
|
+
gp = (await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey })) ?? primary;
|
|
148
|
+
}
|
|
149
|
+
return { provider: gp, enabled: true };
|
|
150
|
+
}
|
|
113
151
|
function authHint(cfg) {
|
|
114
|
-
|
|
152
|
+
const ap = loadActiveProfile();
|
|
153
|
+
if (ap.kind === "gateway")
|
|
154
|
+
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>\`.`;
|
|
155
|
+
const provider = ap.provider ?? cfg.provider;
|
|
156
|
+
if (provider === "qwen-oauth")
|
|
115
157
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
116
|
-
return `Set ${c.bold(providerEnvKey(
|
|
158
|
+
return `Set ${c.bold(providerEnvKey(provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
|
|
159
|
+
}
|
|
160
|
+
const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", glm: "glm-4.6", deepseek: "deepseek-chat", openrouter: "openai/gpt-4o-mini", "qwen-oauth": "coder-model" };
|
|
161
|
+
/** Numbered provider menu for `hara setup`. Order is the displayed order; `id` maps to a ProviderId
|
|
162
|
+
* (or the special "custom"/"qwen-oauth" routes). GLM/DeepSeek carry a preset base URL so the user
|
|
163
|
+
* never types one; "custom" prompts for an OpenAI-compatible base URL. */
|
|
164
|
+
const SETUP_MENU = [
|
|
165
|
+
{ label: "Anthropic", id: "anthropic" },
|
|
166
|
+
{ label: "OpenAI", id: "openai" },
|
|
167
|
+
{ label: "GLM (Zhipu)", id: "glm" },
|
|
168
|
+
{ label: "DeepSeek", id: "deepseek" },
|
|
169
|
+
{ label: "Qwen (DashScope key)", id: "qwen" },
|
|
170
|
+
{ label: "OpenAI-compatible (custom base URL)", id: "custom" },
|
|
171
|
+
{ label: "Qwen — free, no key (browser sign-in)", id: "qwen-oauth" },
|
|
172
|
+
];
|
|
173
|
+
/** Read a secret from the TTY without echoing it (shows `*` per char). Falls back to a plain
|
|
174
|
+
* readline question when stdin isn't a raw-capable TTY (piped input / odd terminals) so scripted
|
|
175
|
+
* `printf 'key\n' | hara setup` still works. Handles backspace, Enter, and Ctrl-C/Ctrl-D. */
|
|
176
|
+
function readSecret(prompt, rl) {
|
|
177
|
+
const input = stdin;
|
|
178
|
+
if (!input.isTTY || typeof input.setRawMode !== "function") {
|
|
179
|
+
// Non-TTY (piped/scripted): can't suppress echo at the terminal level; read it plainly.
|
|
180
|
+
return rl.question(prompt);
|
|
181
|
+
}
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
stdout.write(prompt);
|
|
184
|
+
let buf = "";
|
|
185
|
+
const prevRaw = input.isRaw ?? false;
|
|
186
|
+
// Pause the readline interface so it doesn't also consume keystrokes / echo while we read raw.
|
|
187
|
+
// We restore it in cleanup() before the next rl.question() runs.
|
|
188
|
+
rl.pause();
|
|
189
|
+
input.setRawMode(true);
|
|
190
|
+
input.resume();
|
|
191
|
+
const onData = (chunk) => {
|
|
192
|
+
const s = chunk.toString("utf8");
|
|
193
|
+
for (const ch of s) {
|
|
194
|
+
const code = ch.charCodeAt(0);
|
|
195
|
+
if (ch === "\r" || ch === "\n") {
|
|
196
|
+
cleanup();
|
|
197
|
+
stdout.write("\n");
|
|
198
|
+
resolve(buf);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
else if (code === 3) {
|
|
202
|
+
// Ctrl-C → abort the wizard (mirror readline's SIGINT behavior).
|
|
203
|
+
cleanup();
|
|
204
|
+
stdout.write("\n");
|
|
205
|
+
reject(new Error("cancelled"));
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
else if (code === 4) {
|
|
209
|
+
// Ctrl-D → end of input; resolve with whatever we have.
|
|
210
|
+
cleanup();
|
|
211
|
+
stdout.write("\n");
|
|
212
|
+
resolve(buf);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
else if (code === 127 || code === 8) {
|
|
216
|
+
// Backspace/Delete.
|
|
217
|
+
if (buf.length) {
|
|
218
|
+
buf = buf.slice(0, -1);
|
|
219
|
+
stdout.write("\b \b");
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
else if (code >= 32) {
|
|
223
|
+
buf += ch;
|
|
224
|
+
stdout.write("*");
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
};
|
|
228
|
+
const cleanup = () => {
|
|
229
|
+
input.removeListener("data", onData);
|
|
230
|
+
try {
|
|
231
|
+
input.setRawMode(prevRaw);
|
|
232
|
+
}
|
|
233
|
+
catch {
|
|
234
|
+
/* best-effort */
|
|
235
|
+
}
|
|
236
|
+
// Hand control back to readline for the next prompt (model question).
|
|
237
|
+
rl.resume();
|
|
238
|
+
};
|
|
239
|
+
input.on("data", onData);
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
/** One-shot validation ping: build the provider exactly as the runtime would (anthropic vs the
|
|
243
|
+
* OpenAI-compatible path with the resolved base URL) and send a tiny prompt with a short timeout.
|
|
244
|
+
* Never throws — returns true on a clean turn, false on any error/timeout. Used only to print a
|
|
245
|
+
* friendly "connected" hint; the wizard saves config regardless. */
|
|
246
|
+
async function pingProvider(args) {
|
|
247
|
+
const { provider, apiKey, model, baseURL } = args;
|
|
248
|
+
if (!apiKey || !model)
|
|
249
|
+
return false;
|
|
250
|
+
const prov = provider === "anthropic"
|
|
251
|
+
? createAnthropicProvider({ apiKey, model, baseURL })
|
|
252
|
+
: createOpenAIProvider({ apiKey, model, baseURL, label: provider });
|
|
253
|
+
const ctrl = new AbortController();
|
|
254
|
+
const timer = setTimeout(() => ctrl.abort(), 12_000);
|
|
255
|
+
try {
|
|
256
|
+
const r = await prov.turn({
|
|
257
|
+
system: "Reply with the single word: ok",
|
|
258
|
+
history: [{ role: "user", content: "ping" }],
|
|
259
|
+
tools: [],
|
|
260
|
+
onText: () => { },
|
|
261
|
+
signal: ctrl.signal,
|
|
262
|
+
});
|
|
263
|
+
return r.stop !== "error";
|
|
264
|
+
}
|
|
265
|
+
catch {
|
|
266
|
+
return false;
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
clearTimeout(timer);
|
|
270
|
+
}
|
|
117
271
|
}
|
|
118
|
-
|
|
119
|
-
|
|
272
|
+
/** Interactive first-run setup: pick a provider (numbered menu), API key (masked), and model →
|
|
273
|
+
* ~/.hara/config.json. GLM/DeepSeek/OpenRouter and "custom" route through the OpenAI-compatible
|
|
274
|
+
* path; "Qwen free" routes to the device-login flow. Storage model is unchanged (config.json 0600). */
|
|
120
275
|
async function runSetup() {
|
|
121
276
|
if (!stdin.isTTY) {
|
|
122
277
|
out(c.yellow("`hara setup` is interactive — run it in a terminal, or use `hara config set <key> <value>` in scripts.\n"));
|
|
@@ -125,14 +280,42 @@ async function runSetup() {
|
|
|
125
280
|
const rl = createInterface({ input: stdin, output: stdout });
|
|
126
281
|
try {
|
|
127
282
|
out(c.bold("hara setup") + c.dim(" — configure a provider, key, and model (Ctrl-C to cancel)\n\n"));
|
|
128
|
-
|
|
283
|
+
SETUP_MENU.forEach((m, i) => out(` ${c.bold(String(i + 1))}) ${m.label}\n`));
|
|
284
|
+
out("\n");
|
|
285
|
+
const pick = (await rl.question(`Provider [1]: `)).trim() || "1";
|
|
286
|
+
const idx = Number.parseInt(pick, 10);
|
|
287
|
+
const choice = Number.isInteger(idx) && idx >= 1 && idx <= SETUP_MENU.length ? SETUP_MENU[idx - 1] : SETUP_MENU[0];
|
|
288
|
+
// Route 7: Qwen free device login — no key, no model prompt. Reuse the existing flow + config writes.
|
|
289
|
+
if (choice.id === "qwen-oauth") {
|
|
290
|
+
try {
|
|
291
|
+
await qwenDeviceLogin((m) => out(m + "\n"));
|
|
292
|
+
writeConfigValue("provider", "qwen-oauth");
|
|
293
|
+
writeConfigValue("model", "coder-model");
|
|
294
|
+
out(c.green("\n✓ Qwen OAuth complete — provider set to qwen-oauth (model coder-model).\n") + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
|
|
295
|
+
}
|
|
296
|
+
catch (e) {
|
|
297
|
+
out(c.red(`\nQwen OAuth failed: ${e?.message ?? e}\n`));
|
|
298
|
+
}
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
// Resolve the concrete provider id + base URL. "custom" = OpenAI-compatible: ask for the base
|
|
302
|
+
// URL and store the chosen provider as "openai" (the generic OpenAI-compatible dispatch).
|
|
303
|
+
let provider;
|
|
129
304
|
let baseURL = "";
|
|
130
|
-
if (
|
|
131
|
-
|
|
305
|
+
if (choice.id === "custom") {
|
|
306
|
+
provider = "openai";
|
|
307
|
+
baseURL = (await rl.question(`Base URL ${c.dim("(OpenAI-compatible endpoint, e.g. https://your-host/v1)")}: `)).trim();
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
provider = choice.id;
|
|
311
|
+
// GLM/DeepSeek/OpenRouter carry a preset base URL (PROVIDER_DEFAULTS) — written explicitly so
|
|
312
|
+
// the personal profile is self-contained. anthropic/openai use their built-in defaults.
|
|
313
|
+
baseURL = providerDefaultBaseURL(provider) ?? "";
|
|
132
314
|
}
|
|
133
315
|
const envKey = providerEnvKey(provider);
|
|
134
|
-
const apiKey = (await
|
|
135
|
-
const
|
|
316
|
+
const apiKey = (await readSecret(`API key ${c.dim(`(masked; blank = use the ${envKey} env var)`)}: `, rl)).trim();
|
|
317
|
+
const defaultModel = SETUP_DEFAULT_MODEL[choice.id === "custom" ? "openai" : provider] ?? "";
|
|
318
|
+
const model = (await rl.question(`Model [${defaultModel || "?"}]: `)).trim() || defaultModel;
|
|
136
319
|
writeConfigValue("provider", provider);
|
|
137
320
|
if (baseURL)
|
|
138
321
|
writeConfigValue("baseURL", baseURL);
|
|
@@ -140,8 +323,20 @@ async function runSetup() {
|
|
|
140
323
|
writeConfigValue("apiKey", apiKey);
|
|
141
324
|
if (model)
|
|
142
325
|
writeConfigValue("model", model);
|
|
326
|
+
// One-shot validation ping (best-effort; never blocks saving). Only when we have a key + model.
|
|
327
|
+
if (apiKey && model) {
|
|
328
|
+
out(c.dim("\nChecking connection… "));
|
|
329
|
+
const ok = await pingProvider({ provider, apiKey, model, baseURL: baseURL || undefined });
|
|
330
|
+
out(ok ? c.green("✓ connected\n") : c.yellow(`⚠ couldn't reach ${provider} (saved anyway)\n`));
|
|
331
|
+
}
|
|
143
332
|
out(c.green(`\n✓ saved to ${configPath()}\n`) + c.dim(`Check it with ${c.bold("hara doctor")}, then just run ${c.bold("hara")}.\n`));
|
|
144
333
|
}
|
|
334
|
+
catch (e) {
|
|
335
|
+
if (e?.message === "cancelled")
|
|
336
|
+
out(c.dim("\n(cancelled)\n"));
|
|
337
|
+
else
|
|
338
|
+
throw e;
|
|
339
|
+
}
|
|
145
340
|
finally {
|
|
146
341
|
rl.close();
|
|
147
342
|
}
|
|
@@ -236,8 +431,10 @@ async function runOrg(task, o) {
|
|
|
236
431
|
}
|
|
237
432
|
}
|
|
238
433
|
out(c.dim(`→ ${role.id} owns this task\n`));
|
|
239
|
-
|
|
240
|
-
|
|
434
|
+
// Role-model resolution: respect role.model by default; --force collapses everything to cfg.model.
|
|
435
|
+
const __roleModel = effectiveRoleModel(role.model, o.cfg.model);
|
|
436
|
+
const roleProvider = __roleModel
|
|
437
|
+
? ((await buildProvider({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
|
|
241
438
|
: o.baseProvider;
|
|
242
439
|
const toolFilter = role.allowTools
|
|
243
440
|
? (n) => role.allowTools.includes(n)
|
|
@@ -273,7 +470,8 @@ async function runOrg(task, o) {
|
|
|
273
470
|
}
|
|
274
471
|
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
275
472
|
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
276
|
-
const
|
|
473
|
+
const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
|
|
474
|
+
const revProvider = __revModel ? ((await buildProvider({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
|
|
277
475
|
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
278
476
|
const revTools = reviewer?.allowTools ? (n) => reviewer.allowTools.includes(n) : (n) => READONLY_TOOLS.has(n);
|
|
279
477
|
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
@@ -325,7 +523,8 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
325
523
|
atom.status = "running";
|
|
326
524
|
savePlan(o.cwd, plan);
|
|
327
525
|
const role = atom.role ? roles.find((r) => r.id === atom.role) : undefined;
|
|
328
|
-
const
|
|
526
|
+
const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
|
|
527
|
+
const roleProvider = __atomModel ? ((await buildProvider({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
|
|
329
528
|
const toolFilter = role?.allowTools
|
|
330
529
|
? (n) => role.allowTools.includes(n)
|
|
331
530
|
: role?.denyTools
|
|
@@ -559,7 +758,8 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify) {
|
|
|
559
758
|
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId) {
|
|
560
759
|
const roles = loadRoles(cwd);
|
|
561
760
|
const role = roleId ? roles.find((r) => r.id === roleId) : undefined;
|
|
562
|
-
const
|
|
761
|
+
const __subModel = effectiveRoleModel(role?.model, cfg.model);
|
|
762
|
+
const provider = __subModel ? ((await buildProvider({ ...cfg, model: __subModel })) ?? baseProvider) : baseProvider;
|
|
563
763
|
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
564
764
|
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
565
765
|
// Write-capable roles run in the main loop via `hara org`, behind the user's gate.
|
|
@@ -637,10 +837,27 @@ program
|
|
|
637
837
|
.option("-y, --yes", "auto-approve all tool actions (= --approval full-auto)")
|
|
638
838
|
.option("-m, --model <model>", "model id (overrides config)")
|
|
639
839
|
.option("--approval <mode>", "approval mode: suggest | auto-edit | full-auto")
|
|
640
|
-
.option("--profile <
|
|
840
|
+
.option("--profile <id>", "use this identity profile for this run (personal / org id) — see `hara profile list`")
|
|
841
|
+
.option("--overlay <name>", "apply a named config overlay from ~/.hara/config.json (legacy: --profile)")
|
|
641
842
|
.option("-c, --continue", "resume the most recent session in this directory")
|
|
642
843
|
.option("--resume <id>", "resume a specific session by id")
|
|
643
844
|
.option("--sandbox <mode>", "sandbox the shell: off | workspace-write | read-only");
|
|
845
|
+
// Wire the global `--profile <id>` flag into the resolution chain BEFORE any subcommand
|
|
846
|
+
// action runs. resolveActive() consults setFlagOverride() at the top of the priority chain,
|
|
847
|
+
// so this single hook covers `hara whoami`, `hara profile list`, `hara model …`, and the
|
|
848
|
+
// default REPL action — without each subcommand having to reach into program.opts() itself.
|
|
849
|
+
// Validation: unknown id is a hard fail (don't silently fall through to default; the user
|
|
850
|
+
// asked for a specific identity, surface the mistake).
|
|
851
|
+
program.hook("preAction", (thisCmd) => {
|
|
852
|
+
const flag = thisCmd.opts().profile;
|
|
853
|
+
if (!flag)
|
|
854
|
+
return;
|
|
855
|
+
if (!getProfile(flag)) {
|
|
856
|
+
out(c.red(`No identity profile '${flag}'.\n`) + c.dim("List: `hara profile list`\n"));
|
|
857
|
+
process.exit(1);
|
|
858
|
+
}
|
|
859
|
+
setFlagOverride(flag);
|
|
860
|
+
});
|
|
644
861
|
program
|
|
645
862
|
.command("init")
|
|
646
863
|
.description("analyze the project and (re)generate AGENTS.md")
|
|
@@ -664,8 +881,33 @@ program
|
|
|
664
881
|
return;
|
|
665
882
|
}
|
|
666
883
|
for (const m of metas) {
|
|
667
|
-
out(`${c.bold(m.id)} ${c.dim(m.updatedAt.slice(0, 16).replace("T", " "))} ${m.provider
|
|
884
|
+
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`);
|
|
885
|
+
}
|
|
886
|
+
out(c.dim("\nResume: hara resume <id>\n"));
|
|
887
|
+
});
|
|
888
|
+
program
|
|
889
|
+
.command("resume [id]")
|
|
890
|
+
.description("resume a session — no id resumes the most recent here (list ids with `hara sessions`)")
|
|
891
|
+
.action((id) => {
|
|
892
|
+
let full;
|
|
893
|
+
if (id) {
|
|
894
|
+
full = resolveSessionId(id) ?? undefined;
|
|
895
|
+
if (!full) {
|
|
896
|
+
out(c.red(`No session matching '${id}'.`) + c.dim(" Run `hara sessions` to list.\n"));
|
|
897
|
+
process.exit(1);
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
else {
|
|
901
|
+
const latest = latestForCwd(process.cwd());
|
|
902
|
+
if (!latest) {
|
|
903
|
+
out(c.dim("No sessions for this directory yet — `hara sessions` lists all.\n"));
|
|
904
|
+
process.exit(0);
|
|
905
|
+
}
|
|
906
|
+
full = latest.meta.id;
|
|
668
907
|
}
|
|
908
|
+
out(c.dim(`↩ resuming ${shortId(full)}…\n`));
|
|
909
|
+
// reuse the existing --resume path exactly (one engine), inheriting this terminal
|
|
910
|
+
execFileSync(process.execPath, [process.argv[1], "--resume", full], { stdio: "inherit" });
|
|
669
911
|
});
|
|
670
912
|
program
|
|
671
913
|
.command("org <task...>")
|
|
@@ -800,30 +1042,407 @@ program
|
|
|
800
1042
|
.command("setup")
|
|
801
1043
|
.description("interactive first-run setup — pick a provider, API key, and model")
|
|
802
1044
|
.action(runSetup);
|
|
1045
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
1046
|
+
// Identity profiles — the single switch for "who am I as right now" (personal vs each
|
|
1047
|
+
// org I belong to). Switching a profile flips provider, key/token, base URL, AND the
|
|
1048
|
+
// default model the gateway / setup chose. See src/profile/profile.ts.
|
|
1049
|
+
// ────────────────────────────────────────────────────────────────────────────────
|
|
1050
|
+
function fmtProfile(p, mark = "") {
|
|
1051
|
+
const kindBadge = p.kind === "gateway" ? c.bold(c.cyan("ORG")) : c.bold(c.dim("PERSONAL"));
|
|
1052
|
+
const label = p.label ? `${c.bold(p.label)} ` : "";
|
|
1053
|
+
const model = effectiveModel(p) || c.dim("(unset)");
|
|
1054
|
+
const route = routingLabel(p);
|
|
1055
|
+
return `${mark} ${kindBadge} ${label}${c.dim("[" + p.id + "]")} ${c.dim("· model")} ${model} ${c.dim("· →")} ${route}`;
|
|
1056
|
+
}
|
|
1057
|
+
/** Human-readable suffix for the active row: "(active · <where it came from>)".
|
|
1058
|
+
* pin gets a relative file path; flag/env/default each get their own tag. */
|
|
1059
|
+
function activeSuffix(r) {
|
|
1060
|
+
switch (r.source) {
|
|
1061
|
+
case "flag":
|
|
1062
|
+
return c.dim("(active · ") + c.bold("--profile flag") + c.dim(")");
|
|
1063
|
+
case "env":
|
|
1064
|
+
return c.dim("(active · ") + c.bold("HARA_PROFILE env") + c.dim(")");
|
|
1065
|
+
case "pin": {
|
|
1066
|
+
const rel = r.pinFile ? relPath(r.pinFile) : ".hara-profile";
|
|
1067
|
+
return c.dim("(active · ") + c.bold("pinned by " + rel) + c.dim(")");
|
|
1068
|
+
}
|
|
1069
|
+
case "default":
|
|
1070
|
+
return c.dim("(active · ") + c.bold("global default") + c.dim(")");
|
|
1071
|
+
case "fallback":
|
|
1072
|
+
return c.dim("(active · fallback)");
|
|
1073
|
+
}
|
|
1074
|
+
}
|
|
1075
|
+
/** Render an absolute path relative to cwd. Same-dir paths get `./` for clarity
|
|
1076
|
+
* ("pinned by ./.hara-profile" reads better than "pinned by .hara-profile" because
|
|
1077
|
+
* the leading `.` of the filename is otherwise hard to spot). Parent-dir pins keep
|
|
1078
|
+
* their relative form (`../../.hara-profile`) — still way more readable than absolute. */
|
|
1079
|
+
function relPath(abs) {
|
|
1080
|
+
try {
|
|
1081
|
+
const r = relative(process.cwd(), abs);
|
|
1082
|
+
if (!r)
|
|
1083
|
+
return ".";
|
|
1084
|
+
// r could be: ".hara-profile", "sub/.hara-profile", "../.hara-profile".
|
|
1085
|
+
// For the same-cwd hit we want `./.hara-profile` — start with "./" unless it already
|
|
1086
|
+
// navigates with ".." (which speaks for itself).
|
|
1087
|
+
if (r.startsWith(".."))
|
|
1088
|
+
return r;
|
|
1089
|
+
return "./" + r;
|
|
1090
|
+
}
|
|
1091
|
+
catch {
|
|
1092
|
+
return abs;
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
/** Stable "▶ active" line — first thing printed at startup so the user always sees where requests
|
|
1096
|
+
* are going. Tests look for this prefix; keep the format. */
|
|
1097
|
+
export function activeProfileLine(p) {
|
|
1098
|
+
const route = routingLabel(p);
|
|
1099
|
+
const model = effectiveModel(p) || "(unset)";
|
|
1100
|
+
return `▶ ${p.label || p.id} · ${model} · ${route}`;
|
|
1101
|
+
}
|
|
1102
|
+
/** Shared whoami body so the `profile current` alias reuses the same output exactly. */
|
|
1103
|
+
function printWhoami() {
|
|
1104
|
+
const r = resolveActive();
|
|
1105
|
+
const p = loadActiveProfile();
|
|
1106
|
+
out(c.bold("active profile") + " " + activeSuffix(r) + "\n" + fmtProfile(p, " ") + "\n");
|
|
1107
|
+
if (p.kind === "gateway") {
|
|
1108
|
+
out(c.dim(` gateway: ${p.gatewayUrl}\n`));
|
|
1109
|
+
if (p.deviceId)
|
|
1110
|
+
out(c.dim(` device: ${p.deviceId.length > 8 ? "…" + p.deviceId.slice(-8) : p.deviceId}\n`));
|
|
1111
|
+
if (p.availableModels?.length)
|
|
1112
|
+
out(c.dim(` available: ${p.availableModels.join(", ")}\n`));
|
|
1113
|
+
}
|
|
1114
|
+
else {
|
|
1115
|
+
out(c.dim(` provider: ${p.provider}\n`));
|
|
1116
|
+
if (p.baseURL)
|
|
1117
|
+
out(c.dim(` baseURL: ${p.baseURL}\n`));
|
|
1118
|
+
out(c.dim(` key: ${p.apiKey ? maskKey(p.apiKey) : "(env / unset)"}\n`));
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
program
|
|
1122
|
+
.command("whoami")
|
|
1123
|
+
.description("show the active identity profile (label · model · routing target · source)")
|
|
1124
|
+
.action(printWhoami);
|
|
1125
|
+
const profileCmd = program.command("profile").description("manage identity profiles (personal / org A / org B…)");
|
|
1126
|
+
// `profile current` — nvm muscle-memory ("nvm current" → "hara profile current"). Same as `hara whoami`.
|
|
1127
|
+
profileCmd
|
|
1128
|
+
.command("current")
|
|
1129
|
+
.description("alias of `hara whoami` — print the active identity profile (with source)")
|
|
1130
|
+
.action(printWhoami);
|
|
1131
|
+
// ── `profile list` (alias `ls`) ────────────────────────────────────────────────
|
|
1132
|
+
// Layout: profiles grouped by kind (PERSONAL above ORG), one line per profile, columns
|
|
1133
|
+
// aligned across the whole table (so id/model/routing visually stack). Active row is
|
|
1134
|
+
// prefixed with `→ *` (so you can read it at a glance even in copy-pasted output) and
|
|
1135
|
+
// suffixed with the source tag. Footer is a 2-line hint pointing at the two switching
|
|
1136
|
+
// gestures: `profile use <id>` (write the default), `profile pin <id>` (lock this dir).
|
|
1137
|
+
function renderProfileList() {
|
|
1138
|
+
const r = resolveActive();
|
|
1139
|
+
const ps = listProfiles();
|
|
1140
|
+
const lines = [];
|
|
1141
|
+
// Group by kind so the "where am I in the world" stratification is visible.
|
|
1142
|
+
const groups = [
|
|
1143
|
+
{ kind: "byok", title: "PERSONAL", rows: ps.filter((p) => p.kind === "byok") },
|
|
1144
|
+
{ kind: "gateway", title: "ORG", rows: ps.filter((p) => p.kind === "gateway") },
|
|
1145
|
+
];
|
|
1146
|
+
// Column widths from raw (un-styled) strings — styling never participates in padding.
|
|
1147
|
+
const idW = Math.max(2, ...ps.map((p) => p.id.length));
|
|
1148
|
+
const labelW = Math.max(0, ...ps.map((p) => (p.label || "").length));
|
|
1149
|
+
const modelW = Math.max(5, ...ps.map((p) => (effectiveModel(p) || "(unset)").length));
|
|
1150
|
+
for (const g of groups) {
|
|
1151
|
+
if (!g.rows.length)
|
|
1152
|
+
continue;
|
|
1153
|
+
if (lines.length)
|
|
1154
|
+
lines.push(""); // blank between groups
|
|
1155
|
+
lines.push(c.dim(g.title));
|
|
1156
|
+
for (const p of g.rows) {
|
|
1157
|
+
const isActive = p.id === r.id;
|
|
1158
|
+
const mark = isActive ? c.green("→ *") : " ";
|
|
1159
|
+
const id = p.id.padEnd(idW, " ");
|
|
1160
|
+
const label = (p.label || "").padEnd(labelW, " ");
|
|
1161
|
+
const model = (effectiveModel(p) || "(unset)").padEnd(modelW, " ");
|
|
1162
|
+
const route = routingLabel(p);
|
|
1163
|
+
const tail = isActive ? " " + activeSuffix(r) : "";
|
|
1164
|
+
const cols = `${mark} ${c.dim("[")}${c.bold(id)}${c.dim("]")} ${label} ${c.dim("· model")} ${model} ${c.dim("· →")} ${route}${tail}`;
|
|
1165
|
+
lines.push(cols);
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
// Tail hint — nudge users toward the two everyday gestures.
|
|
1169
|
+
lines.push("");
|
|
1170
|
+
lines.push(c.dim("💡 use ") + "`hara profile use <id>`" + c.dim(" to switch · ") + "`hara profile pin <id>`" + c.dim(" to lock to this dir"));
|
|
1171
|
+
return lines.join("\n");
|
|
1172
|
+
}
|
|
1173
|
+
profileCmd
|
|
1174
|
+
.command("list")
|
|
1175
|
+
.alias("ls")
|
|
1176
|
+
.description("list all profiles (active marked with → *) — alias: `ls`")
|
|
1177
|
+
.action(() => {
|
|
1178
|
+
out(renderProfileList() + "\n");
|
|
1179
|
+
});
|
|
1180
|
+
profileCmd
|
|
1181
|
+
.command("use <id>")
|
|
1182
|
+
.description("switch the active profile (echoes the diff: profile / model / routing)")
|
|
1183
|
+
.option("-y, --yes", "skip confirmation when switching INTO a gateway profile from BYOK")
|
|
1184
|
+
.action(async (id, opts) => {
|
|
1185
|
+
const before = loadActiveProfile();
|
|
1186
|
+
const target = getProfile(id);
|
|
1187
|
+
if (!target) {
|
|
1188
|
+
out(c.red(`No profile '${id}'.\n`) + c.dim("List: `hara profile list`\n"));
|
|
1189
|
+
process.exit(1);
|
|
1190
|
+
}
|
|
1191
|
+
// Safety: BYOK → gateway is the direction that changes where your traffic goes (from your own
|
|
1192
|
+
// key to a controlled gateway). Confirm unless -y. The reverse direction is allowed silently
|
|
1193
|
+
// but the diff is still echoed.
|
|
1194
|
+
if (before.kind === "byok" && target.kind === "gateway" && !opts.yes) {
|
|
1195
|
+
const ok = await askConfirm(`Switch to gateway profile '${id}' (${target.gatewayUrl})? Traffic will route through the org gateway.`);
|
|
1196
|
+
if (!ok) {
|
|
1197
|
+
out(c.dim("(unchanged)\n"));
|
|
1198
|
+
return;
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
const r = useProfile(id);
|
|
1202
|
+
if (!r.ok) {
|
|
1203
|
+
out(c.red(r.reason + "\n"));
|
|
1204
|
+
process.exit(1);
|
|
1205
|
+
}
|
|
1206
|
+
const after = r.profile;
|
|
1207
|
+
const modelBefore = effectiveModel(before) || "(unset)";
|
|
1208
|
+
const modelAfter = effectiveModel(after) || "(unset)";
|
|
1209
|
+
const routeBefore = routingLabel(before);
|
|
1210
|
+
const routeAfter = routingLabel(after);
|
|
1211
|
+
out(c.green("✓ switched\n"));
|
|
1212
|
+
out(` profile: ${c.dim(before.id)} ${c.dim("→")} ${c.bold(after.id)}\n`);
|
|
1213
|
+
out(` model: ${c.dim(modelBefore)} ${c.dim("→")} ${c.bold(modelAfter)}\n`);
|
|
1214
|
+
out(` routing: ${c.dim(routeBefore)} ${c.dim("→")} ${c.bold(routeAfter)}\n`);
|
|
1215
|
+
});
|
|
1216
|
+
profileCmd
|
|
1217
|
+
.command("add <id>")
|
|
1218
|
+
.description("add a new identity profile (gateway = `hara enroll`; byok = your own key)")
|
|
1219
|
+
.option("--gateway <url>", "(gateway) join this hara-control gateway")
|
|
1220
|
+
.option("--code <code>", "(gateway) enrollment code from your admin")
|
|
1221
|
+
.option("--label <label>", "human-friendly label for the profile")
|
|
1222
|
+
.option("--byok", "(byok) BYOK profile — bring your own provider key")
|
|
1223
|
+
.option("--provider <id>", "(byok) anthropic | openai | glm | deepseek | openrouter | qwen | qwen-oauth")
|
|
1224
|
+
.option("--key <key>", "(byok) API key (else read from the provider's env var at use-time)")
|
|
1225
|
+
.option("--base-url <url>", "(byok) override the provider base URL (OpenAI-compatible endpoints)")
|
|
1226
|
+
.option("--model <model>", "(byok) default model for this profile")
|
|
1227
|
+
.action(async (id, opts) => {
|
|
1228
|
+
if (opts.gateway) {
|
|
1229
|
+
if (!opts.code)
|
|
1230
|
+
return void out(c.red("gateway profile add needs --code <code> from your hara-control admin\n"));
|
|
1231
|
+
try {
|
|
1232
|
+
const e = await enrollDevice(opts.gateway, opts.code);
|
|
1233
|
+
const p = {
|
|
1234
|
+
id,
|
|
1235
|
+
kind: "gateway",
|
|
1236
|
+
label: opts.label || id,
|
|
1237
|
+
gatewayUrl: e.gatewayUrl,
|
|
1238
|
+
deviceId: e.deviceId,
|
|
1239
|
+
deviceToken: e.deviceToken,
|
|
1240
|
+
baseURL: e.baseURL,
|
|
1241
|
+
defaultModel: e.model || "",
|
|
1242
|
+
availableModels: e.model ? [e.model] : [],
|
|
1243
|
+
enrolledAt: e.enrolledAt,
|
|
1244
|
+
};
|
|
1245
|
+
upsertProfile(p); // upsert: re-enrolling the same id rotates the token
|
|
1246
|
+
const r = useProfile(id);
|
|
1247
|
+
if (r.ok) {
|
|
1248
|
+
out(c.green(`✓ enrolled and switched to '${id}' (${e.gatewayUrl})`) + c.dim(` · model ${p.defaultModel || "(gateway default)"}\n`));
|
|
1249
|
+
const nRoles = await syncOrgRoles();
|
|
1250
|
+
if (nRoles > 0)
|
|
1251
|
+
out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
catch (err) {
|
|
1255
|
+
out(c.red(`Enroll failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
1256
|
+
process.exit(1);
|
|
1257
|
+
}
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (opts.byok || opts.provider) {
|
|
1261
|
+
const provider = (opts.provider || "anthropic");
|
|
1262
|
+
if (provider === "hara-gateway")
|
|
1263
|
+
return void out(c.red("`--provider hara-gateway` is retired — use --gateway <url> --code <code> instead.\n"));
|
|
1264
|
+
const p = {
|
|
1265
|
+
id,
|
|
1266
|
+
kind: "byok",
|
|
1267
|
+
label: opts.label || id,
|
|
1268
|
+
provider,
|
|
1269
|
+
apiKey: opts.key,
|
|
1270
|
+
baseURL: opts.baseUrl,
|
|
1271
|
+
defaultModel: opts.model,
|
|
1272
|
+
};
|
|
1273
|
+
const r = addProfile(p);
|
|
1274
|
+
if (!r.ok) {
|
|
1275
|
+
out(c.red(r.reason + "\n"));
|
|
1276
|
+
process.exit(1);
|
|
1277
|
+
}
|
|
1278
|
+
out(c.green(`✓ added BYOK profile '${id}'`) + c.dim(` · provider ${provider}${opts.model ? " · model " + opts.model : ""}\n`));
|
|
1279
|
+
out(c.dim(`Switch to it with \`hara profile use ${id}\`.\n`));
|
|
1280
|
+
return;
|
|
1281
|
+
}
|
|
1282
|
+
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|openai|glm|deepseek|openrouter|qwen|qwen-oauth [--key … --base-url … --model …]\n"));
|
|
1283
|
+
process.exit(1);
|
|
1284
|
+
});
|
|
1285
|
+
profileCmd
|
|
1286
|
+
.command("remove <id>")
|
|
1287
|
+
.alias("rm")
|
|
1288
|
+
.alias("uninstall")
|
|
1289
|
+
.description("remove a profile (active falls back to personal) — aliases: `rm`, `uninstall`")
|
|
1290
|
+
.action((id) => {
|
|
1291
|
+
// Capture the profile before removal so we can mention the gateway host in the token-hint
|
|
1292
|
+
// line (5 below). After removeProfile, getProfile(id) is gone.
|
|
1293
|
+
const before = getProfile(id);
|
|
1294
|
+
const r = removeProfile(id);
|
|
1295
|
+
if (!r.ok) {
|
|
1296
|
+
out(c.red(r.reason + "\n"));
|
|
1297
|
+
process.exit(1);
|
|
1298
|
+
}
|
|
1299
|
+
if (r.activeChanged) {
|
|
1300
|
+
// Single line that reads naturally: "removed 'X' · active → personal".
|
|
1301
|
+
out(c.green(`✓ removed '${id}'`) + c.dim(` · active → ${PERSONAL_ID}\n`));
|
|
1302
|
+
}
|
|
1303
|
+
else {
|
|
1304
|
+
out(c.green(`✓ removed '${id}'\n`));
|
|
1305
|
+
}
|
|
1306
|
+
// For gateway profiles: we deliberately do NOT phone the control plane to revoke the device
|
|
1307
|
+
// token (that's a privileged operation that needs admin auth + we don't want a stale CLI
|
|
1308
|
+
// calling production). Print a one-line hint so the user knows the *server-side* identity
|
|
1309
|
+
// outlives this local removal — and who to ask if they want it gone there too.
|
|
1310
|
+
if (r.removedKind === "gateway") {
|
|
1311
|
+
const host = (() => {
|
|
1312
|
+
try {
|
|
1313
|
+
return before?.gatewayUrl ? new URL(before.gatewayUrl).host : (before?.gatewayUrl || "the gateway");
|
|
1314
|
+
}
|
|
1315
|
+
catch {
|
|
1316
|
+
return before?.gatewayUrl || "the gateway";
|
|
1317
|
+
}
|
|
1318
|
+
})();
|
|
1319
|
+
out(c.dim(`💡 token left registered at ${host}; ask your admin to revoke if needed\n`));
|
|
1320
|
+
}
|
|
1321
|
+
});
|
|
1322
|
+
// ── `.hara-profile` project pin (like .nvmrc but personal — keep it out of repos) ─────
|
|
1323
|
+
profileCmd
|
|
1324
|
+
.command("pin [id]")
|
|
1325
|
+
.description("write `.hara-profile` in this dir to lock the active profile here (omit id = pin current active)")
|
|
1326
|
+
.action((id) => {
|
|
1327
|
+
const target = (id && id.trim()) || activeId();
|
|
1328
|
+
if (!getProfile(target)) {
|
|
1329
|
+
out(c.red(`No profile '${target}'.\n`) + c.dim("List: `hara profile list`\n"));
|
|
1330
|
+
process.exit(1);
|
|
1331
|
+
}
|
|
1332
|
+
try {
|
|
1333
|
+
const { file } = writePin(process.cwd(), target);
|
|
1334
|
+
out(c.green(`✓ pinned ${target} to ${relPath(file)}\n`));
|
|
1335
|
+
// .hara-profile carries personal identity (which org you're as), unlike .nvmrc which
|
|
1336
|
+
// is project-level. Nudge user toward GLOBAL gitignore so they don't accidentally
|
|
1337
|
+
// commit it. We intentionally do NOT modify .gitignore — that's user space.
|
|
1338
|
+
out(c.dim("💡 .hara-profile is personal identity — add it to your global gitignore (unlike .nvmrc, don't commit it)\n"));
|
|
1339
|
+
}
|
|
1340
|
+
catch (err) {
|
|
1341
|
+
out(c.red(`pin failed: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
1342
|
+
process.exit(1);
|
|
1343
|
+
}
|
|
1344
|
+
});
|
|
1345
|
+
profileCmd
|
|
1346
|
+
.command("unpin")
|
|
1347
|
+
.description("remove `.hara-profile` from this dir")
|
|
1348
|
+
.action(() => {
|
|
1349
|
+
const file = pinFilePath(process.cwd());
|
|
1350
|
+
const ok = removePin(process.cwd());
|
|
1351
|
+
if (ok)
|
|
1352
|
+
out(c.green(`✓ unpinned`) + c.dim(` · removed ${relPath(file)}\n`));
|
|
1353
|
+
else
|
|
1354
|
+
out(c.dim(`(no ${relPath(file)} here — nothing to unpin)\n`));
|
|
1355
|
+
});
|
|
1356
|
+
// ── per-profile model switching ──────────────────────────────────────────────────
|
|
1357
|
+
const modelCmd = program.command("model").description("manage the model on the active profile");
|
|
1358
|
+
modelCmd
|
|
1359
|
+
.command("list")
|
|
1360
|
+
.description("list models for the active profile (gateway profiles list what the control plane advertised)")
|
|
1361
|
+
.action(() => {
|
|
1362
|
+
const p = loadActiveProfile();
|
|
1363
|
+
const cur = effectiveModel(p);
|
|
1364
|
+
if (p.kind === "gateway") {
|
|
1365
|
+
const list = p.availableModels?.length ? p.availableModels : (p.defaultModel ? [p.defaultModel] : []);
|
|
1366
|
+
if (!list.length) {
|
|
1367
|
+
out(c.dim("(gateway didn't advertise any models — use the gateway default; `hara model use <id>` to override locally)\n"));
|
|
1368
|
+
return;
|
|
1369
|
+
}
|
|
1370
|
+
for (const m of list)
|
|
1371
|
+
out(`${m === cur ? c.green("*") : " "} ${m}\n`);
|
|
1372
|
+
}
|
|
1373
|
+
else {
|
|
1374
|
+
// BYOK has no constrained list — show the current effective + suggestion.
|
|
1375
|
+
out(`${c.green("*")} ${cur || c.dim("(unset)")}\n`);
|
|
1376
|
+
out(c.dim("(BYOK profiles accept any model id the provider supports — `hara model use <id>` to switch)\n"));
|
|
1377
|
+
}
|
|
1378
|
+
});
|
|
1379
|
+
modelCmd
|
|
1380
|
+
.command("use <model>")
|
|
1381
|
+
.description("override the model on the active profile (validated against availableModels on gateway profiles)")
|
|
1382
|
+
.action((model) => {
|
|
1383
|
+
const id = activeId();
|
|
1384
|
+
const r = setProfileModel(id, model);
|
|
1385
|
+
if (!r.ok) {
|
|
1386
|
+
out(c.red(r.reason + "\n"));
|
|
1387
|
+
process.exit(1);
|
|
1388
|
+
}
|
|
1389
|
+
out(c.green(`✓ model → ${model}`) + c.dim(` (profile ${id})\n`));
|
|
1390
|
+
});
|
|
1391
|
+
modelCmd
|
|
1392
|
+
.command("reset")
|
|
1393
|
+
.description("clear the per-profile model override → fall back to defaultModel")
|
|
1394
|
+
.action(() => {
|
|
1395
|
+
const id = activeId();
|
|
1396
|
+
const r = resetProfileModel(id);
|
|
1397
|
+
if (!r.ok) {
|
|
1398
|
+
out(c.red(r.reason + "\n"));
|
|
1399
|
+
process.exit(1);
|
|
1400
|
+
}
|
|
1401
|
+
const p = loadActiveProfile();
|
|
1402
|
+
out(c.green(`✓ reset`) + c.dim(` · effective model → ${effectiveModel(p) || "(unset)"}\n`));
|
|
1403
|
+
});
|
|
1404
|
+
// ── `hara enroll` — kept as a convenience alias mapping to the default-org gateway profile.
|
|
803
1405
|
program
|
|
804
1406
|
.command("enroll [gateway-url]")
|
|
805
|
-
.description("
|
|
1407
|
+
.description("alias of `hara profile add default-org --gateway <url> --code <code>` (B-end: join a fleet)")
|
|
806
1408
|
.option("--code <code>", "enrollment code from your hara-control admin")
|
|
807
|
-
.option("--status", "
|
|
808
|
-
.option("--clear", "
|
|
1409
|
+
.option("--status", "alias of `hara whoami`")
|
|
1410
|
+
.option("--clear", "switch active profile back to personal (does NOT delete the gateway profile)")
|
|
809
1411
|
.action(async (gatewayUrl, opts) => {
|
|
810
1412
|
if (opts.status) {
|
|
811
|
-
const
|
|
812
|
-
return void out(
|
|
1413
|
+
const p = loadActiveProfile();
|
|
1414
|
+
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"));
|
|
1415
|
+
}
|
|
1416
|
+
if (opts.clear) {
|
|
1417
|
+
// Behavior change: don't *delete* the gateway profile (keeps the token around for re-use);
|
|
1418
|
+
// just switch active back to personal. Legacy clearEnrollment() also called to remove any
|
|
1419
|
+
// stray org.json file from pre-migration installs.
|
|
1420
|
+
clearEnrollment();
|
|
1421
|
+
const r = useProfile(PERSONAL_ID);
|
|
1422
|
+
return void out(r.ok ? c.green("✓ active → personal") + c.dim(" — gateway profile preserved (remove with `hara profile remove default-org`)\n") : c.dim("(no change)\n"));
|
|
813
1423
|
}
|
|
814
|
-
if (opts.clear)
|
|
815
|
-
return void out(clearEnrollment() ? c.green("✓ enrollment cleared — set your own provider with `hara setup`.\n") : c.dim("(not enrolled)\n"));
|
|
816
1424
|
if (!gatewayUrl)
|
|
817
1425
|
return void out(c.red("usage: hara enroll <gateway-url> --code <code> (or --status / --clear)\n"));
|
|
818
1426
|
if (!opts.code)
|
|
819
1427
|
return void out(c.red("Need --code <code> — ask your hara-control admin to issue an enrollment code.\n"));
|
|
820
1428
|
try {
|
|
821
1429
|
const e = await enrollDevice(gatewayUrl, opts.code);
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
1430
|
+
const p = {
|
|
1431
|
+
id: DEFAULT_ORG_ID,
|
|
1432
|
+
kind: "gateway",
|
|
1433
|
+
label: "Default Org",
|
|
1434
|
+
gatewayUrl: e.gatewayUrl,
|
|
1435
|
+
deviceId: e.deviceId,
|
|
1436
|
+
deviceToken: e.deviceToken,
|
|
1437
|
+
baseURL: e.baseURL,
|
|
1438
|
+
defaultModel: e.model || "",
|
|
1439
|
+
availableModels: e.model ? [e.model] : [],
|
|
1440
|
+
enrolledAt: e.enrolledAt,
|
|
1441
|
+
};
|
|
1442
|
+
upsertProfile(p);
|
|
1443
|
+
useProfile(DEFAULT_ORG_ID);
|
|
1444
|
+
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"));
|
|
1445
|
+
const nRoles = await syncOrgRoles();
|
|
827
1446
|
if (nRoles > 0)
|
|
828
1447
|
out(c.dim(` ↳ synced ${nRoles} org role${nRoles === 1 ? "" : "s"} → ~/.hara/org-roles/\n`));
|
|
829
1448
|
}
|
|
@@ -855,7 +1474,7 @@ program
|
|
|
855
1474
|
program
|
|
856
1475
|
.command("gateway")
|
|
857
1476
|
.description("run a chat gateway (Telegram or WeChat) so you can drive your local hara from your phone — opt-in daemon")
|
|
858
|
-
.option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk", "telegram")
|
|
1477
|
+
.option("--platform <name>", "chat platform: telegram | weixin | discord | feishu | slack | mattermost | matrix | dingtalk | wecom | signal", "telegram")
|
|
859
1478
|
.option("--login", "(weixin) scan a QR to log in and save credentials, then exit")
|
|
860
1479
|
.option("--cwd <dir>", "directory hara operates in per message (default: ~/.hara/workspace)")
|
|
861
1480
|
.action(async (opts) => {
|
|
@@ -867,6 +1486,66 @@ program
|
|
|
867
1486
|
const cwd = opts.cwd ? (await import("node:path")).resolve(opts.cwd) : undefined; // undefined → ~/.hara/workspace
|
|
868
1487
|
await mod.runGateway({ cwd, platform: opts.platform });
|
|
869
1488
|
});
|
|
1489
|
+
program
|
|
1490
|
+
.command("remote [action] [text]")
|
|
1491
|
+
.description("drive THIS tmux session from chat: register the pane so WeChat replies inject back into it. actions: ask \"<q>\" | bind | back | status")
|
|
1492
|
+
.action(async (action = "status", text) => {
|
|
1493
|
+
const { registerTmuxRoute, unbindPane, listRoutes } = await import("./gateway/tmux-routes.js");
|
|
1494
|
+
const pane = process.env.TMUX_PANE; // set by tmux inside every pane
|
|
1495
|
+
const needPane = () => {
|
|
1496
|
+
if (!pane) {
|
|
1497
|
+
out(c.red("`hara remote` must run inside tmux ($TMUX_PANE unset) — it injects chat replies into a tmux pane.\n"));
|
|
1498
|
+
process.exit(2);
|
|
1499
|
+
}
|
|
1500
|
+
};
|
|
1501
|
+
if (action === "status") {
|
|
1502
|
+
const rs = listRoutes();
|
|
1503
|
+
out(rs.length ? rs.map((r) => `${r.pane} [${r.mode ?? "once"}] ${r.cwd ?? ""}`).join("\n") + "\n" : "(no panes registered)\n");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
if (action === "unbind" || action === "back") {
|
|
1507
|
+
needPane();
|
|
1508
|
+
out(unbindPane(pane) ? `✓ ${action === "back" ? "back from remote — unbound" : "unbound"} ${pane}\n` : `${pane} was not registered\n`);
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
if (action === "bind") {
|
|
1512
|
+
needPane();
|
|
1513
|
+
registerTmuxRoute(pane, undefined, process.cwd(), "bind");
|
|
1514
|
+
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`);
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
if (action === "ask") {
|
|
1518
|
+
needPane();
|
|
1519
|
+
if (!text)
|
|
1520
|
+
return void out(c.red('usage: hara remote ask "<question>"\n'));
|
|
1521
|
+
registerTmuxRoute(pane, undefined, process.cwd(), "once"); // register first — inbound inject works even if the push is throttled
|
|
1522
|
+
try {
|
|
1523
|
+
const wx = await import("./gateway/weixin.js");
|
|
1524
|
+
const creds = wx.loadWeixinCreds();
|
|
1525
|
+
if (!creds)
|
|
1526
|
+
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`));
|
|
1527
|
+
let peer = process.env.HARA_WX_PEER;
|
|
1528
|
+
if (!peer) {
|
|
1529
|
+
try {
|
|
1530
|
+
const f = join(homedir(), ".hara", "weixin", `${creds.account_id}.context-tokens.json`);
|
|
1531
|
+
const keys = Object.keys(JSON.parse(readFileSync(f, "utf8")));
|
|
1532
|
+
peer = keys.find((k) => k.endsWith("@im.wechat")) || keys[0];
|
|
1533
|
+
}
|
|
1534
|
+
catch {
|
|
1535
|
+
/* no peer file */
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
if (peer)
|
|
1539
|
+
await wx.weixinAdapter(creds).send(peer, text);
|
|
1540
|
+
out(c.green(`↩ asked on WeChat + registered ${pane}`) + ` — reply on WeChat and it'll be injected here. Daemon must be running.\n`);
|
|
1541
|
+
}
|
|
1542
|
+
catch (e) {
|
|
1543
|
+
out(c.yellow(`↩ ${pane} registered; WeChat push failed (${e.message}) — your next reply to the bot still injects here.\n`));
|
|
1544
|
+
}
|
|
1545
|
+
return;
|
|
1546
|
+
}
|
|
1547
|
+
out(c.red(`unknown action '${action}'. use: ask "<q>" | bind | back | status\n`));
|
|
1548
|
+
});
|
|
870
1549
|
program
|
|
871
1550
|
.command("export [session]")
|
|
872
1551
|
.description("export a session to a Markdown transcript (default: the latest in this directory)")
|
|
@@ -1217,6 +1896,14 @@ pluginCmd
|
|
|
1217
1896
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
1218
1897
|
].filter(Boolean);
|
|
1219
1898
|
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
1899
|
+
// A plugin can ship CLI commands (manifest `bin`); they're linked into ~/.hara/bin. Tell the user to PATH it.
|
|
1900
|
+
const bins = Object.keys(m.bin ?? {});
|
|
1901
|
+
if (bins.length) {
|
|
1902
|
+
const onPath = (process.env.PATH ?? "").split(":").includes(haraBinDir());
|
|
1903
|
+
out(c.green(`Linked command(s): ${bins.join(", ")} → ${c.dim(haraBinDir())}\n`));
|
|
1904
|
+
if (!onPath)
|
|
1905
|
+
out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
|
|
1906
|
+
}
|
|
1220
1907
|
// Surface the code-execution surface: a plugin's MCP servers + hooks run shell commands on every
|
|
1221
1908
|
// hara launch with no prompt. Installing a plugin = trusting its author to run code; show what.
|
|
1222
1909
|
const execs = [];
|
|
@@ -1289,6 +1976,10 @@ config
|
|
|
1289
1976
|
out(c.red(`Invalid sandbox mode. One of: ${SANDBOX_MODES.join(", ")}.\n`));
|
|
1290
1977
|
process.exit(1);
|
|
1291
1978
|
}
|
|
1979
|
+
if (key === "reasoningEffort" && !REASONING_EFFORTS.includes(value)) {
|
|
1980
|
+
out(c.red(`Invalid reasoning effort. One of: ${REASONING_EFFORTS.join(", ")}.\n`));
|
|
1981
|
+
process.exit(1);
|
|
1982
|
+
}
|
|
1292
1983
|
writeConfigValue(key, value);
|
|
1293
1984
|
out(c.green(`Set ${key} → ${configPath()}\n`));
|
|
1294
1985
|
});
|
|
@@ -1316,12 +2007,17 @@ config
|
|
|
1316
2007
|
.action(() => out(configPath() + "\n"));
|
|
1317
2008
|
// default action (interactive REPL / one-shot)
|
|
1318
2009
|
program.action(async (opts) => {
|
|
1319
|
-
|
|
2010
|
+
// Identity-profile selection (--profile flag) is now handled by the program-level preAction
|
|
2011
|
+
// hook above — see setFlagOverride() + resolveActive() in profile.ts. activeId() / loadActiveProfile()
|
|
2012
|
+
// pick it up automatically. `HARA_PROFILE` env still works as a transient override (one slot lower
|
|
2013
|
+
// in the priority chain than --profile).
|
|
2014
|
+
const cfg = loadConfig({ overlay: opts.overlay });
|
|
1320
2015
|
if (opts.model)
|
|
1321
2016
|
cfg.model = opts.model;
|
|
1322
2017
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1323
2018
|
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;
|
|
1324
2019
|
const fbOpt = fallbackProvider ? { provider: fallbackProvider } : undefined; // app-failover for the main chat turns
|
|
2020
|
+
const guardianOpt = await buildGuardian(cfg, provider0); // internal safety layer (high-risk actions only)
|
|
1325
2021
|
if (!provider0) {
|
|
1326
2022
|
// First-run friendliness: offer the setup wizard instead of just erroring (interactive TTY only).
|
|
1327
2023
|
if (stdin.isTTY && !opts.print) {
|
|
@@ -1338,7 +2034,18 @@ program.action(async (opts) => {
|
|
|
1338
2034
|
process.exit(1);
|
|
1339
2035
|
}
|
|
1340
2036
|
let provider = provider0;
|
|
1341
|
-
|
|
2037
|
+
// Active profile is the source of truth for gateway-side concerns (heartbeat / role sync).
|
|
2038
|
+
// Legacy: cfg.provider==='hara-gateway' kept for users still pointing config.json at the old
|
|
2039
|
+
// sentinel — but profile.kind is what the rest of the CLI now reasons about.
|
|
2040
|
+
const __activeP = loadActiveProfile();
|
|
2041
|
+
// Safety UX: first line of stdout = "where am I sending requests right now". Stable, scriptable,
|
|
2042
|
+
// and reassuring at the start of every session. Suppressed in pure -p print mode to keep that
|
|
2043
|
+
// path clean stdout-only (the user wants the model output, not banner noise). Set HARA_QUIET=1
|
|
2044
|
+
// to suppress everywhere.
|
|
2045
|
+
if (!opts.print && process.env.HARA_QUIET !== "1") {
|
|
2046
|
+
out(c.dim(activeProfileLine(__activeP)) + "\n");
|
|
2047
|
+
}
|
|
2048
|
+
if (__activeP.kind === "gateway" || cfg.provider === "hara-gateway") {
|
|
1342
2049
|
void heartbeat(); // fleet visibility — fire-and-forget, never blocks startup
|
|
1343
2050
|
void syncOrgRoles(); // refresh governed org-role bundle (B3) in the background; best-effort, never blocks
|
|
1344
2051
|
}
|
|
@@ -1389,6 +2096,35 @@ program.action(async (opts) => {
|
|
|
1389
2096
|
if (prior?.history)
|
|
1390
2097
|
history.push(...prior.history);
|
|
1391
2098
|
meta = prior?.meta ?? { id: rid ?? newSessionId(), cwd, provider: cfg.provider, model: cfg.model, title: "", createdAt: new Date().toISOString(), updatedAt: "" };
|
|
2099
|
+
// Apply per-session pinned model on headless resume (mirrors the interactive path).
|
|
2100
|
+
// --model flag wins (already on cfg.model) and is written back; otherwise restore meta.model.
|
|
2101
|
+
if (prior) {
|
|
2102
|
+
if (opts.model) {
|
|
2103
|
+
meta.model = cfg.model;
|
|
2104
|
+
}
|
|
2105
|
+
else if (meta.model && meta.model !== cfg.model) {
|
|
2106
|
+
const __allowed = __activeP.kind === "gateway" && __activeP.availableModels && __activeP.availableModels.length > 0;
|
|
2107
|
+
if (__allowed && !__activeP.availableModels.includes(meta.model)) {
|
|
2108
|
+
const __fb = __activeP.defaultModel || cfg.model;
|
|
2109
|
+
// headless: log to stderr so it doesn't pollute the captured stdout reply
|
|
2110
|
+
try {
|
|
2111
|
+
process.stderr.write(`hara: resumed session pinned '${meta.model}' not in availableModels — falling back to '${__fb}'.\n`);
|
|
2112
|
+
}
|
|
2113
|
+
catch { /* ignore */ }
|
|
2114
|
+
cfg.model = __fb;
|
|
2115
|
+
meta.model = __fb;
|
|
2116
|
+
const __rb = await buildProvider(cfg);
|
|
2117
|
+
if (__rb)
|
|
2118
|
+
provider = __rb;
|
|
2119
|
+
}
|
|
2120
|
+
else {
|
|
2121
|
+
cfg.model = meta.model;
|
|
2122
|
+
const __rb = await buildProvider(cfg);
|
|
2123
|
+
if (__rb)
|
|
2124
|
+
provider = __rb;
|
|
2125
|
+
}
|
|
2126
|
+
}
|
|
2127
|
+
}
|
|
1392
2128
|
}
|
|
1393
2129
|
// Inbound images (gateway): the platform downloaded the user's photo(s) and passed their paths via env.
|
|
1394
2130
|
// Let the agent actually SEE them — attached inline for a vision-capable main model, else described via the
|
|
@@ -1429,6 +2165,7 @@ program.action(async (opts) => {
|
|
|
1429
2165
|
projectContext,
|
|
1430
2166
|
memory: memoryDigest(cwd),
|
|
1431
2167
|
stats,
|
|
2168
|
+
guardian: guardianOpt, // safety layer stays on in headless -p (fail-open; breaker aborts, never hangs)
|
|
1432
2169
|
});
|
|
1433
2170
|
if (meta) {
|
|
1434
2171
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
@@ -1457,6 +2194,22 @@ program.action(async (opts) => {
|
|
|
1457
2194
|
},
|
|
1458
2195
|
});
|
|
1459
2196
|
const confirm = async (q) => (await rl.question(`${q} ${c.dim("[y/N]")} `)).trim().toLowerCase().startsWith("y");
|
|
2197
|
+
// ask_user (classic REPL): print the question + a numbered menu (matching the setup menu look) and read the
|
|
2198
|
+
// answer through the SAME rl.question channel confirm uses. A bare option number selects it; any other text
|
|
2199
|
+
// is taken as a free-text answer — so the user can always type their own response.
|
|
2200
|
+
const askUser = async (q, options) => {
|
|
2201
|
+
out(c.bold("\n? ") + q + "\n");
|
|
2202
|
+
const opts = (options ?? []).map((o) => o.trim()).filter(Boolean);
|
|
2203
|
+
opts.forEach((o, i) => out(` ${c.bold(String(i + 1))}) ${o}\n`));
|
|
2204
|
+
const hint = opts.length ? c.dim(`(1-${opts.length} to pick, or type your own answer) `) : c.dim("(type your answer) ");
|
|
2205
|
+
const raw = (await rl.question(`${c.cyan("›")} ${hint}`)).trim();
|
|
2206
|
+
if (opts.length) {
|
|
2207
|
+
const n = Number.parseInt(raw, 10);
|
|
2208
|
+
if (Number.isInteger(n) && n >= 1 && n <= opts.length)
|
|
2209
|
+
return opts[n - 1];
|
|
2210
|
+
}
|
|
2211
|
+
return raw;
|
|
2212
|
+
};
|
|
1460
2213
|
// shift+tab cycles the approval mode (classic REPL only; the TUI handles its own keys).
|
|
1461
2214
|
// Bare /approval is the reliable fallback everywhere.
|
|
1462
2215
|
if (stdin.isTTY && !useTui) {
|
|
@@ -1477,7 +2230,10 @@ program.action(async (opts) => {
|
|
|
1477
2230
|
/* keypress unavailable; /approval still works */
|
|
1478
2231
|
}
|
|
1479
2232
|
}
|
|
1480
|
-
|
|
2233
|
+
// First-run AGENTS.md offer — classic REPL only. In TUI mode we must NOT call rl.question before ink
|
|
2234
|
+
// mounts: a readline question puts stdin in a state ink can't read from, leaving the input box dead
|
|
2235
|
+
// (the TUI shows a `/init` tip instead, below). See the `tip` in the runTui header.
|
|
2236
|
+
if (!hasAgentsMd(cwd) && !useTui) {
|
|
1481
2237
|
const ans = (await rl.question(`${c.dim("No AGENTS.md here — analyze this project and create one?")} ${c.dim("[Y/n]")} `)).trim().toLowerCase();
|
|
1482
2238
|
if (ans === "" || ans.startsWith("y")) {
|
|
1483
2239
|
out(c.dim("Analyzing project…\n"));
|
|
@@ -1513,11 +2269,43 @@ program.action(async (opts) => {
|
|
|
1513
2269
|
createdAt: new Date().toISOString(),
|
|
1514
2270
|
updatedAt: "",
|
|
1515
2271
|
};
|
|
2272
|
+
// Per-session model precedence on resume:
|
|
2273
|
+
// 1. --model flag (already applied to cfg.model up-top) → wins and is written back to meta.model.
|
|
2274
|
+
// 2. resumed meta.model → restored into cfg.model (the user's last /model choice).
|
|
2275
|
+
// 3. otherwise leave cfg.model as the profile-resolved default.
|
|
2276
|
+
// Safety: if we're on a gateway profile with a finite availableModels list and the resumed
|
|
2277
|
+
// meta.model isn't in it (e.g. user switched profiles between sessions), warn and degrade to
|
|
2278
|
+
// profile.defaultModel — a stale pinned model shouldn't brick the resume.
|
|
2279
|
+
if (resumed) {
|
|
2280
|
+
if (opts.model) {
|
|
2281
|
+
// explicit --model on the command line wins; persist it onto the session.
|
|
2282
|
+
meta.model = cfg.model;
|
|
2283
|
+
}
|
|
2284
|
+
else if (meta.model && meta.model !== cfg.model) {
|
|
2285
|
+
const __ap = __activeP;
|
|
2286
|
+
const __allowed = __ap.kind === "gateway" && __ap.availableModels && __ap.availableModels.length > 0;
|
|
2287
|
+
if (__allowed && !__ap.availableModels.includes(meta.model)) {
|
|
2288
|
+
const __fallback = __ap.defaultModel || cfg.model;
|
|
2289
|
+
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`));
|
|
2290
|
+
cfg.model = __fallback;
|
|
2291
|
+
meta.model = __fallback;
|
|
2292
|
+
const __rebuilt = await buildProvider(cfg);
|
|
2293
|
+
if (__rebuilt)
|
|
2294
|
+
provider = __rebuilt;
|
|
2295
|
+
}
|
|
2296
|
+
else {
|
|
2297
|
+
cfg.model = meta.model;
|
|
2298
|
+
const __rebuilt = await buildProvider(cfg);
|
|
2299
|
+
if (__rebuilt)
|
|
2300
|
+
provider = __rebuilt;
|
|
2301
|
+
}
|
|
2302
|
+
}
|
|
2303
|
+
}
|
|
1516
2304
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
1517
2305
|
const memorySnap = memoryDigest(cwd); // durable memory, read once (frozen snapshot)
|
|
1518
2306
|
const buildMemory = () => (meta.workingSet?.length ? `## Working memory (this task)\n${meta.workingSet.map((w) => `- ${w}`).join("\n")}\n\n` : "") + memorySnap;
|
|
1519
2307
|
if (resumed)
|
|
1520
|
-
out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs)\n`));
|
|
2308
|
+
out(c.dim(`(resumed ${shortId(meta.id)} · ${history.length} msgs · model = ${cfg.model})\n`));
|
|
1521
2309
|
// Vision describer state — shared by the `/vision` command (both REPLs) and the TUI image pipeline.
|
|
1522
2310
|
let visionProvider;
|
|
1523
2311
|
let remindedVision = false;
|
|
@@ -1580,24 +2368,48 @@ program.action(async (opts) => {
|
|
|
1580
2368
|
},
|
|
1581
2369
|
{
|
|
1582
2370
|
name: "model",
|
|
1583
|
-
desc: "show or switch model: /model [id]",
|
|
2371
|
+
desc: "show or switch model: /model [id [--force|all]]",
|
|
1584
2372
|
run: async (a) => {
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
2373
|
+
const parts = (a || "").trim().split(/\s+/).filter(Boolean);
|
|
2374
|
+
const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
|
|
2375
|
+
const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
|
|
2376
|
+
if (!id) {
|
|
2377
|
+
// Bare /model: pinned model + per-role overrides table, so the user sees what's pinned now
|
|
2378
|
+
// and which roles deviate from it.
|
|
2379
|
+
const __force = isSessionForceModel();
|
|
2380
|
+
const __lines = [`${cfg.provider}:${cfg.model}`];
|
|
2381
|
+
if (meta.model && meta.model !== cfg.model) {
|
|
2382
|
+
__lines.push(c.dim(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`));
|
|
1595
2383
|
}
|
|
1596
|
-
else
|
|
1597
|
-
|
|
2384
|
+
else {
|
|
2385
|
+
__lines.push(c.dim(`session pinned: ${meta.model || "(none)"}${__force ? c.yellow(" · forced (all roles use session model)") : ""}`));
|
|
2386
|
+
}
|
|
2387
|
+
const __roles = loadRoles(cwd);
|
|
2388
|
+
if (__roles.length) {
|
|
2389
|
+
__lines.push(c.dim("roles:"));
|
|
2390
|
+
for (const r of __roles) {
|
|
2391
|
+
const eff = __force ? cfg.model : (r.model || cfg.model);
|
|
2392
|
+
const tag = __force && r.model && r.model !== cfg.model ? c.yellow(" (overridden by --force)") : r.model ? c.dim(" (role pin)") : c.dim(" (session)");
|
|
2393
|
+
__lines.push(` ${r.id}: ${eff}${tag}`);
|
|
2394
|
+
}
|
|
2395
|
+
}
|
|
2396
|
+
return void out(__lines.join("\n") + "\n");
|
|
2397
|
+
}
|
|
2398
|
+
cfg.model = id;
|
|
2399
|
+
meta.model = id;
|
|
2400
|
+
setSessionForceModel(force);
|
|
2401
|
+
visionProvider = undefined;
|
|
2402
|
+
remindedVision = false;
|
|
2403
|
+
const p = await buildProvider(cfg);
|
|
2404
|
+
if (p) {
|
|
2405
|
+
provider = p;
|
|
2406
|
+
if (bar.isActive())
|
|
2407
|
+
bar.update({ model: id });
|
|
2408
|
+
saveSession(meta, history); // persist the session-pinned model so resume restores it
|
|
2409
|
+
out(c.dim(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})\n`));
|
|
1598
2410
|
}
|
|
1599
2411
|
else
|
|
1600
|
-
out(
|
|
2412
|
+
out(c.red("(could not rebuild provider)\n"));
|
|
1601
2413
|
},
|
|
1602
2414
|
},
|
|
1603
2415
|
{
|
|
@@ -1789,6 +2601,21 @@ program.action(async (opts) => {
|
|
|
1789
2601
|
}
|
|
1790
2602
|
if (useTui) {
|
|
1791
2603
|
rl.close(); // hand stdin over to ink
|
|
2604
|
+
// First-run AGENTS.md offer — via a tiny ink prompt, NOT readline. A readline question before the
|
|
2605
|
+
// main TUI leaves stdin unreadable by ink (dead input box); ink cleans up on unmount, so the TUI
|
|
2606
|
+
// mounted right after gets working input. Runs before mount, like the classic path.
|
|
2607
|
+
if (!hasAgentsMd(cwd)) {
|
|
2608
|
+
if (await askConfirm("No AGENTS.md here — analyze this project and create one?")) {
|
|
2609
|
+
out(c.dim("Analyzing project…\n"));
|
|
2610
|
+
try {
|
|
2611
|
+
await runInit(provider, cwd, sandbox);
|
|
2612
|
+
}
|
|
2613
|
+
catch (e) {
|
|
2614
|
+
out(c.red(`[init error] ${e.message}\n`));
|
|
2615
|
+
}
|
|
2616
|
+
projectContext = loadAgentsMd(cwd) || undefined;
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
1792
2619
|
setTheme(cfg.theme);
|
|
1793
2620
|
// Vision: a text-only main model routes pasted images through a describer (`visionModel`); a
|
|
1794
2621
|
// vision-capable main model gets them inline (describer auto-suspended). Unknown models are asked
|
|
@@ -1876,19 +2703,48 @@ program.action(async (opts) => {
|
|
|
1876
2703
|
return { skip: true };
|
|
1877
2704
|
}
|
|
1878
2705
|
};
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
2706
|
+
// ── Header (rebuilt per 顾雅 spec, 2026-06):
|
|
2707
|
+
// • Single-line logo + tagline (no ASCII banner block).
|
|
2708
|
+
// • Identity line branches on profile kind: personal collapses to `personal <provider>:<model>`
|
|
2709
|
+
// (route host only when baseURL is custom); org spreads to `org <label> · <id> → <host>`
|
|
2710
|
+
// plus its own `model` line annotated with the source (org default / user override).
|
|
2711
|
+
// • cwd line silently appends "· AGENTS.md" when loaded — we never show a negative noise line.
|
|
2712
|
+
// • Vision routing is NOT in the header anymore — App emits a one-shot inline notice via
|
|
2713
|
+
// `visionNotice` the first time an image lands in the session.
|
|
2714
|
+
const __mainCap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2715
|
+
const __routeForHeader = routeHost(__activeP);
|
|
2716
|
+
// Model-source label (org only). `loadConfig` already merges env > project > overlay > globals,
|
|
2717
|
+
// so cfg.model is whatever the runtime will actually use. If it equals the profile's defaultModel
|
|
2718
|
+
// we treat it as "org default"; otherwise it's a user override (per-profile setModel, env, or flag).
|
|
2719
|
+
const __modelSource = __activeP.kind === "gateway"
|
|
2720
|
+
? cfg.model && __activeP.defaultModel && cfg.model === __activeP.defaultModel
|
|
2721
|
+
? "org default"
|
|
2722
|
+
: "user override"
|
|
2723
|
+
: undefined;
|
|
2724
|
+
// Lazy vision notice: only set it for the "describer in use" path (header used to always-on it).
|
|
2725
|
+
// Native-vision models stay silent (the routing IS direct, nothing to say). "Unknown" stays silent
|
|
2726
|
+
// too — the existing per-image picker (resolveImages) handles that on first paste.
|
|
2727
|
+
const __visionNotice = __mainCap === "text" && cfg.visionModel
|
|
2728
|
+
? `${cfg.model} is text-only — images read by ${cfg.visionModel}`
|
|
2729
|
+
: undefined;
|
|
1887
2730
|
await runTui({
|
|
1888
2731
|
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
1889
2732
|
model: cfg.model,
|
|
1890
2733
|
cwd,
|
|
1891
|
-
header: {
|
|
2734
|
+
header: {
|
|
2735
|
+
version: pkg.version,
|
|
2736
|
+
modelLabel: `${cfg.provider}:${cfg.model}`,
|
|
2737
|
+
cwd,
|
|
2738
|
+
agentsMdLoaded: !!projectContext,
|
|
2739
|
+
session: meta.id,
|
|
2740
|
+
kind: __activeP.kind === "gateway" ? "org" : "personal",
|
|
2741
|
+
profileId: __activeP.kind === "gateway" || __activeP.id === PERSONAL_ID ? undefined : __activeP.id,
|
|
2742
|
+
orgLabel: __activeP.kind === "gateway" ? __activeP.label : undefined,
|
|
2743
|
+
orgId: __activeP.kind === "gateway" ? __activeP.deviceId || __activeP.id : undefined,
|
|
2744
|
+
routeHost: __routeForHeader?.host,
|
|
2745
|
+
modelSource: __modelSource,
|
|
2746
|
+
},
|
|
2747
|
+
visionNotice: __visionNotice,
|
|
1892
2748
|
cycleApproval: (m) => cycleMode(m),
|
|
1893
2749
|
onClipboardImage: readClipboardImage,
|
|
1894
2750
|
vim: cfg.vimMode,
|
|
@@ -1933,15 +2789,39 @@ program.action(async (opts) => {
|
|
|
1933
2789
|
return void h.sink.notice("error" in r ? `(${r.error})` : `↩ reverted: ${r.files.join(", ")}`);
|
|
1934
2790
|
}
|
|
1935
2791
|
if (nm === "model") {
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
2792
|
+
const parts = (arg || "").trim().split(/\s+/).filter(Boolean);
|
|
2793
|
+
const force = parts.some((p) => p === "--force" || p === "all" || p === "-f");
|
|
2794
|
+
const id = parts.find((p) => p !== "--force" && p !== "all" && p !== "-f");
|
|
2795
|
+
if (!id) {
|
|
2796
|
+
const __force = isSessionForceModel();
|
|
2797
|
+
const __lines = [`model: ${cfg.provider}:${cfg.model}`];
|
|
2798
|
+
if (meta.model && meta.model !== cfg.model) {
|
|
2799
|
+
__lines.push(`session pinned: ${meta.model} (cfg drift — /model ${meta.model} to re-pin)`);
|
|
2800
|
+
}
|
|
2801
|
+
else {
|
|
2802
|
+
__lines.push(`session pinned: ${meta.model || "(none)"}${__force ? " · forced (all roles use session model)" : ""}`);
|
|
2803
|
+
}
|
|
2804
|
+
const __roles = loadRoles(cwd);
|
|
2805
|
+
if (__roles.length) {
|
|
2806
|
+
__lines.push("roles:");
|
|
2807
|
+
for (const r of __roles) {
|
|
2808
|
+
const eff = __force ? cfg.model : (r.model || cfg.model);
|
|
2809
|
+
const tag = __force && r.model && r.model !== cfg.model ? " (overridden by --force)" : r.model ? " (role pin)" : " (session)";
|
|
2810
|
+
__lines.push(` ${r.id}: ${eff}${tag}`);
|
|
2811
|
+
}
|
|
2812
|
+
}
|
|
2813
|
+
return void h.sink.notice(__lines.join("\n"));
|
|
2814
|
+
}
|
|
2815
|
+
cfg.model = id;
|
|
2816
|
+
meta.model = id;
|
|
2817
|
+
setSessionForceModel(force);
|
|
1939
2818
|
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
1940
2819
|
remindedVision = false;
|
|
1941
2820
|
const p = await buildProvider(cfg);
|
|
1942
2821
|
if (p) {
|
|
1943
2822
|
provider = p;
|
|
1944
|
-
|
|
2823
|
+
saveSession(meta, history); // persist the session-pinned model so resume restores it
|
|
2824
|
+
return void h.sink.notice(`(model → ${cfg.provider}:${id}${force ? " · forced (all roles)" : ""})`);
|
|
1945
2825
|
}
|
|
1946
2826
|
return void h.sink.notice("(could not rebuild provider)");
|
|
1947
2827
|
}
|
|
@@ -2095,6 +2975,37 @@ program.action(async (opts) => {
|
|
|
2095
2975
|
}
|
|
2096
2976
|
if (byName.has(nm))
|
|
2097
2977
|
return void h.sink.notice(`/${nm} isn't wired into the TUI yet — use \`hara ${nm} …\` as a subcommand, or HARA_TUI=0.`);
|
|
2978
|
+
// /<skill> — a user-invocable skill (built-in/global/plugin). ENTER it: load the skill + run a kickoff
|
|
2979
|
+
// turn so the agent acts at once (e.g. design mode opens its live workspace + surfaces prior progress).
|
|
2980
|
+
{
|
|
2981
|
+
const sk = loadSkillIndex(cwd).find((s) => s.id === nm && s.userInvocable);
|
|
2982
|
+
if (sk) {
|
|
2983
|
+
h.sink.notice(`↗ entering ${sk.id}…`);
|
|
2984
|
+
history.push({
|
|
2985
|
+
role: "user",
|
|
2986
|
+
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.`,
|
|
2987
|
+
});
|
|
2988
|
+
const skin = stats.input;
|
|
2989
|
+
const skout = stats.output;
|
|
2990
|
+
// `h.approval` is the TUI-level union (includes "plan"); runAgent wants the config-level
|
|
2991
|
+
// ApprovalMode (no "plan"). Inside a /<skill> kickoff "plan" wouldn't make sense anyway —
|
|
2992
|
+
// fall back to "suggest" so we keep the user's confirm gate without crashing the type check.
|
|
2993
|
+
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
2994
|
+
try {
|
|
2995
|
+
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 }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
2996
|
+
}
|
|
2997
|
+
catch (e) {
|
|
2998
|
+
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
2999
|
+
}
|
|
3000
|
+
if (!meta.title) {
|
|
3001
|
+
meta.title = await nameSession(provider, history);
|
|
3002
|
+
h.sink.session(meta.title);
|
|
3003
|
+
}
|
|
3004
|
+
h.sink.usage(stats.input - skin, stats.output - skout);
|
|
3005
|
+
saveSession(meta, history);
|
|
3006
|
+
return;
|
|
3007
|
+
}
|
|
3008
|
+
}
|
|
2098
3009
|
const near = nearest(nm, [...byName.keys()]);
|
|
2099
3010
|
return void h.sink.notice(`Unknown command /${nm}.${near.length ? " Did you mean " + near.map((n) => "/" + n).join(", ") + "?" : ""}`);
|
|
2100
3011
|
}
|
|
@@ -2127,7 +3038,7 @@ program.action(async (opts) => {
|
|
|
2127
3038
|
const pout = stats.output;
|
|
2128
3039
|
await runAgent(history, {
|
|
2129
3040
|
provider,
|
|
2130
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3041
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2131
3042
|
approval: "suggest",
|
|
2132
3043
|
confirm: h.confirm,
|
|
2133
3044
|
toolFilter: (n) => READONLY_TOOLS.has(n),
|
|
@@ -2156,7 +3067,7 @@ program.action(async (opts) => {
|
|
|
2156
3067
|
const xout = stats.output;
|
|
2157
3068
|
await runAgent(history, {
|
|
2158
3069
|
provider,
|
|
2159
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3070
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2160
3071
|
approval: choice,
|
|
2161
3072
|
memory: buildMemory(),
|
|
2162
3073
|
confirm: h.confirm,
|
|
@@ -2165,6 +3076,7 @@ program.action(async (opts) => {
|
|
|
2165
3076
|
stats,
|
|
2166
3077
|
signal: h.signal,
|
|
2167
3078
|
pendingInput,
|
|
3079
|
+
guardian: guardianOpt,
|
|
2168
3080
|
});
|
|
2169
3081
|
h.sink.usage(stats.input - xin, stats.output - xout);
|
|
2170
3082
|
saveSession(meta, history);
|
|
@@ -2184,7 +3096,7 @@ program.action(async (opts) => {
|
|
|
2184
3096
|
const beforeOut = stats.output;
|
|
2185
3097
|
await runAgent(history, {
|
|
2186
3098
|
provider,
|
|
2187
|
-
ctx: { cwd, sandbox, spawn, ui, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
3099
|
+
ctx: { cwd, sandbox, spawn, ui, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot },
|
|
2188
3100
|
approval: appr,
|
|
2189
3101
|
memory: buildMemory(),
|
|
2190
3102
|
confirm: h.confirm,
|
|
@@ -2194,6 +3106,7 @@ program.action(async (opts) => {
|
|
|
2194
3106
|
signal: h.signal,
|
|
2195
3107
|
pendingInput,
|
|
2196
3108
|
fallback: fbOpt,
|
|
3109
|
+
guardian: guardianOpt,
|
|
2197
3110
|
});
|
|
2198
3111
|
if (!meta.title) {
|
|
2199
3112
|
meta.title = await nameSession(provider, history);
|
|
@@ -2205,11 +3118,12 @@ program.action(async (opts) => {
|
|
|
2205
3118
|
await maybeAutoCompact(provider, history, meta, stats, cfg, (m) => h.sink.notice(m));
|
|
2206
3119
|
},
|
|
2207
3120
|
});
|
|
3121
|
+
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
2208
3122
|
await closeMcp();
|
|
2209
3123
|
process.exit(0); // TUI done — exit cleanly (ink can leave stdin referenced)
|
|
2210
3124
|
}
|
|
2211
3125
|
out(c.dim(`Type a task. /help · @path attaches a file · shift+tab cycles mode · Esc interrupts · /exit to quit.${projectContext ? " (AGENTS.md loaded)" : ""}\n\n`));
|
|
2212
|
-
bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output });
|
|
3126
|
+
bar.install({ sessionName: meta.title || shortId(meta.id), model: cfg.model, approval, input: stats.input, output: stats.output, profileId: __activeP.id, profileKind: __activeP.kind });
|
|
2213
3127
|
process.on("exit", () => {
|
|
2214
3128
|
try {
|
|
2215
3129
|
bar.uninstall();
|
|
@@ -2234,6 +3148,30 @@ program.action(async (opts) => {
|
|
|
2234
3148
|
const [name, ...rest] = line.slice(1).split(/\s+/);
|
|
2235
3149
|
const cmd = byName.get(name);
|
|
2236
3150
|
if (!cmd) {
|
|
3151
|
+
const sk = loadSkillIndex(cwd).find((s) => s.id === name && s.userInvocable);
|
|
3152
|
+
if (sk) {
|
|
3153
|
+
// ENTER the mode: load the skill + run a kickoff turn now (mirrors the TUI path) so e.g. /design
|
|
3154
|
+
// opens its workspace + surfaces prior progress immediately, instead of just staging context.
|
|
3155
|
+
out(c.dim(`↗ entering ${sk.id}…\n`));
|
|
3156
|
+
history.push({
|
|
3157
|
+
role: "user",
|
|
3158
|
+
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.`,
|
|
3159
|
+
});
|
|
3160
|
+
currentTurn = new AbortController();
|
|
3161
|
+
try {
|
|
3162
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
3163
|
+
}
|
|
3164
|
+
catch (e) {
|
|
3165
|
+
out(c.red(`\n[error] ${e.message}\n`));
|
|
3166
|
+
}
|
|
3167
|
+
finally {
|
|
3168
|
+
currentTurn = null;
|
|
3169
|
+
}
|
|
3170
|
+
if (!meta.title)
|
|
3171
|
+
meta.title = await nameSession(provider, history);
|
|
3172
|
+
saveSession(meta, history);
|
|
3173
|
+
continue;
|
|
3174
|
+
}
|
|
2237
3175
|
const near = nearest(name, [...byName.keys()]);
|
|
2238
3176
|
const hint = near.length ? c.dim(` Did you mean ${near.map((n) => "/" + n).join(", ")}?`) : "";
|
|
2239
3177
|
out(c.red(`Unknown command /${name}.`) + hint + c.dim(" — /help for the list.\n"));
|
|
@@ -2252,7 +3190,7 @@ program.action(async (opts) => {
|
|
|
2252
3190
|
currentTurn = new AbortController();
|
|
2253
3191
|
const t0 = Date.now();
|
|
2254
3192
|
try {
|
|
2255
|
-
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt });
|
|
3193
|
+
await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), stats, signal: currentTurn.signal, fallback: fbOpt, guardian: guardianOpt });
|
|
2256
3194
|
}
|
|
2257
3195
|
catch (e) {
|
|
2258
3196
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -2282,6 +3220,7 @@ program.action(async (opts) => {
|
|
|
2282
3220
|
}
|
|
2283
3221
|
}
|
|
2284
3222
|
bar.uninstall();
|
|
3223
|
+
out("\n" + c.dim("Session ") + c.bold(shortId(meta.id)) + c.dim(" saved · resume: ") + c.cyan(`hara resume ${shortId(meta.id)}`) + "\n");
|
|
2285
3224
|
rl.close();
|
|
2286
3225
|
await closeMcp();
|
|
2287
3226
|
});
|