@nanhara/hara 0.125.3 → 0.126.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +59 -1
- package/README.md +10 -4
- package/SECURITY.md +7 -3
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +270 -98
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +372 -159
- package/dist/mcp/client.js +2 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/openai.js +6 -1
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +139 -35
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +19 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +5 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -14,7 +14,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
16
|
import { delimiter, dirname, join, relative, resolve } from "node:path";
|
|
17
|
-
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
|
|
17
|
+
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerCatalog, providerEnvKey, providerDefaultBaseURL, providerIsLocal, providerRequiresApiKey, normalizePersonalProviderConfig, reusablePersonalProviderApiKey, updatePersonalProviderConfig, isProviderId, CONFIG_KEYS, APPROVAL_MODES, SANDBOX_MODES, REASONING_EFFORTS, } from "./config.js";
|
|
18
18
|
import { runAgent } from "./agent/loop.js";
|
|
19
19
|
import { formatAgentDuration, parseAgentRunTimeoutMs, MIN_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_RUN_TIMEOUT_MS, MAX_AGENT_MAX_ROUNDS, } from "./agent/limits.js";
|
|
20
20
|
import { parseSchemaArg, structuredOutputTool, STRUCTURED_INSTRUCTION, STRUCTURED_NUDGE } from "./agent/structured.js";
|
|
@@ -45,8 +45,8 @@ import { resetReachability } from "./tools/net-reachability.js";
|
|
|
45
45
|
import { resetRepeatGuard } from "./agent/repeat-guard.js";
|
|
46
46
|
import { allowsEvolutionTool, EVOLUTION_SYSTEM, evolutionStatus, shouldAutoEvolve } from "./agent/evolution.js";
|
|
47
47
|
import { EXPLORE_SYSTEM } from "./tools/agent.js";
|
|
48
|
-
import {
|
|
49
|
-
import {
|
|
48
|
+
import { overrideProviderTarget, profileForConfig, resolveByokProviderTarget, resolveGatewayModel, } from "./providers/target.js";
|
|
49
|
+
import { createProviderForTarget } from "./providers/factory.js";
|
|
50
50
|
import { resolvePlatform } from "./providers/registry.js";
|
|
51
51
|
import { boundedProviderTurn } from "./providers/bounded-turn.js";
|
|
52
52
|
import { levelsFor } from "./tui/model-picker.js";
|
|
@@ -54,6 +54,7 @@ import { listModels } from "./providers/models.js";
|
|
|
54
54
|
import { listJobs, tailJob, killJob } from "./exec/jobs.js";
|
|
55
55
|
import { readModelContextFileSync } from "./fs-read.js";
|
|
56
56
|
import { MIN_NODE_VERSION, unsupportedNodeMessage } from "./runtime.js";
|
|
57
|
+
import { redactKnownSecrets } from "./security/secrets.js";
|
|
57
58
|
/** Render the background-job list for /jobs (user-facing view of what the agent has running in the
|
|
58
59
|
* background — dev servers, watchers, long tasks). Mirrors codex/Claude-Code process visibility. */
|
|
59
60
|
function renderBgJobs() {
|
|
@@ -68,7 +69,7 @@ function renderBgJobs() {
|
|
|
68
69
|
});
|
|
69
70
|
return `Background jobs — /jobs tail <id> · /jobs kill <id>:\n${rows.join("\n")}`;
|
|
70
71
|
}
|
|
71
|
-
import { qwenDeviceLogin,
|
|
72
|
+
import { qwenDeviceLogin, loadQwenToken } from "./providers/qwen-oauth.js";
|
|
72
73
|
import { loadAgentContext, hasAgentsMd, INIT_PROMPT, findProjectRoot } from "./context/agents-md.js";
|
|
73
74
|
import { homeWorkspaceActionError, isUnsafeProjectWorkspace, resolveWorkspaceSwitch, } from "./context/workspace-scope.js";
|
|
74
75
|
import { getEmbedder } from "./search/embed.js";
|
|
@@ -79,6 +80,7 @@ import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExi
|
|
|
79
80
|
import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, routeTaskInteraction, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
|
|
80
81
|
import { displaySessionCwd, resolveSessionResumeTarget } from "./session/resume.js";
|
|
81
82
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
83
|
+
import { createPhysicalOperationDrain } from "./session/operation-drain.js";
|
|
82
84
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
83
85
|
import { buildAgentsIndex, canonicalProjectPath, resolveAgent, loadProjects, addProject, removeProject } from "./org/projects.js";
|
|
84
86
|
import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "./skills/skills.js";
|
|
@@ -93,6 +95,7 @@ import { c, out, statusLine } from "./ui.js";
|
|
|
93
95
|
import * as bar from "./statusbar.js";
|
|
94
96
|
import { nearest } from "./fuzzy.js";
|
|
95
97
|
import "./tools/builtin.js"; // register read_file/write_file/bash
|
|
98
|
+
import "./tools/runtime.js"; // register tool_search/tool_result_read
|
|
96
99
|
import "./tools/edit.js"; // register edit_file
|
|
97
100
|
import "./tools/search.js"; // register grep/glob/ls
|
|
98
101
|
import "./tools/patch.js"; // register apply_patch
|
|
@@ -123,70 +126,52 @@ const pkg = {
|
|
|
123
126
|
}
|
|
124
127
|
})()),
|
|
125
128
|
};
|
|
126
|
-
const maskKey = (v) => (v ?
|
|
127
|
-
async function buildProvider(cfg) {
|
|
129
|
+
const maskKey = (v) => (v ? `••••${v.slice(-4)}` : "(unset)");
|
|
130
|
+
async function buildProvider(cfg, targetOverride) {
|
|
128
131
|
// Identity-profile is the source of truth for routing. `cfg` is the *merged* HaraConfig (env +
|
|
129
132
|
// project + global) and still drives non-routing concerns (model overrides, baseURL fallbacks
|
|
130
133
|
// for things like vision/route/fallback sidecars). The active profile decides "where to send
|
|
131
134
|
// requests" — gateway (deviceToken at the gateway) vs BYOK (user's key direct to the provider).
|
|
132
|
-
const ap =
|
|
133
|
-
|
|
134
|
-
// cfg that explicitly carries an apiKey + baseURL, honor those over the profile — they're the
|
|
135
|
-
// sidecar's intended target. Detected by "cfg.apiKey present + cfg.baseURL present and we're not
|
|
136
|
-
// routing to a gateway." This keeps `withRouting`/vision unchanged.
|
|
137
|
-
const isSidecarOverride = !!cfg.apiKey && !!cfg.baseURL && ap.kind === "byok" && cfg.apiKey !== ap.apiKey;
|
|
138
|
-
if (ap.kind === "gateway" && !isSidecarOverride) {
|
|
135
|
+
const { profile: ap } = profileForConfig(cfg);
|
|
136
|
+
if (ap.kind === "gateway") {
|
|
139
137
|
if (!ap.gatewayUrl || !ap.deviceToken)
|
|
140
138
|
return null;
|
|
141
139
|
const baseURL = ap.baseURL || `${ap.gatewayUrl.replace(/\/$/, "")}/v1`;
|
|
142
|
-
const model = cfg.model
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const wire = resolvePlatform(provider, baseURL).wireApi;
|
|
166
|
-
if (wire === "anthropic") {
|
|
167
|
-
return createAnthropicProvider({ apiKey, model, baseURL, reasoningEffort: cfg.reasoningEffort });
|
|
168
|
-
}
|
|
169
|
-
if (wire === "responses") {
|
|
170
|
-
// The OpenAI Responses API (e.g. Aliyun Token Plan's newest models) — a distinct wire hara doesn't
|
|
171
|
-
// speak yet. Fail with guidance instead of sending a chat body it will reject. (Tracked for when a
|
|
172
|
-
// Token-Plan key is available to build + verify against; the chat + anthropic endpoints work today.)
|
|
173
|
-
return {
|
|
174
|
-
id: provider,
|
|
175
|
-
model,
|
|
176
|
-
async turn() {
|
|
177
|
-
return { text: "", toolUses: [], stop: "error", errorMsg: `This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at the plan's OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or its Anthropic-compatible endpoint (…/apps/anthropic) instead.` };
|
|
178
|
-
},
|
|
179
|
-
};
|
|
180
|
-
}
|
|
181
|
-
return createOpenAIProvider({ apiKey, model, baseURL, label: provider, reasoningEffort: cfg.reasoningEffort });
|
|
140
|
+
const model = resolveGatewayModel(cfg, ap, process.env, targetOverride?.model);
|
|
141
|
+
const target = { provider: "hara-gateway", apiKey: ap.deviceToken, baseURL, model };
|
|
142
|
+
const built = await createProviderForTarget(target, cfg.reasoningEffort);
|
|
143
|
+
if (!targetOverride && built) {
|
|
144
|
+
cfg.provider = target.provider;
|
|
145
|
+
cfg.model = target.model;
|
|
146
|
+
cfg.baseURL = target.baseURL;
|
|
147
|
+
cfg.apiKey = undefined;
|
|
148
|
+
}
|
|
149
|
+
return built;
|
|
150
|
+
}
|
|
151
|
+
const baseTarget = resolveByokProviderTarget(cfg, ap, false);
|
|
152
|
+
const target = overrideProviderTarget(baseTarget, targetOverride);
|
|
153
|
+
const built = await createProviderForTarget(target, cfg.reasoningEffort);
|
|
154
|
+
// The rest of the active run (status, vision classification, role defaults, resume) must see the resolved
|
|
155
|
+
// identity route rather than the always-populated Personal/global fields. Explicit sidecars stay isolated.
|
|
156
|
+
if (!targetOverride && built) {
|
|
157
|
+
cfg.provider = target.provider;
|
|
158
|
+
cfg.model = target.model;
|
|
159
|
+
cfg.baseURL = target.baseURL;
|
|
160
|
+
cfg.apiKey = target.apiKey;
|
|
161
|
+
}
|
|
162
|
+
return built;
|
|
182
163
|
}
|
|
183
164
|
/** Wrap the main provider with per-turn model routing when `routeModel` is configured: trivial/non-coding
|
|
184
165
|
* turns go to the alternate (cheap/general) model, real coding/action work stays on the primary. No-op when
|
|
185
166
|
* routeModel is unset or equals the primary model. routeBaseURL/routeApiKey default to the primary's. */
|
|
186
167
|
async function withRouting(primary, cfg) {
|
|
187
|
-
if (!primary || !cfg.routeModel || cfg.routeModel ===
|
|
168
|
+
if (!primary || !cfg.routeModel || cfg.routeModel === primary.model)
|
|
188
169
|
return primary;
|
|
189
|
-
const alt = await buildProvider(
|
|
170
|
+
const alt = await buildProvider(cfg, {
|
|
171
|
+
model: cfg.routeModel,
|
|
172
|
+
...(cfg.routeBaseURL ? { baseURL: cfg.routeBaseURL } : {}),
|
|
173
|
+
...(cfg.routeApiKey ? { apiKey: cfg.routeApiKey } : {}),
|
|
174
|
+
});
|
|
190
175
|
return alt ? routingProvider(primary, alt) : primary;
|
|
191
176
|
}
|
|
192
177
|
/** Guardian veto model: the CHEAP tier if `routeModel` is configured (a small classifier call, not real
|
|
@@ -197,21 +182,148 @@ async function buildGuardian(cfg, primary) {
|
|
|
197
182
|
if (cfg.guardian === "off")
|
|
198
183
|
return undefined;
|
|
199
184
|
let gp = primary;
|
|
200
|
-
if (cfg.routeModel && cfg.routeModel !==
|
|
201
|
-
gp = (await buildProvider(
|
|
185
|
+
if (cfg.routeModel && cfg.routeModel !== primary?.model) {
|
|
186
|
+
gp = (await buildProvider(cfg, {
|
|
187
|
+
model: cfg.routeModel,
|
|
188
|
+
...(cfg.routeBaseURL ? { baseURL: cfg.routeBaseURL } : {}),
|
|
189
|
+
...(cfg.routeApiKey ? { apiKey: cfg.routeApiKey } : {}),
|
|
190
|
+
})) ?? primary;
|
|
202
191
|
}
|
|
203
192
|
return { provider: gp, enabled: true };
|
|
204
193
|
}
|
|
205
194
|
function authHint(cfg) {
|
|
206
|
-
const ap =
|
|
195
|
+
const { profile: ap } = profileForConfig(cfg);
|
|
207
196
|
if (ap.kind === "gateway")
|
|
208
197
|
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>\`.`;
|
|
209
|
-
const
|
|
198
|
+
const target = resolveByokProviderTarget(cfg, ap, false);
|
|
199
|
+
const provider = target.provider;
|
|
210
200
|
if (provider === "qwen-oauth")
|
|
211
201
|
return `Run ${c.bold("hara login qwen")} to authenticate.`;
|
|
202
|
+
if (providerIsLocal(provider)) {
|
|
203
|
+
return `Start ${provider === "ollama" ? "Ollama" : "LM Studio"} at ${c.bold(target.baseURL ?? "its local endpoint")}, then choose an installed model.`;
|
|
204
|
+
}
|
|
212
205
|
return `Set ${c.bold(providerEnvKey(provider))} (or ${c.bold("HARA_API_KEY")}), or run ${c.bold("hara setup")}.`;
|
|
213
206
|
}
|
|
214
|
-
|
|
207
|
+
function providerEnvironmentOverride() {
|
|
208
|
+
return !!(process.env.HARA_PROVIDER ||
|
|
209
|
+
process.env.HARA_MODEL ||
|
|
210
|
+
process.env.HARA_BASE_URL);
|
|
211
|
+
}
|
|
212
|
+
function providerSettingsSnapshot(targetCwd) {
|
|
213
|
+
const live = loadConfig({ cwd: targetCwd });
|
|
214
|
+
const { profile, resolution } = profileForConfig(live);
|
|
215
|
+
const catalog = providerCatalog();
|
|
216
|
+
if (profile.kind === "gateway") {
|
|
217
|
+
const entry = catalog.find((candidate) => candidate.id === "hara-gateway");
|
|
218
|
+
return {
|
|
219
|
+
current: {
|
|
220
|
+
provider: "hara-gateway",
|
|
221
|
+
model: process.env.HARA_MODEL || effectiveModel(profile) || live.model,
|
|
222
|
+
baseURL: profile.baseURL || (profile.gatewayUrl ? `${profile.gatewayUrl.replace(/\/+$/, "")}/v1` : undefined),
|
|
223
|
+
location: entry.location,
|
|
224
|
+
auth: entry.auth,
|
|
225
|
+
keyConfigured: !!profile.deviceToken,
|
|
226
|
+
authenticated: !!profile.gatewayUrl && !!profile.deviceToken,
|
|
227
|
+
profileId: profile.id,
|
|
228
|
+
profileKind: profile.kind,
|
|
229
|
+
profileSource: resolution.source,
|
|
230
|
+
editable: false,
|
|
231
|
+
},
|
|
232
|
+
providers: catalog,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
const target = resolveByokProviderTarget(live, profile, false);
|
|
236
|
+
const provider = target.provider;
|
|
237
|
+
const entry = catalog.find((candidate) => candidate.id === provider) ?? catalog[0];
|
|
238
|
+
const baseURL = target.baseURL;
|
|
239
|
+
const model = target.model;
|
|
240
|
+
const apiKey = target.apiKey;
|
|
241
|
+
const environmentOverride = providerEnvironmentOverride();
|
|
242
|
+
const keyConfigured = providerIsLocal(provider) ||
|
|
243
|
+
(provider === "qwen-oauth" ? loadQwenToken() !== null : !!apiKey);
|
|
244
|
+
return {
|
|
245
|
+
current: {
|
|
246
|
+
provider,
|
|
247
|
+
model,
|
|
248
|
+
...(baseURL ? { baseURL } : {}),
|
|
249
|
+
location: entry.location,
|
|
250
|
+
auth: entry.auth,
|
|
251
|
+
keyConfigured,
|
|
252
|
+
authenticated: keyConfigured,
|
|
253
|
+
profileId: profile.id,
|
|
254
|
+
profileKind: profile.kind,
|
|
255
|
+
profileSource: resolution.source,
|
|
256
|
+
editable: profile.id === PERSONAL_ID && !environmentOverride,
|
|
257
|
+
...(environmentOverride ? { environmentOverride: true } : {}),
|
|
258
|
+
},
|
|
259
|
+
providers: catalog,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
async function testProviderSettingsCandidate(input) {
|
|
263
|
+
if (!isProviderId(input.provider) || input.provider === "hara-gateway") {
|
|
264
|
+
throw new Error("provider is not a configurable personal provider");
|
|
265
|
+
}
|
|
266
|
+
const candidate = normalizePersonalProviderConfig({
|
|
267
|
+
provider: input.provider,
|
|
268
|
+
model: input.model,
|
|
269
|
+
baseURL: input.baseURL,
|
|
270
|
+
apiKey: input.apiKey,
|
|
271
|
+
clearApiKey: input.clearApiKey,
|
|
272
|
+
});
|
|
273
|
+
const raw = readRawConfig();
|
|
274
|
+
const apiKey = reusablePersonalProviderApiKey(candidate, raw);
|
|
275
|
+
if (providerRequiresApiKey(candidate.provider) && !apiKey) {
|
|
276
|
+
return {
|
|
277
|
+
ok: false,
|
|
278
|
+
models: [],
|
|
279
|
+
error: "A new API key is required when the provider or endpoint changes",
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
const models = await listModels(candidate.baseURL, apiKey ?? "");
|
|
283
|
+
const probeModel = providerIsLocal(candidate.provider) &&
|
|
284
|
+
models.length > 0 &&
|
|
285
|
+
!models.includes(candidate.model) &&
|
|
286
|
+
(candidate.model === "local-model" || candidate.model === "qwen3")
|
|
287
|
+
? models[0]
|
|
288
|
+
: candidate.model;
|
|
289
|
+
const provider = await createProviderForTarget({
|
|
290
|
+
provider: candidate.provider,
|
|
291
|
+
apiKey,
|
|
292
|
+
model: probeModel,
|
|
293
|
+
baseURL: candidate.baseURL,
|
|
294
|
+
});
|
|
295
|
+
if (!provider) {
|
|
296
|
+
const error = candidate.provider === "qwen-oauth"
|
|
297
|
+
? "Qwen browser sign-in is not complete; run `hara login qwen` first"
|
|
298
|
+
: "provider is not authenticated";
|
|
299
|
+
return { ok: false, models, error };
|
|
300
|
+
}
|
|
301
|
+
const result = await boundedProviderTurn(provider, {
|
|
302
|
+
system: "This is a connection check. Reply with the single word ok.",
|
|
303
|
+
history: [{ role: "user", content: "ok" }],
|
|
304
|
+
tools: [],
|
|
305
|
+
onText: () => { },
|
|
306
|
+
}, { timeoutMs: 12_000, label: "provider connection test" });
|
|
307
|
+
if (result.stop === "error") {
|
|
308
|
+
return {
|
|
309
|
+
ok: false,
|
|
310
|
+
models,
|
|
311
|
+
error: redactKnownSecrets(result.errorMsg || "provider connection test failed", [apiKey]).text.slice(0, 500),
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
return { ok: true, models };
|
|
315
|
+
}
|
|
316
|
+
const SETUP_DEFAULT_MODEL = {
|
|
317
|
+
anthropic: "claude-opus-4-8",
|
|
318
|
+
qwen: "qwen-plus",
|
|
319
|
+
openai: "gpt-4o-mini",
|
|
320
|
+
glm: "glm-4.6",
|
|
321
|
+
deepseek: "deepseek-chat",
|
|
322
|
+
openrouter: "openai/gpt-4o-mini",
|
|
323
|
+
ollama: "qwen3",
|
|
324
|
+
lmstudio: "local-model",
|
|
325
|
+
"qwen-oauth": "coder-model",
|
|
326
|
+
};
|
|
215
327
|
/** Numbered provider menu for `hara setup`. Order is the displayed order; `id` maps to a ProviderId
|
|
216
328
|
* (or the special "custom"/"qwen-oauth" routes). GLM/DeepSeek carry a preset base URL so the user
|
|
217
329
|
* never types one; "custom" prompts for an OpenAI-compatible base URL. */
|
|
@@ -221,6 +333,8 @@ const SETUP_MENU = [
|
|
|
221
333
|
{ label: "GLM (Zhipu)", id: "glm" },
|
|
222
334
|
{ label: "DeepSeek", id: "deepseek" },
|
|
223
335
|
{ label: "Qwen (DashScope key)", id: "qwen" },
|
|
336
|
+
{ label: "Ollama (local, no key)", id: "ollama" },
|
|
337
|
+
{ label: "LM Studio (local, no key)", id: "lmstudio" },
|
|
224
338
|
{ label: "OpenAI-compatible (custom base URL)", id: "custom" },
|
|
225
339
|
{ label: "Qwen — free, no key (browser sign-in)", id: "qwen-oauth" },
|
|
226
340
|
];
|
|
@@ -299,12 +413,12 @@ function readSecret(prompt, rl) {
|
|
|
299
413
|
* friendly "connected" hint; the wizard saves config regardless. */
|
|
300
414
|
async function pingProvider(args) {
|
|
301
415
|
const { provider, apiKey, model, baseURL } = args;
|
|
302
|
-
if (!apiKey || !model)
|
|
416
|
+
if ((!apiKey && !providerIsLocal(provider)) || !model)
|
|
303
417
|
return false;
|
|
304
|
-
const prov = provider === "anthropic"
|
|
305
|
-
? createAnthropicProvider({ apiKey, model, baseURL })
|
|
306
|
-
: createOpenAIProvider({ apiKey, model, baseURL, label: provider });
|
|
307
418
|
try {
|
|
419
|
+
const prov = await createProviderForTarget({ provider, apiKey: apiKey || undefined, model, baseURL });
|
|
420
|
+
if (!prov)
|
|
421
|
+
return false;
|
|
308
422
|
const r = await boundedProviderTurn(prov, {
|
|
309
423
|
system: "Reply with the single word: ok",
|
|
310
424
|
history: [{ role: "user", content: "ping" }],
|
|
@@ -337,8 +451,11 @@ async function runSetup() {
|
|
|
337
451
|
if (choice.id === "qwen-oauth") {
|
|
338
452
|
try {
|
|
339
453
|
await qwenDeviceLogin((m) => out(m + "\n"));
|
|
340
|
-
|
|
341
|
-
|
|
454
|
+
updatePersonalProviderConfig({
|
|
455
|
+
provider: "qwen-oauth",
|
|
456
|
+
model: "coder-model",
|
|
457
|
+
clearApiKey: true,
|
|
458
|
+
});
|
|
342
459
|
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`));
|
|
343
460
|
}
|
|
344
461
|
catch (e) {
|
|
@@ -361,18 +478,20 @@ async function runSetup() {
|
|
|
361
478
|
baseURL = providerDefaultBaseURL(provider) ?? "";
|
|
362
479
|
}
|
|
363
480
|
const envKey = providerEnvKey(provider);
|
|
364
|
-
const apiKey = (
|
|
481
|
+
const apiKey = providerRequiresApiKey(provider)
|
|
482
|
+
? (await readSecret(`API key ${c.dim(`(masked; blank = use the ${envKey} env var)`)}: `, rl)).trim()
|
|
483
|
+
: "";
|
|
365
484
|
const defaultModel = SETUP_DEFAULT_MODEL[choice.id === "custom" ? "openai" : provider] ?? "";
|
|
366
485
|
const model = (await rl.question(`Model [${defaultModel || "?"}]: `)).trim() || defaultModel;
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
486
|
+
updatePersonalProviderConfig({
|
|
487
|
+
provider,
|
|
488
|
+
model,
|
|
489
|
+
...(baseURL ? { baseURL } : {}),
|
|
490
|
+
...(apiKey ? { apiKey } : {}),
|
|
491
|
+
...(!providerRequiresApiKey(provider) ? { clearApiKey: true } : {}),
|
|
492
|
+
});
|
|
374
493
|
// One-shot validation ping (best-effort; never blocks saving). Only when we have a key + model.
|
|
375
|
-
if (apiKey && model) {
|
|
494
|
+
if ((apiKey || providerIsLocal(provider)) && model) {
|
|
376
495
|
out(c.dim("\nChecking connection… "));
|
|
377
496
|
const ok = await pingProvider({ provider, apiKey, model, baseURL: baseURL || undefined });
|
|
378
497
|
out(ok ? c.green("✓ connected\n") : c.yellow(`⚠ couldn't reach ${provider} (saved anyway)\n`));
|
|
@@ -528,7 +647,7 @@ async function runOrg(task, o) {
|
|
|
528
647
|
// Role-model resolution: respect role.model by default; --force collapses everything to cfg.model.
|
|
529
648
|
const __roleModel = effectiveRoleModel(role.model, o.cfg.model);
|
|
530
649
|
const roleProvider = __roleModel
|
|
531
|
-
? ((await buildProvider(
|
|
650
|
+
? ((await buildProvider(o.cfg, { model: __roleModel })) ?? o.baseProvider)
|
|
532
651
|
: o.baseProvider;
|
|
533
652
|
const toolFilter = roleToolFilter(role);
|
|
534
653
|
const history = [{ role: "user", content: await expandMentionsAsync(task, o.cwd) }];
|
|
@@ -567,7 +686,7 @@ async function runOrg(task, o) {
|
|
|
567
686
|
// Review chain: a reviewer role inspects the diff and APPROVES or sends it back, looping until clean.
|
|
568
687
|
const reviewer = roles.find((r) => r.id === "reviewer");
|
|
569
688
|
const __revModel = effectiveRoleModel(reviewer?.model, o.cfg.model);
|
|
570
|
-
const revProvider = __revModel ? ((await buildProvider(
|
|
689
|
+
const revProvider = __revModel ? ((await buildProvider(o.cfg, { model: __revModel })) ?? o.baseProvider) : o.baseProvider;
|
|
571
690
|
const revSystem = reviewer?.system ?? REVIEWER_SYSTEM;
|
|
572
691
|
const revTools = roleToolFilter(reviewer ? { ...reviewer, readOnly: true } : undefined) ?? ((n) => READONLY_TOOLS.has(n));
|
|
573
692
|
const maxRounds = Math.max(1, o.rounds ?? 3);
|
|
@@ -639,7 +758,7 @@ async function executeAtom(atom, plan, done, roles, o) {
|
|
|
639
758
|
return false;
|
|
640
759
|
}
|
|
641
760
|
const __atomModel = effectiveRoleModel(role?.model, o.cfg.model);
|
|
642
|
-
const roleProvider = __atomModel ? ((await buildProvider(
|
|
761
|
+
const roleProvider = __atomModel ? ((await buildProvider(o.cfg, { model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
|
|
643
762
|
const toolFilter = roleToolFilter(role);
|
|
644
763
|
const history = [{ role: "user", content: atomPrompt(atom, plan, done) }];
|
|
645
764
|
try {
|
|
@@ -903,7 +1022,7 @@ async function curateSessionLearning(provider, history, cfg, options) {
|
|
|
903
1022
|
// cannot drift into different context semantics.
|
|
904
1023
|
/** Summarize the conversation and replace history with the summary (keeping working-memory notes). Shared by
|
|
905
1024
|
* /compact (manual) and auto-compaction. Returns the summary, or null on failure / nothing to do. */
|
|
906
|
-
async function compactConversation(provider, history, meta, stats, signal, task) {
|
|
1025
|
+
async function compactConversation(provider, history, meta, stats, signal, task, onProviderTurn) {
|
|
907
1026
|
if (history.length < 2 || signal?.aborted)
|
|
908
1027
|
return null;
|
|
909
1028
|
const recent = recentHistoryForCompaction(history);
|
|
@@ -912,7 +1031,7 @@ async function compactConversation(provider, history, meta, stats, signal, task)
|
|
|
912
1031
|
history: [...compactionSourceHistory(history), { role: "user", content: "Create the bounded execution checkpoint now." }],
|
|
913
1032
|
tools: [],
|
|
914
1033
|
onText: () => { },
|
|
915
|
-
}, { timeoutMs: 60_000, label: "conversation compaction", signal });
|
|
1034
|
+
}, { timeoutMs: 60_000, label: "conversation compaction", signal, onProviderTurn });
|
|
916
1035
|
if (signal?.aborted || r.stop === "error")
|
|
917
1036
|
return null;
|
|
918
1037
|
const rawSummary = r.text.trim();
|
|
@@ -948,7 +1067,7 @@ async function compactConversation(provider, history, meta, stats, signal, task)
|
|
|
948
1067
|
/** Auto-compact (à la Claude Code) when the last turn filled the context past the threshold, so the NEXT turn
|
|
949
1068
|
* doesn't overflow. Opt-out via `autoCompact: false` / `HARA_AUTO_COMPACT=0`. Best-effort; `notify` surfaces
|
|
950
1069
|
* a one-line status. Returns true if it compacted. */
|
|
951
|
-
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal, task) {
|
|
1070
|
+
async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, signal, task, onProviderTurn) {
|
|
952
1071
|
if (signal?.aborted)
|
|
953
1072
|
return false;
|
|
954
1073
|
const lastInput = stats.lastInput ?? 0;
|
|
@@ -963,14 +1082,14 @@ async function maybeAutoCompact(provider, history, meta, stats, cfg, notify, sig
|
|
|
963
1082
|
if (signal?.aborted)
|
|
964
1083
|
return false;
|
|
965
1084
|
notify(`✻ Auto-compacting conversation (context ${pct}% full, ~${Math.round(lastInput / 1000)}k tok)…`);
|
|
966
|
-
const summary = await compactConversation(provider, history, meta, stats, signal, task);
|
|
1085
|
+
const summary = await compactConversation(provider, history, meta, stats, signal, task, onProviderTurn);
|
|
967
1086
|
if (signal?.aborted)
|
|
968
1087
|
return false;
|
|
969
1088
|
notify(summary ? `(auto-compacted — context replaced with a summary; ${meta.workingSet?.length ?? 0} notes kept)` : "(auto-compact failed — use /compact or /clear)");
|
|
970
1089
|
return !!summary;
|
|
971
1090
|
}
|
|
972
1091
|
/** Run a (read-only by default) sub-agent to completion, quietly, and return its final text. */
|
|
973
|
-
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId, signal) {
|
|
1092
|
+
async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stats, task, roleId, signal, observers) {
|
|
974
1093
|
const roles = loadRoles(cwd);
|
|
975
1094
|
const roleRef = roleId?.trim();
|
|
976
1095
|
if (roleId !== undefined && !roleRef)
|
|
@@ -1001,7 +1120,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
1001
1120
|
}
|
|
1002
1121
|
const __subModel = effectiveRoleModel(role?.model, baseProvider.model);
|
|
1003
1122
|
const provider = __subModel && __subModel !== baseProvider.model
|
|
1004
|
-
? ((await buildProvider(
|
|
1123
|
+
? ((await buildProvider(cfg, { model: __subModel })) ?? baseProvider)
|
|
1005
1124
|
: baseProvider;
|
|
1006
1125
|
// A sub-agent runs full-auto + UNCONFIRMED + parallel, so it is ALWAYS read-only — a role may narrow
|
|
1007
1126
|
// further but can never GRANT write/exec to a fan-out sub-agent (that would bypass the approval gate).
|
|
@@ -1026,6 +1145,7 @@ async function runSubagent(cfg, baseProvider, cwd, sandbox, projectContext, stat
|
|
|
1026
1145
|
signal,
|
|
1027
1146
|
timeoutMs: Math.min(cfg.runTimeoutMs, 8 * 60_000),
|
|
1028
1147
|
maxRounds: Math.min(cfg.maxAgentRounds, 24),
|
|
1148
|
+
...(observers ?? {}),
|
|
1029
1149
|
});
|
|
1030
1150
|
}
|
|
1031
1151
|
finally {
|
|
@@ -1063,9 +1183,10 @@ function runDoctor(cfg) {
|
|
|
1063
1183
|
const ok = (b) => (b ? c.green("✓") : c.red("✗"));
|
|
1064
1184
|
const dot = c.dim("·");
|
|
1065
1185
|
const nodeSupported = unsupportedNodeMessage() === null;
|
|
1066
|
-
const
|
|
1186
|
+
const envKey = providerEnvKey(cfg.provider);
|
|
1187
|
+
const hasKey = !!(cfg.apiKey || (envKey ? process.env[envKey] : undefined) || process.env.HARA_API_KEY);
|
|
1067
1188
|
const oauthOk = cfg.provider === "qwen-oauth" && loadQwenToken() !== null;
|
|
1068
|
-
const authed = hasKey || oauthOk;
|
|
1189
|
+
const authed = hasKey || oauthOk || providerIsLocal(cfg.provider);
|
|
1069
1190
|
const ad = assetsDir();
|
|
1070
1191
|
const roles = loadRoles(cfg.cwd);
|
|
1071
1192
|
const vcap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
@@ -1074,7 +1195,7 @@ function runDoctor(cfg) {
|
|
|
1074
1195
|
c.bold("hara doctor"),
|
|
1075
1196
|
`${ok(nodeSupported)} node ${process.versions.node} ${c.dim(`(need ≥${MIN_NODE_VERSION})`)}`,
|
|
1076
1197
|
`${dot} provider ${c.bold(cfg.provider)} · model ${c.bold(cfg.model)}${cfg.baseURL ? c.dim(" · " + cfg.baseURL) : ""}`,
|
|
1077
|
-
`${ok(authed)} auth ${authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
|
|
1198
|
+
`${ok(authed)} auth ${providerIsLocal(cfg.provider) ? c.dim("not required (local endpoint)") : authed ? c.dim("configured") : c.yellow("missing — " + authHint(cfg))}`,
|
|
1078
1199
|
`${ok(existsSync(configPath()))} config ${c.dim(configPath())}`,
|
|
1079
1200
|
`${dot} code-assets ${existsSync(ad) ? c.dim(ad) : c.dim("none — run: hara recall --init")}`,
|
|
1080
1201
|
`${dot} roles ${roles.length ? c.dim(`${roles.length} (${roles.slice(0, 8).map((r) => r.id).join(", ")}${roles.length > 8 ? ", …" : ""})`) : c.dim("none — run: hara roles init")}`,
|
|
@@ -1086,7 +1207,7 @@ function runDoctor(cfg) {
|
|
|
1086
1207
|
`${dot} plugins ${(() => { const inst = listInstalled(); const on = enabledPlugins().length; return inst.length ? c.dim(`${on}/${inst.length} enabled: ${inst.map((p) => p.name).slice(0, 6).join(", ")}`) : c.dim("none — hara plugin add <source>"); })()}`,
|
|
1087
1208
|
`${dot} mcp ${c.dim(`client: ${Object.keys({ ...pluginMcpServers(), ...cfg.mcpServers }).length} server(s) · serve: ${mcpServeToolNames().length} read tools via \`hara mcp\``)}`,
|
|
1088
1209
|
`${dot} hooks ${(() => { const ph = pluginHooks(); const pre = (cfg.hooks.PreToolUse ?? []).length + (ph.PreToolUse ?? []).length; const post = (cfg.hooks.PostToolUse ?? []).length + (ph.PostToolUse ?? []).length; return pre + post ? c.dim(`${pre} pre · ${post} post`) : c.dim("none — config.json \"hooks\""); })()}`,
|
|
1089
|
-
`${dot} run-limits ${c.bold(formatAgentDuration(cfg.runTimeoutMs))}${c.dim("
|
|
1210
|
+
`${dot} run-limits ${c.bold(formatAgentDuration(cfg.runTimeoutMs))}${c.dim(" active execution · ")}${c.bold(String(cfg.maxAgentRounds))}${c.dim(" rounds · sub-agents ≤8m/24")}`,
|
|
1090
1211
|
`${dot} notify ${cfg.notify === "off" ? c.dim("off — hara config set notify bell|system") : c.bold(cfg.notify)}`,
|
|
1091
1212
|
`${dot} cron ${(() => { try {
|
|
1092
1213
|
const n = loadJobs().length;
|
|
@@ -1653,7 +1774,7 @@ profileCmd
|
|
|
1653
1774
|
.option("--code <code>", "(gateway) enrollment code from your admin")
|
|
1654
1775
|
.option("--label <label>", "human-friendly label for the profile")
|
|
1655
1776
|
.option("--byok", "(byok) BYOK profile — bring your own provider key")
|
|
1656
|
-
.option("--provider <id>", "(byok) anthropic | openai | glm | deepseek | openrouter | qwen | qwen-oauth")
|
|
1777
|
+
.option("--provider <id>", "(byok/local) anthropic | openai | glm | deepseek | openrouter | qwen | qwen-oauth | ollama | lmstudio")
|
|
1657
1778
|
.option("--key <key>", "(byok) API key (else read from the provider's env var at use-time)")
|
|
1658
1779
|
.option("--base-url <url>", "(byok) override the provider base URL (OpenAI-compatible endpoints)")
|
|
1659
1780
|
.option("--model <model>", "(byok) default model for this profile")
|
|
@@ -1691,17 +1812,35 @@ profileCmd
|
|
|
1691
1812
|
return;
|
|
1692
1813
|
}
|
|
1693
1814
|
if (opts.byok || opts.provider) {
|
|
1694
|
-
const
|
|
1815
|
+
const requestedProvider = opts.provider || "anthropic";
|
|
1816
|
+
if (!isProviderId(requestedProvider)) {
|
|
1817
|
+
return void out(c.red(`Unknown provider '${requestedProvider}'. Run \`hara setup\` to see supported providers.\n`));
|
|
1818
|
+
}
|
|
1819
|
+
const provider = requestedProvider;
|
|
1695
1820
|
if (provider === "hara-gateway")
|
|
1696
1821
|
return void out(c.red("`--provider hara-gateway` is retired — use --gateway <url> --code <code> instead.\n"));
|
|
1822
|
+
const preset = providerCatalog().find((entry) => entry.id === provider);
|
|
1823
|
+
let normalized;
|
|
1824
|
+
try {
|
|
1825
|
+
normalized = normalizePersonalProviderConfig({
|
|
1826
|
+
provider,
|
|
1827
|
+
apiKey: opts.key,
|
|
1828
|
+
baseURL: opts.baseUrl ?? preset.defaultBaseURL,
|
|
1829
|
+
model: opts.model ?? preset.defaultModel,
|
|
1830
|
+
});
|
|
1831
|
+
}
|
|
1832
|
+
catch (err) {
|
|
1833
|
+
out(c.red(`Invalid provider profile: ${err instanceof Error ? err.message : String(err)}\n`));
|
|
1834
|
+
process.exit(1);
|
|
1835
|
+
}
|
|
1697
1836
|
const p = {
|
|
1698
1837
|
id,
|
|
1699
1838
|
kind: "byok",
|
|
1700
1839
|
label: opts.label || id,
|
|
1701
|
-
provider,
|
|
1702
|
-
apiKey:
|
|
1703
|
-
baseURL:
|
|
1704
|
-
defaultModel:
|
|
1840
|
+
provider: normalized.provider,
|
|
1841
|
+
apiKey: normalized.apiKey,
|
|
1842
|
+
baseURL: normalized.baseURL,
|
|
1843
|
+
defaultModel: normalized.model,
|
|
1705
1844
|
};
|
|
1706
1845
|
const r = addProfile(p);
|
|
1707
1846
|
if (!r.ok) {
|
|
@@ -1712,7 +1851,7 @@ profileCmd
|
|
|
1712
1851
|
out(c.dim(`Switch to it with \`hara profile use ${id}\`.\n`));
|
|
1713
1852
|
return;
|
|
1714
1853
|
}
|
|
1715
|
-
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"));
|
|
1854
|
+
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|ollama|lmstudio [--key … --base-url … --model …]\n"));
|
|
1716
1855
|
process.exit(1);
|
|
1717
1856
|
});
|
|
1718
1857
|
profileCmd
|
|
@@ -1952,14 +2091,10 @@ program
|
|
|
1952
2091
|
.option("--cwd <dir>", "default working directory for new sessions (default: current directory)")
|
|
1953
2092
|
.option("--approval <mode>", "default approval mode for sessions: suggest | auto-edit | full-auto", "auto-edit")
|
|
1954
2093
|
.action(async (o) => {
|
|
1955
|
-
const
|
|
2094
|
+
const cwd = o.cwd ? (await import("node:path")).resolve(o.cwd) : process.cwd();
|
|
2095
|
+
const cfg = loadConfig({ cwd });
|
|
1956
2096
|
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
1957
|
-
if (!provider0) {
|
|
1958
|
-
out(c.red(`Not authenticated for '${cfg.provider}' — run \`hara setup\` first.\n`));
|
|
1959
|
-
process.exit(1);
|
|
1960
|
-
}
|
|
1961
2097
|
const guardianOpt = await buildGuardian(cfg, provider0);
|
|
1962
|
-
const cwd = o.cwd ? (await import("node:path")).resolve(o.cwd) : process.cwd();
|
|
1963
2098
|
const sandbox = (process.env.HARA_SANDBOX ?? cfg.sandbox ?? "off");
|
|
1964
2099
|
const approval = APPROVAL_MODES.includes(o.approval) ? o.approval : "auto-edit";
|
|
1965
2100
|
const { startServe } = await import("./serve/server.js");
|
|
@@ -1976,26 +2111,74 @@ program
|
|
|
1976
2111
|
},
|
|
1977
2112
|
buildProviderFor: async (model, effort, targetCwd) => {
|
|
1978
2113
|
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1979
|
-
return withRouting(await buildProvider({
|
|
2114
|
+
return withRouting(await buildProvider({
|
|
2115
|
+
...live,
|
|
2116
|
+
reasoningEffort: effort ?? live.reasoningEffort,
|
|
2117
|
+
}, { model }), live);
|
|
1980
2118
|
},
|
|
1981
2119
|
listModels: (targetCwd) => {
|
|
1982
2120
|
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1983
|
-
|
|
2121
|
+
const { profile } = profileForConfig(live);
|
|
2122
|
+
if (profile.kind === "gateway") {
|
|
2123
|
+
const baseURL = profile.baseURL || (profile.gatewayUrl ? `${profile.gatewayUrl.replace(/\/+$/, "")}/v1` : undefined);
|
|
2124
|
+
return listModels(baseURL, profile.deviceToken ?? "");
|
|
2125
|
+
}
|
|
2126
|
+
const target = resolveByokProviderTarget(live, profile, false);
|
|
2127
|
+
return listModels(target.baseURL, target.apiKey ?? "");
|
|
2128
|
+
},
|
|
2129
|
+
providerSettings: (targetCwd) => providerSettingsSnapshot(targetCwd ?? cwd),
|
|
2130
|
+
testProviderSettings: (input) => testProviderSettingsCandidate(input),
|
|
2131
|
+
saveProviderSettings: async (input, targetCwd) => {
|
|
2132
|
+
const settingsCwd = targetCwd ?? cwd;
|
|
2133
|
+
if (!isProviderId(input.provider) || input.provider === "hara-gateway") {
|
|
2134
|
+
throw new Error("provider is not a configurable personal provider");
|
|
2135
|
+
}
|
|
2136
|
+
if (providerEnvironmentOverride()) {
|
|
2137
|
+
throw new Error("provider/model/base URL is overridden by HARA_* environment variables; remove the override before editing System Settings");
|
|
2138
|
+
}
|
|
2139
|
+
const resolution = resolveActive(settingsCwd);
|
|
2140
|
+
if (resolution.id !== PERSONAL_ID) {
|
|
2141
|
+
if (resolution.source === "flag" || resolution.source === "env" || resolution.source === "pin") {
|
|
2142
|
+
throw new Error(`profile '${resolution.id}' is selected by ${resolution.source}; switch or unpin it before editing Personal provider settings`);
|
|
2143
|
+
}
|
|
2144
|
+
if (input.activatePersonal !== true) {
|
|
2145
|
+
throw new Error(`profile '${resolution.id}' is active; confirm activatePersonal to save and switch to Personal`);
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
const normalized = normalizePersonalProviderConfig({
|
|
2149
|
+
provider: input.provider,
|
|
2150
|
+
model: input.model,
|
|
2151
|
+
baseURL: input.baseURL,
|
|
2152
|
+
apiKey: input.apiKey,
|
|
2153
|
+
clearApiKey: input.clearApiKey,
|
|
2154
|
+
});
|
|
2155
|
+
const raw = readRawConfig();
|
|
2156
|
+
const availableKey = reusablePersonalProviderApiKey(normalized, raw);
|
|
2157
|
+
if (providerRequiresApiKey(normalized.provider) && !availableKey) {
|
|
2158
|
+
throw new Error("a new API key is required when the provider or endpoint changes");
|
|
2159
|
+
}
|
|
2160
|
+
updatePersonalProviderConfig(normalized);
|
|
2161
|
+
if (resolution.id !== PERSONAL_ID) {
|
|
2162
|
+
const switched = useProfile(PERSONAL_ID);
|
|
2163
|
+
if (!switched.ok)
|
|
2164
|
+
throw new Error(switched.reason);
|
|
2165
|
+
}
|
|
2166
|
+
return providerSettingsSnapshot(settingsCwd);
|
|
1984
2167
|
},
|
|
1985
2168
|
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning, cfg.model).filter((e) => !!e),
|
|
1986
2169
|
runtimeInfo: (targetCwd, selectedModel) => {
|
|
1987
|
-
const
|
|
1988
|
-
const model = selectedModel ??
|
|
2170
|
+
const current = providerSettingsSnapshot(targetCwd ?? cwd).current;
|
|
2171
|
+
const model = selectedModel ?? current.model;
|
|
1989
2172
|
return {
|
|
1990
|
-
providerId:
|
|
2173
|
+
providerId: current.provider,
|
|
1991
2174
|
model,
|
|
1992
|
-
effortLevels: levelsFor(resolvePlatform(
|
|
2175
|
+
effortLevels: levelsFor(resolvePlatform(current.provider, current.baseURL).reasoning, model).filter((e) => !!e),
|
|
1993
2176
|
};
|
|
1994
2177
|
},
|
|
1995
2178
|
runLimits: (targetCwd) => agentRunLimits(loadConfig({ cwd: targetCwd ?? cwd })),
|
|
1996
|
-
spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal) => {
|
|
2179
|
+
spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal, observers) => {
|
|
1997
2180
|
const live = loadConfig({ cwd: scwd });
|
|
1998
|
-
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal);
|
|
2181
|
+
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal, observers);
|
|
1999
2182
|
},
|
|
2000
2183
|
guardian: guardianOpt,
|
|
2001
2184
|
buildGuardian: async (targetCwd) => {
|
|
@@ -2006,7 +2189,8 @@ program
|
|
|
2006
2189
|
sandbox,
|
|
2007
2190
|
approval,
|
|
2008
2191
|
});
|
|
2009
|
-
|
|
2192
|
+
const setupStatus = provider0 ? `${cfg.provider}:${cfg.model}` : `setup required · ${cfg.provider}:${cfg.model}`;
|
|
2193
|
+
out(c.bold("hara serve") + c.dim(` · ws://${o.host}:${handle.port} · ${setupStatus} · approval ${approval} · token → ~/.hara/serve.json\n`));
|
|
2010
2194
|
const bye = async () => {
|
|
2011
2195
|
await handle.close();
|
|
2012
2196
|
process.exit(0);
|
|
@@ -2666,13 +2850,13 @@ pluginCmd
|
|
|
2666
2850
|
if (!onPath)
|
|
2667
2851
|
out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
|
|
2668
2852
|
}
|
|
2669
|
-
// Surface the code-execution surface
|
|
2670
|
-
//
|
|
2853
|
+
// Surface the code-execution surface without echoing manifest-provided arguments or hook bodies:
|
|
2854
|
+
// those fields may contain credentials. Installing a plugin means trusting code from its author.
|
|
2671
2855
|
const execs = [];
|
|
2672
2856
|
for (const [name, s] of Object.entries(m.mcpServers ?? {}))
|
|
2673
|
-
execs.push(`mcp ${name}: ${
|
|
2674
|
-
for (const
|
|
2675
|
-
execs.push(
|
|
2857
|
+
execs.push(`mcp ${name}: ${s.command}`);
|
|
2858
|
+
for (const _hook of [...(m.hooks?.PreToolUse ?? []), ...(m.hooks?.PostToolUse ?? [])])
|
|
2859
|
+
execs.push("hook configured (command hidden)");
|
|
2676
2860
|
if (execs.length) {
|
|
2677
2861
|
out(c.yellow(`⚠ ${p.name} will run these commands on every hara launch (a plugin is code you run — review them):\n`) +
|
|
2678
2862
|
execs.map((e) => c.dim(` ${e}`)).join("\n") +
|
|
@@ -2687,7 +2871,14 @@ pluginCmd
|
|
|
2687
2871
|
.command("remove <name>")
|
|
2688
2872
|
.alias("uninstall")
|
|
2689
2873
|
.description("uninstall a plugin")
|
|
2690
|
-
.action((name) =>
|
|
2874
|
+
.action((name) => {
|
|
2875
|
+
try {
|
|
2876
|
+
out(uninstallPlugin(name) ? c.green(`Removed ${name}\n`) : c.dim(`(no plugin '${name}')\n`));
|
|
2877
|
+
}
|
|
2878
|
+
catch (error) {
|
|
2879
|
+
out(c.red(`Remove failed: ${error?.message ?? String(error)}\n`));
|
|
2880
|
+
}
|
|
2881
|
+
});
|
|
2691
2882
|
pluginCmd
|
|
2692
2883
|
.command("enable <name>")
|
|
2693
2884
|
.description("enable an installed plugin")
|
|
@@ -2834,31 +3025,39 @@ program.action(async (opts) => {
|
|
|
2834
3025
|
const machineOutput = !!opts.print && !!opts.schema;
|
|
2835
3026
|
if (opts.model)
|
|
2836
3027
|
cfg.model = opts.model;
|
|
2837
|
-
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
3028
|
+
const provider0 = await withRouting(await buildProvider(cfg, opts.model ? { model: cfg.model } : undefined), cfg);
|
|
3029
|
+
if (provider0) {
|
|
3030
|
+
cfg.provider = provider0.id;
|
|
3031
|
+
cfg.model = provider0.model;
|
|
3032
|
+
}
|
|
2838
3033
|
// Fallback provider, built correctly for CROSS-PROVIDER failover. The old `baseURL: fallbackBaseURL ??
|
|
2839
3034
|
// baseURL` sent the fallback model to the PRIMARY endpoint when no fallbackBaseURL was set — e.g.
|
|
2840
3035
|
// deepseek-v4-pro posted to coding.dashscope → 400. Now: `fallbackProvider` routes to that vendor's
|
|
2841
3036
|
// endpoint + its own key (fallbackBaseURL/fallbackApiKey still override); without it we stay on the
|
|
2842
3037
|
// primary endpoint (correct only for a same-endpoint fallback).
|
|
2843
3038
|
let fallbackProv = null;
|
|
2844
|
-
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !==
|
|
3039
|
+
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !== provider0.model) {
|
|
2845
3040
|
const fp = cfg.fallbackProvider;
|
|
2846
|
-
const cross = !!fp && fp !==
|
|
3041
|
+
const cross = !!fp && fp !== provider0.id;
|
|
2847
3042
|
const family = (m) => m.toLowerCase().split(/[-.:/]/)[0];
|
|
2848
|
-
|
|
3043
|
+
const fallbackEnvKey = fp ? providerEnvKey(fp) : "";
|
|
3044
|
+
const crossKey = cfg.fallbackApiKey ?? (fallbackEnvKey ? process.env[fallbackEnvKey] : undefined);
|
|
3045
|
+
if (fp === "hara-gateway") {
|
|
3046
|
+
process.stderr.write("hara: fallbackProvider cannot be hara-gateway; select an enrolled gateway profile instead. Fallback disabled.\n");
|
|
3047
|
+
}
|
|
3048
|
+
else if (cross && providerRequiresApiKey(fp) && !crossKey) {
|
|
2849
3049
|
process.stderr.write(`hara: fallbackProvider '${fp}' needs its own key — set fallbackApiKey. Fallback disabled.\n`);
|
|
2850
3050
|
}
|
|
2851
|
-
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(
|
|
3051
|
+
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(provider0.model)) {
|
|
2852
3052
|
// A different-looking vendor with no routing set would hit the primary endpoint and 400 on failover.
|
|
2853
|
-
process.stderr.write(`hara: fallbackModel '${cfg.fallbackModel}' looks like a different vendor than '${
|
|
3053
|
+
process.stderr.write(`hara: fallbackModel '${cfg.fallbackModel}' looks like a different vendor than '${provider0.model}', but no fallbackProvider/fallbackBaseURL is set — it would hit the PRIMARY endpoint (likely 400). Set fallbackProvider (+ fallbackApiKey). Fallback disabled.\n`);
|
|
2854
3054
|
}
|
|
2855
3055
|
else {
|
|
2856
|
-
fallbackProv = await buildProvider({
|
|
2857
|
-
...
|
|
2858
|
-
provider: fp ?? cfg.provider,
|
|
3056
|
+
fallbackProv = await buildProvider(cfg, {
|
|
3057
|
+
...(fp ? { provider: fp } : {}),
|
|
2859
3058
|
model: cfg.fallbackModel,
|
|
2860
|
-
|
|
2861
|
-
apiKey: cfg.fallbackApiKey
|
|
3059
|
+
...(cfg.fallbackBaseURL ? { baseURL: cfg.fallbackBaseURL } : {}),
|
|
3060
|
+
...(cross ? { apiKey: crossKey } : cfg.fallbackApiKey ? { apiKey: cfg.fallbackApiKey } : {}),
|
|
2862
3061
|
});
|
|
2863
3062
|
}
|
|
2864
3063
|
}
|
|
@@ -2923,12 +3122,14 @@ program.action(async (opts) => {
|
|
|
2923
3122
|
// one-shot
|
|
2924
3123
|
if (opts.print) {
|
|
2925
3124
|
let headlessLockId = null;
|
|
2926
|
-
const
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
const
|
|
2930
|
-
|
|
2931
|
-
|
|
3125
|
+
const headlessOperations = createPhysicalOperationDrain(() => {
|
|
3126
|
+
if (!headlessLockId)
|
|
3127
|
+
return;
|
|
3128
|
+
const lockId = headlessLockId;
|
|
3129
|
+
headlessLockId = null;
|
|
3130
|
+
releaseSessionLock(lockId);
|
|
3131
|
+
});
|
|
3132
|
+
const trackHeadlessOperation = headlessOperations.observe;
|
|
2932
3133
|
try {
|
|
2933
3134
|
const projectContext = loadAgentContext(cwd) || undefined;
|
|
2934
3135
|
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
@@ -2937,7 +3138,11 @@ program.action(async (opts) => {
|
|
|
2937
3138
|
const describeImage = async (path, hint, signal) => {
|
|
2938
3139
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2939
3140
|
const vp = cfg.visionModel
|
|
2940
|
-
? ((await buildProvider(
|
|
3141
|
+
? ((await buildProvider(cfg, {
|
|
3142
|
+
model: cfg.visionModel,
|
|
3143
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
3144
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
3145
|
+
})) ?? null)
|
|
2941
3146
|
: cap === "vision"
|
|
2942
3147
|
? provider
|
|
2943
3148
|
: null;
|
|
@@ -3044,13 +3249,13 @@ program.action(async (opts) => {
|
|
|
3044
3249
|
catch { /* ignore */ }
|
|
3045
3250
|
cfg.model = __fb;
|
|
3046
3251
|
meta.model = __fb;
|
|
3047
|
-
const __rb = await buildProvider(cfg);
|
|
3252
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3048
3253
|
if (__rb)
|
|
3049
3254
|
provider = __rb;
|
|
3050
3255
|
}
|
|
3051
3256
|
else {
|
|
3052
3257
|
cfg.model = meta.model;
|
|
3053
|
-
const __rb = await buildProvider(cfg);
|
|
3258
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3054
3259
|
if (__rb)
|
|
3055
3260
|
provider = __rb;
|
|
3056
3261
|
}
|
|
@@ -3109,7 +3314,11 @@ program.action(async (opts) => {
|
|
|
3109
3314
|
else if (inboundImgs.length && cfg.visionModel) {
|
|
3110
3315
|
let desc = "";
|
|
3111
3316
|
try {
|
|
3112
|
-
const vp = await buildProvider(
|
|
3317
|
+
const vp = await buildProvider(cfg, {
|
|
3318
|
+
model: cfg.visionModel,
|
|
3319
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
3320
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
3321
|
+
});
|
|
3113
3322
|
if (vp)
|
|
3114
3323
|
desc = await describeImages(vp, inboundImgs);
|
|
3115
3324
|
}
|
|
@@ -3158,7 +3367,7 @@ program.action(async (opts) => {
|
|
|
3158
3367
|
headlessHooks = false;
|
|
3159
3368
|
const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
|
|
3160
3369
|
if (roleModel && roleModel !== provider.model) {
|
|
3161
|
-
const selected = await buildProvider(
|
|
3370
|
+
const selected = await buildProvider(cfg, { model: roleModel });
|
|
3162
3371
|
if (!selected) {
|
|
3163
3372
|
process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
|
|
3164
3373
|
process.exitCode = 2;
|
|
@@ -3172,7 +3381,15 @@ program.action(async (opts) => {
|
|
|
3172
3381
|
let structuredSet = false;
|
|
3173
3382
|
const printRunOpts = {
|
|
3174
3383
|
provider: headlessProvider,
|
|
3175
|
-
ctx: {
|
|
3384
|
+
ctx: {
|
|
3385
|
+
cwd,
|
|
3386
|
+
sandbox,
|
|
3387
|
+
spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal, {
|
|
3388
|
+
onProviderTurn: trackHeadlessOperation,
|
|
3389
|
+
onToolRun: trackHeadlessOperation,
|
|
3390
|
+
}),
|
|
3391
|
+
describeImage,
|
|
3392
|
+
},
|
|
3176
3393
|
approval: "full-auto",
|
|
3177
3394
|
confirm: async () => true,
|
|
3178
3395
|
projectContext,
|
|
@@ -3239,7 +3456,7 @@ program.action(async (opts) => {
|
|
|
3239
3456
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
3240
3457
|
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
3241
3458
|
if (runOutcome.status === "completed") {
|
|
3242
|
-
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task);
|
|
3459
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task, trackHeadlessOperation);
|
|
3243
3460
|
}
|
|
3244
3461
|
saveSession(meta, history, task); // persist when resuming/continuing; plain -p stays stateless
|
|
3245
3462
|
}
|
|
@@ -3249,19 +3466,11 @@ program.action(async (opts) => {
|
|
|
3249
3466
|
return;
|
|
3250
3467
|
}
|
|
3251
3468
|
finally {
|
|
3252
|
-
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
3257
|
-
// A truly inert never-settling Promise has no side effect/handle, so normal process exit makes the
|
|
3258
|
-
// PID-backed lease safely reclaimable.
|
|
3259
|
-
void Promise.allSettled([...pendingHeadlessOperations]).then(() => releaseSessionLock(lockId));
|
|
3260
|
-
}
|
|
3261
|
-
else {
|
|
3262
|
-
releaseSessionLock(lockId);
|
|
3263
|
-
}
|
|
3264
|
-
}
|
|
3469
|
+
// Do not await here: the logical deadline stays hard. The live drain includes operations registered
|
|
3470
|
+
// by an already-observed outer tool after cleanup begins, and releases the PID-backed session lease
|
|
3471
|
+
// only when the entire physical tree is empty. Handle-less inert Promises cannot keep the process
|
|
3472
|
+
// alive; after process exit the ordinary stale-PID recovery remains authoritative.
|
|
3473
|
+
headlessOperations.close();
|
|
3265
3474
|
await closeMcp();
|
|
3266
3475
|
}
|
|
3267
3476
|
}
|
|
@@ -3424,13 +3633,13 @@ program.action(async (opts) => {
|
|
|
3424
3633
|
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`));
|
|
3425
3634
|
cfg.model = __fallback;
|
|
3426
3635
|
meta.model = __fallback;
|
|
3427
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3636
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3428
3637
|
if (__rebuilt)
|
|
3429
3638
|
provider = __rebuilt;
|
|
3430
3639
|
}
|
|
3431
3640
|
else {
|
|
3432
3641
|
cfg.model = meta.model;
|
|
3433
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3642
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3434
3643
|
if (__rebuilt)
|
|
3435
3644
|
provider = __rebuilt;
|
|
3436
3645
|
}
|
|
@@ -3643,7 +3852,7 @@ program.action(async (opts) => {
|
|
|
3643
3852
|
setSessionForceModel(force);
|
|
3644
3853
|
visionProvider = undefined;
|
|
3645
3854
|
remindedVision = false;
|
|
3646
|
-
const p = await buildProvider(cfg);
|
|
3855
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
3647
3856
|
if (p) {
|
|
3648
3857
|
provider = p;
|
|
3649
3858
|
if (bar.isActive())
|
|
@@ -3985,7 +4194,11 @@ program.action(async (opts) => {
|
|
|
3985
4194
|
const getVisionProvider = async () => {
|
|
3986
4195
|
if (visionProvider !== undefined)
|
|
3987
4196
|
return visionProvider;
|
|
3988
|
-
visionProvider = await buildProvider(
|
|
4197
|
+
visionProvider = await buildProvider(cfg, {
|
|
4198
|
+
model: cfg.visionModel,
|
|
4199
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
4200
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
4201
|
+
});
|
|
3989
4202
|
return visionProvider;
|
|
3990
4203
|
};
|
|
3991
4204
|
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
@@ -4289,7 +4502,7 @@ program.action(async (opts) => {
|
|
|
4289
4502
|
cfg.reasoningEffort = chosen.effort;
|
|
4290
4503
|
visionProvider = undefined;
|
|
4291
4504
|
remindedVision = false;
|
|
4292
|
-
const p2 = await buildProvider(cfg);
|
|
4505
|
+
const p2 = await buildProvider(cfg, { model: cfg.model });
|
|
4293
4506
|
if (!p2)
|
|
4294
4507
|
return void h.sink.notice("(could not rebuild provider)");
|
|
4295
4508
|
provider = p2;
|
|
@@ -4301,7 +4514,7 @@ program.action(async (opts) => {
|
|
|
4301
4514
|
setSessionForceModel(force);
|
|
4302
4515
|
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
4303
4516
|
remindedVision = false;
|
|
4304
|
-
const p = await buildProvider(cfg);
|
|
4517
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
4305
4518
|
if (p) {
|
|
4306
4519
|
provider = p;
|
|
4307
4520
|
persistSession(); // persist the session-pinned model so resume restores it
|