@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/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 { createAnthropicProvider } from "./providers/anthropic.js";
49
- import { createOpenAIProvider } from "./providers/openai.js";
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, getValidQwenAuth, loadQwenToken } from "./providers/qwen-oauth.js";
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 ? `${v.slice(0, 7)}…${v.slice(-4)}` : "(unset)");
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 = loadActiveProfile();
133
- // CFG-OVERRIDE PATH: when a sidecar (vision / route / fallback) calls buildProvider with a tweaked
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 || effectiveModel(ap);
143
- return createOpenAIProvider({ apiKey: ap.deviceToken, baseURL, model, label: "hara-gateway", reasoningEffort: cfg.reasoningEffort });
144
- }
145
- // BYOK paths use the active profile's provider/key/baseURL by default, but let the merged cfg
146
- // override (so `--profile`/`HARA_PROFILE`-overridden values flow + sidecar provider builds work).
147
- const provider = (cfg.provider && cfg.provider !== "hara-gateway" ? cfg.provider : ap.provider) || "anthropic";
148
- const apiKey = cfg.apiKey ?? ap.apiKey;
149
- // Resolve base URL: explicit (cfg profile) wins; otherwise fall back to the provider's preset
150
- // (GLM/DeepSeek/OpenRouter). This keeps `profile add --byok --provider glm` working even when the
151
- // user didn't pass --base-url (anthropic/openai stay undefined → their SDK defaults).
152
- const baseURL = cfg.baseURL ?? ap.baseURL ?? providerDefaultBaseURL(provider);
153
- const model = cfg.model || effectiveModel(ap);
154
- if (provider === "qwen-oauth") {
155
- const auth = await getValidQwenAuth();
156
- if (!auth)
157
- return null;
158
- return createOpenAIProvider({ apiKey: auth.accessToken, baseURL: auth.baseURL, model, label: "qwen-oauth", reasoningEffort: cfg.reasoningEffort });
159
- }
160
- if (!apiKey)
161
- return null;
162
- // Transport is chosen from the provider REGISTRY (the dictionary) by wire protocol — so a custom
163
- // baseURL Just Works: a vendor's `.../anthropic` endpoint (DeepSeek/Kimi/GLM/MiniMax/Aliyun) speaks the
164
- // Anthropic wire (gets cache_control + thinking budget), DashScope/Ollama/OpenAI speak chat, etc.
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 === cfg.model)
168
+ if (!primary || !cfg.routeModel || cfg.routeModel === primary.model)
188
169
  return primary;
189
- const alt = await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey });
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 !== cfg.model) {
201
- gp = (await buildProvider({ ...cfg, model: cfg.routeModel, baseURL: cfg.routeBaseURL ?? cfg.baseURL, apiKey: cfg.routeApiKey ?? cfg.apiKey })) ?? primary;
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 = loadActiveProfile();
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 provider = ap.provider ?? cfg.provider;
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
- const SETUP_DEFAULT_MODEL = { anthropic: "claude-opus-4-8", qwen: "qwen-plus", openai: "gpt-4o-mini", glm: "glm-4.6", deepseek: "deepseek-chat", openrouter: "openai/gpt-4o-mini", "qwen-oauth": "coder-model" };
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
- writeConfigValue("provider", "qwen-oauth");
341
- writeConfigValue("model", "coder-model");
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 = (await readSecret(`API key ${c.dim(`(masked; blank = use the ${envKey} env var)`)}: `, rl)).trim();
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
- writeConfigValue("provider", provider);
368
- if (baseURL)
369
- writeConfigValue("baseURL", baseURL);
370
- if (apiKey)
371
- writeConfigValue("apiKey", apiKey);
372
- if (model)
373
- writeConfigValue("model", model);
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({ ...o.cfg, model: __roleModel })) ?? o.baseProvider)
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({ ...o.cfg, model: __revModel })) ?? o.baseProvider) : o.baseProvider;
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({ ...o.cfg, model: __atomModel })) ?? o.baseProvider) : o.baseProvider;
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({ ...cfg, model: __subModel })) ?? baseProvider)
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 hasKey = !!(cfg.apiKey || process.env[providerEnvKey(cfg.provider)] || process.env.HARA_API_KEY);
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(" total · ")}${c.bold(String(cfg.maxAgentRounds))}${c.dim(" rounds · sub-agents ≤8m/24")}`,
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 provider = (opts.provider || "anthropic");
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: opts.key,
1703
- baseURL: opts.baseUrl,
1704
- defaultModel: opts.model,
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 cfg = loadConfig();
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({ ...live, model, reasoningEffort: effort ?? live.reasoningEffort }), live);
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
- return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
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 live = loadConfig({ cwd: targetCwd ?? cwd });
1988
- const model = selectedModel ?? live.model;
2170
+ const current = providerSettingsSnapshot(targetCwd ?? cwd).current;
2171
+ const model = selectedModel ?? current.model;
1989
2172
  return {
1990
- providerId: live.provider,
2173
+ providerId: current.provider,
1991
2174
  model,
1992
- effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning, model).filter((e) => !!e),
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
- out(c.bold("hara serve") + c.dim(` · ws://${o.host}:${handle.port} · ${cfg.provider}:${cfg.model} · approval ${approval} · token → ~/.hara/serve.json\n`));
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 !== cfg.model) {
3032
+ if (provider0 && cfg.fallbackModel && cfg.fallbackModel !== provider0.model) {
2845
3033
  const fp = cfg.fallbackProvider;
2846
- const cross = !!fp && fp !== cfg.provider;
3034
+ const cross = !!fp && fp !== provider0.id;
2847
3035
  const family = (m) => m.toLowerCase().split(/[-.:/]/)[0];
2848
- if (cross && !cfg.fallbackApiKey && !cfg.apiKey) {
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(cfg.model)) {
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 '${cfg.model}', but no fallbackProvider/fallbackBaseURL is set — it would hit the PRIMARY endpoint (likely 400). Set fallbackProvider (+ fallbackApiKey). Fallback disabled.\n`);
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
- ...cfg,
2858
- provider: fp ?? cfg.provider,
3049
+ fallbackProv = await buildProvider(cfg, {
3050
+ ...(fp ? { provider: fp } : {}),
2859
3051
  model: cfg.fallbackModel,
2860
- baseURL: cfg.fallbackBaseURL ?? (fp ? providerDefaultBaseURL(fp) : cfg.baseURL),
2861
- apiKey: cfg.fallbackApiKey ?? (cross ? undefined : cfg.apiKey),
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 pendingHeadlessOperations = new Set();
2927
- const trackHeadlessOperation = (operation) => {
2928
- pendingHeadlessOperations.add(operation);
2929
- const settled = () => { pendingHeadlessOperations.delete(operation); };
2930
- void operation.then(settled, settled);
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({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey })) ?? null)
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({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
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({ ...cfg, model: roleModel });
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: { cwd, sandbox, spawn: (t, role, signal) => runSubagent(cfg, headlessProvider, cwd, sandbox, projectContext, stats, t, role, signal), describeImage },
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
- if (headlessLockId) {
3253
- const lockId = headlessLockId;
3254
- if (pendingHeadlessOperations.size) {
3255
- // Do not await here: the logical deadline stays hard. A late operation with active handles keeps
3256
- // this process (and therefore its cross-process session lease) alive until physical settlement.
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({ ...cfg, model: cfg.visionModel, baseURL: cfg.visionBaseURL ?? cfg.baseURL, apiKey: cfg.visionApiKey ?? cfg.apiKey });
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