@nanhara/hara 0.125.3 → 0.126.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 +40 -1
- package/README.md +6 -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 +359 -153
- package/dist/mcp/client.js +1 -0
- 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);
|
|
@@ -2834,31 +3018,39 @@ program.action(async (opts) => {
|
|
|
2834
3018
|
const machineOutput = !!opts.print && !!opts.schema;
|
|
2835
3019
|
if (opts.model)
|
|
2836
3020
|
cfg.model = opts.model;
|
|
2837
|
-
const provider0 = await withRouting(await buildProvider(cfg), cfg);
|
|
3021
|
+
const provider0 = await withRouting(await buildProvider(cfg, opts.model ? { model: cfg.model } : undefined), cfg);
|
|
3022
|
+
if (provider0) {
|
|
3023
|
+
cfg.provider = provider0.id;
|
|
3024
|
+
cfg.model = provider0.model;
|
|
3025
|
+
}
|
|
2838
3026
|
// Fallback provider, built correctly for CROSS-PROVIDER failover. The old `baseURL: fallbackBaseURL ??
|
|
2839
3027
|
// baseURL` sent the fallback model to the PRIMARY endpoint when no fallbackBaseURL was set — e.g.
|
|
2840
3028
|
// deepseek-v4-pro posted to coding.dashscope → 400. Now: `fallbackProvider` routes to that vendor's
|
|
2841
3029
|
// endpoint + its own key (fallbackBaseURL/fallbackApiKey still override); without it we stay on the
|
|
2842
3030
|
// primary endpoint (correct only for a same-endpoint fallback).
|
|
2843
3031
|
let fallbackProv = null;
|
|
2844
|
-
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !==
|
|
3032
|
+
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !== provider0.model) {
|
|
2845
3033
|
const fp = cfg.fallbackProvider;
|
|
2846
|
-
const cross = !!fp && fp !==
|
|
3034
|
+
const cross = !!fp && fp !== provider0.id;
|
|
2847
3035
|
const family = (m) => m.toLowerCase().split(/[-.:/]/)[0];
|
|
2848
|
-
|
|
3036
|
+
const fallbackEnvKey = fp ? providerEnvKey(fp) : "";
|
|
3037
|
+
const crossKey = cfg.fallbackApiKey ?? (fallbackEnvKey ? process.env[fallbackEnvKey] : undefined);
|
|
3038
|
+
if (fp === "hara-gateway") {
|
|
3039
|
+
process.stderr.write("hara: fallbackProvider cannot be hara-gateway; select an enrolled gateway profile instead. Fallback disabled.\n");
|
|
3040
|
+
}
|
|
3041
|
+
else if (cross && providerRequiresApiKey(fp) && !crossKey) {
|
|
2849
3042
|
process.stderr.write(`hara: fallbackProvider '${fp}' needs its own key — set fallbackApiKey. Fallback disabled.\n`);
|
|
2850
3043
|
}
|
|
2851
|
-
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(
|
|
3044
|
+
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(provider0.model)) {
|
|
2852
3045
|
// 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 '${
|
|
3046
|
+
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
3047
|
}
|
|
2855
3048
|
else {
|
|
2856
|
-
fallbackProv = await buildProvider({
|
|
2857
|
-
...
|
|
2858
|
-
provider: fp ?? cfg.provider,
|
|
3049
|
+
fallbackProv = await buildProvider(cfg, {
|
|
3050
|
+
...(fp ? { provider: fp } : {}),
|
|
2859
3051
|
model: cfg.fallbackModel,
|
|
2860
|
-
|
|
2861
|
-
apiKey: cfg.fallbackApiKey
|
|
3052
|
+
...(cfg.fallbackBaseURL ? { baseURL: cfg.fallbackBaseURL } : {}),
|
|
3053
|
+
...(cross ? { apiKey: crossKey } : cfg.fallbackApiKey ? { apiKey: cfg.fallbackApiKey } : {}),
|
|
2862
3054
|
});
|
|
2863
3055
|
}
|
|
2864
3056
|
}
|
|
@@ -2923,12 +3115,14 @@ program.action(async (opts) => {
|
|
|
2923
3115
|
// one-shot
|
|
2924
3116
|
if (opts.print) {
|
|
2925
3117
|
let headlessLockId = null;
|
|
2926
|
-
const
|
|
2927
|
-
|
|
2928
|
-
|
|
2929
|
-
const
|
|
2930
|
-
|
|
2931
|
-
|
|
3118
|
+
const headlessOperations = createPhysicalOperationDrain(() => {
|
|
3119
|
+
if (!headlessLockId)
|
|
3120
|
+
return;
|
|
3121
|
+
const lockId = headlessLockId;
|
|
3122
|
+
headlessLockId = null;
|
|
3123
|
+
releaseSessionLock(lockId);
|
|
3124
|
+
});
|
|
3125
|
+
const trackHeadlessOperation = headlessOperations.observe;
|
|
2932
3126
|
try {
|
|
2933
3127
|
const projectContext = loadAgentContext(cwd) || undefined;
|
|
2934
3128
|
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
@@ -2937,7 +3131,11 @@ program.action(async (opts) => {
|
|
|
2937
3131
|
const describeImage = async (path, hint, signal) => {
|
|
2938
3132
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2939
3133
|
const vp = cfg.visionModel
|
|
2940
|
-
? ((await buildProvider(
|
|
3134
|
+
? ((await buildProvider(cfg, {
|
|
3135
|
+
model: cfg.visionModel,
|
|
3136
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
3137
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
3138
|
+
})) ?? null)
|
|
2941
3139
|
: cap === "vision"
|
|
2942
3140
|
? provider
|
|
2943
3141
|
: null;
|
|
@@ -3044,13 +3242,13 @@ program.action(async (opts) => {
|
|
|
3044
3242
|
catch { /* ignore */ }
|
|
3045
3243
|
cfg.model = __fb;
|
|
3046
3244
|
meta.model = __fb;
|
|
3047
|
-
const __rb = await buildProvider(cfg);
|
|
3245
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3048
3246
|
if (__rb)
|
|
3049
3247
|
provider = __rb;
|
|
3050
3248
|
}
|
|
3051
3249
|
else {
|
|
3052
3250
|
cfg.model = meta.model;
|
|
3053
|
-
const __rb = await buildProvider(cfg);
|
|
3251
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3054
3252
|
if (__rb)
|
|
3055
3253
|
provider = __rb;
|
|
3056
3254
|
}
|
|
@@ -3109,7 +3307,11 @@ program.action(async (opts) => {
|
|
|
3109
3307
|
else if (inboundImgs.length && cfg.visionModel) {
|
|
3110
3308
|
let desc = "";
|
|
3111
3309
|
try {
|
|
3112
|
-
const vp = await buildProvider(
|
|
3310
|
+
const vp = await buildProvider(cfg, {
|
|
3311
|
+
model: cfg.visionModel,
|
|
3312
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
3313
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
3314
|
+
});
|
|
3113
3315
|
if (vp)
|
|
3114
3316
|
desc = await describeImages(vp, inboundImgs);
|
|
3115
3317
|
}
|
|
@@ -3158,7 +3360,7 @@ program.action(async (opts) => {
|
|
|
3158
3360
|
headlessHooks = false;
|
|
3159
3361
|
const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
|
|
3160
3362
|
if (roleModel && roleModel !== provider.model) {
|
|
3161
|
-
const selected = await buildProvider(
|
|
3363
|
+
const selected = await buildProvider(cfg, { model: roleModel });
|
|
3162
3364
|
if (!selected) {
|
|
3163
3365
|
process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
|
|
3164
3366
|
process.exitCode = 2;
|
|
@@ -3172,7 +3374,15 @@ program.action(async (opts) => {
|
|
|
3172
3374
|
let structuredSet = false;
|
|
3173
3375
|
const printRunOpts = {
|
|
3174
3376
|
provider: headlessProvider,
|
|
3175
|
-
ctx: {
|
|
3377
|
+
ctx: {
|
|
3378
|
+
cwd,
|
|
3379
|
+
sandbox,
|
|
3380
|
+
spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal, {
|
|
3381
|
+
onProviderTurn: trackHeadlessOperation,
|
|
3382
|
+
onToolRun: trackHeadlessOperation,
|
|
3383
|
+
}),
|
|
3384
|
+
describeImage,
|
|
3385
|
+
},
|
|
3176
3386
|
approval: "full-auto",
|
|
3177
3387
|
confirm: async () => true,
|
|
3178
3388
|
projectContext,
|
|
@@ -3239,7 +3449,7 @@ program.action(async (opts) => {
|
|
|
3239
3449
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
3240
3450
|
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
3241
3451
|
if (runOutcome.status === "completed") {
|
|
3242
|
-
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task);
|
|
3452
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task, trackHeadlessOperation);
|
|
3243
3453
|
}
|
|
3244
3454
|
saveSession(meta, history, task); // persist when resuming/continuing; plain -p stays stateless
|
|
3245
3455
|
}
|
|
@@ -3249,19 +3459,11 @@ program.action(async (opts) => {
|
|
|
3249
3459
|
return;
|
|
3250
3460
|
}
|
|
3251
3461
|
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
|
-
}
|
|
3462
|
+
// Do not await here: the logical deadline stays hard. The live drain includes operations registered
|
|
3463
|
+
// by an already-observed outer tool after cleanup begins, and releases the PID-backed session lease
|
|
3464
|
+
// only when the entire physical tree is empty. Handle-less inert Promises cannot keep the process
|
|
3465
|
+
// alive; after process exit the ordinary stale-PID recovery remains authoritative.
|
|
3466
|
+
headlessOperations.close();
|
|
3265
3467
|
await closeMcp();
|
|
3266
3468
|
}
|
|
3267
3469
|
}
|
|
@@ -3424,13 +3626,13 @@ program.action(async (opts) => {
|
|
|
3424
3626
|
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
3627
|
cfg.model = __fallback;
|
|
3426
3628
|
meta.model = __fallback;
|
|
3427
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3629
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3428
3630
|
if (__rebuilt)
|
|
3429
3631
|
provider = __rebuilt;
|
|
3430
3632
|
}
|
|
3431
3633
|
else {
|
|
3432
3634
|
cfg.model = meta.model;
|
|
3433
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3635
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3434
3636
|
if (__rebuilt)
|
|
3435
3637
|
provider = __rebuilt;
|
|
3436
3638
|
}
|
|
@@ -3643,7 +3845,7 @@ program.action(async (opts) => {
|
|
|
3643
3845
|
setSessionForceModel(force);
|
|
3644
3846
|
visionProvider = undefined;
|
|
3645
3847
|
remindedVision = false;
|
|
3646
|
-
const p = await buildProvider(cfg);
|
|
3848
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
3647
3849
|
if (p) {
|
|
3648
3850
|
provider = p;
|
|
3649
3851
|
if (bar.isActive())
|
|
@@ -3985,7 +4187,11 @@ program.action(async (opts) => {
|
|
|
3985
4187
|
const getVisionProvider = async () => {
|
|
3986
4188
|
if (visionProvider !== undefined)
|
|
3987
4189
|
return visionProvider;
|
|
3988
|
-
visionProvider = await buildProvider(
|
|
4190
|
+
visionProvider = await buildProvider(cfg, {
|
|
4191
|
+
model: cfg.visionModel,
|
|
4192
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
4193
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
4194
|
+
});
|
|
3989
4195
|
return visionProvider;
|
|
3990
4196
|
};
|
|
3991
4197
|
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
@@ -4289,7 +4495,7 @@ program.action(async (opts) => {
|
|
|
4289
4495
|
cfg.reasoningEffort = chosen.effort;
|
|
4290
4496
|
visionProvider = undefined;
|
|
4291
4497
|
remindedVision = false;
|
|
4292
|
-
const p2 = await buildProvider(cfg);
|
|
4498
|
+
const p2 = await buildProvider(cfg, { model: cfg.model });
|
|
4293
4499
|
if (!p2)
|
|
4294
4500
|
return void h.sink.notice("(could not rebuild provider)");
|
|
4295
4501
|
provider = p2;
|
|
@@ -4301,7 +4507,7 @@ program.action(async (opts) => {
|
|
|
4301
4507
|
setSessionForceModel(force);
|
|
4302
4508
|
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
4303
4509
|
remindedVision = false;
|
|
4304
|
-
const p = await buildProvider(cfg);
|
|
4510
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
4305
4511
|
if (p) {
|
|
4306
4512
|
provider = p;
|
|
4307
4513
|
persistSession(); // persist the session-pinned model so resume restores it
|