@nanhara/hara 0.125.1 → 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 +76 -0
- package/README.md +22 -8
- package/SECURITY.md +6 -4
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +413 -77
- 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 +439 -227
- package/dist/mcp/client.js +73 -6
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/models.js +39 -6
- package/dist/providers/openai.js +6 -1
- package/dist/providers/reasoning.js +12 -0
- 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 +157 -40
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- 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 +22 -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 +49 -17
- package/dist/tui/model-picker.js +11 -8
- 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";
|
|
@@ -76,16 +77,17 @@ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, i
|
|
|
76
77
|
import { searchHybrid } from "./search/hybrid.js";
|
|
77
78
|
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
78
79
|
import { newSessionId, shortId, resolveSessionId, validSessionId, sessionFileExists, saveSession, loadSession, acquireSessionLock, releaseSessionLock, listSessions, latestForCwd, titleFrom, sessionSourceFromEnv, automatedTitle, slugify, } from "./session/store.js";
|
|
79
|
-
import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
|
|
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";
|
|
85
87
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
|
|
86
88
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
87
89
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
88
|
-
import {
|
|
90
|
+
import { closeMcp, registerLazyMcpServers } from "./mcp/client.js";
|
|
89
91
|
import { sandboxSupported, runShell } from "./sandbox.js";
|
|
90
92
|
import { undoLast } from "./undo.js";
|
|
91
93
|
import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.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,25 +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 ?? "");
|
|
1984
2128
|
},
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
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);
|
|
2167
|
+
},
|
|
2168
|
+
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning, cfg.model).filter((e) => !!e),
|
|
2169
|
+
runtimeInfo: (targetCwd, selectedModel) => {
|
|
2170
|
+
const current = providerSettingsSnapshot(targetCwd ?? cwd).current;
|
|
2171
|
+
const model = selectedModel ?? current.model;
|
|
1988
2172
|
return {
|
|
1989
|
-
providerId:
|
|
1990
|
-
model
|
|
1991
|
-
effortLevels: levelsFor(resolvePlatform(
|
|
2173
|
+
providerId: current.provider,
|
|
2174
|
+
model,
|
|
2175
|
+
effortLevels: levelsFor(resolvePlatform(current.provider, current.baseURL).reasoning, model).filter((e) => !!e),
|
|
1992
2176
|
};
|
|
1993
2177
|
},
|
|
1994
2178
|
runLimits: (targetCwd) => agentRunLimits(loadConfig({ cwd: targetCwd ?? cwd })),
|
|
1995
|
-
spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal) => {
|
|
2179
|
+
spawnSubagent: (provider, scwd, projectContext, stats, task, role, signal, observers) => {
|
|
1996
2180
|
const live = loadConfig({ cwd: scwd });
|
|
1997
|
-
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal);
|
|
2181
|
+
return runSubagent(live, provider, scwd, sandbox, projectContext, stats, task, role, signal, observers);
|
|
1998
2182
|
},
|
|
1999
2183
|
guardian: guardianOpt,
|
|
2000
2184
|
buildGuardian: async (targetCwd) => {
|
|
@@ -2005,7 +2189,8 @@ program
|
|
|
2005
2189
|
sandbox,
|
|
2006
2190
|
approval,
|
|
2007
2191
|
});
|
|
2008
|
-
|
|
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`));
|
|
2009
2194
|
const bye = async () => {
|
|
2010
2195
|
await handle.close();
|
|
2011
2196
|
process.exit(0);
|
|
@@ -2833,31 +3018,39 @@ program.action(async (opts) => {
|
|
|
2833
3018
|
const machineOutput = !!opts.print && !!opts.schema;
|
|
2834
3019
|
if (opts.model)
|
|
2835
3020
|
cfg.model = opts.model;
|
|
2836
|
-
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
|
+
}
|
|
2837
3026
|
// Fallback provider, built correctly for CROSS-PROVIDER failover. The old `baseURL: fallbackBaseURL ??
|
|
2838
3027
|
// baseURL` sent the fallback model to the PRIMARY endpoint when no fallbackBaseURL was set — e.g.
|
|
2839
3028
|
// deepseek-v4-pro posted to coding.dashscope → 400. Now: `fallbackProvider` routes to that vendor's
|
|
2840
3029
|
// endpoint + its own key (fallbackBaseURL/fallbackApiKey still override); without it we stay on the
|
|
2841
3030
|
// primary endpoint (correct only for a same-endpoint fallback).
|
|
2842
3031
|
let fallbackProv = null;
|
|
2843
|
-
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !==
|
|
3032
|
+
if (provider0 && cfg.fallbackModel && cfg.fallbackModel !== provider0.model) {
|
|
2844
3033
|
const fp = cfg.fallbackProvider;
|
|
2845
|
-
const cross = !!fp && fp !==
|
|
3034
|
+
const cross = !!fp && fp !== provider0.id;
|
|
2846
3035
|
const family = (m) => m.toLowerCase().split(/[-.:/]/)[0];
|
|
2847
|
-
|
|
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) {
|
|
2848
3042
|
process.stderr.write(`hara: fallbackProvider '${fp}' needs its own key — set fallbackApiKey. Fallback disabled.\n`);
|
|
2849
3043
|
}
|
|
2850
|
-
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(
|
|
3044
|
+
else if (!fp && !cfg.fallbackBaseURL && family(cfg.fallbackModel) !== family(provider0.model)) {
|
|
2851
3045
|
// A different-looking vendor with no routing set would hit the primary endpoint and 400 on failover.
|
|
2852
|
-
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`);
|
|
2853
3047
|
}
|
|
2854
3048
|
else {
|
|
2855
|
-
fallbackProv = await buildProvider({
|
|
2856
|
-
...
|
|
2857
|
-
provider: fp ?? cfg.provider,
|
|
3049
|
+
fallbackProv = await buildProvider(cfg, {
|
|
3050
|
+
...(fp ? { provider: fp } : {}),
|
|
2858
3051
|
model: cfg.fallbackModel,
|
|
2859
|
-
|
|
2860
|
-
apiKey: cfg.fallbackApiKey
|
|
3052
|
+
...(cfg.fallbackBaseURL ? { baseURL: cfg.fallbackBaseURL } : {}),
|
|
3053
|
+
...(cross ? { apiKey: crossKey } : cfg.fallbackApiKey ? { apiKey: cfg.fallbackApiKey } : {}),
|
|
2861
3054
|
});
|
|
2862
3055
|
}
|
|
2863
3056
|
}
|
|
@@ -2911,45 +3104,25 @@ program.action(async (opts) => {
|
|
|
2911
3104
|
out(c.yellow(message));
|
|
2912
3105
|
}
|
|
2913
3106
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
3107
|
+
// Advertise configured MCP capabilities without starting any subprocess or blocking startup for permission.
|
|
3108
|
+
// The model can call `mcp_connect` when a task first needs ONE server; that external-boundary tool goes
|
|
3109
|
+
// through the ordinary interactive grant, and the newly discovered tools appear on the next model round.
|
|
3110
|
+
// Read-only headless roles do not receive the launcher at all.
|
|
2918
3111
|
const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
|
|
2919
|
-
|
|
2920
|
-
|
|
2921
|
-
const trustedOptIn = process.env.HARA_ALLOW_TRUSTED_EXTENSIONS === "1";
|
|
2922
|
-
const interactiveTerminal = !opts.print && !!stdin.isTTY && !!stdout.isTTY;
|
|
2923
|
-
const approved = trustedOptIn ||
|
|
2924
|
-
(interactiveTerminal &&
|
|
2925
|
-
(await askConfirm(`Start configured MCP server${mcpNames.length === 1 ? "" : "s"} ${mcpNames
|
|
2926
|
-
.slice(0, 8)
|
|
2927
|
-
.map((name) => `'${name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80)}'`)
|
|
2928
|
-
.join(", ")}${mcpNames.length > 8 ? ` and ${mcpNames.length - 8} more` : ""}? ` +
|
|
2929
|
-
"They execute external code outside Hara's protected-file boundary.", false)));
|
|
2930
|
-
if (approved) {
|
|
2931
|
-
await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")), { approved: true });
|
|
2932
|
-
}
|
|
2933
|
-
else {
|
|
2934
|
-
const message = interactiveTerminal
|
|
2935
|
-
? "hara: MCP server startup declined; no configured MCP command was executed.\n"
|
|
2936
|
-
: "hara: MCP servers skipped because this run has no interactive startup approval. " +
|
|
2937
|
-
"Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only for reviewed servers.\n";
|
|
2938
|
-
if (machineOutput || !interactiveTerminal)
|
|
2939
|
-
process.stderr.write(message);
|
|
2940
|
-
else
|
|
2941
|
-
out(c.dim(message));
|
|
2942
|
-
}
|
|
3112
|
+
if (!requestedHeadlessRole?.readOnly && Object.keys(mcpAll).length) {
|
|
3113
|
+
registerLazyMcpServers(mcpAll, (message) => machineOutput ? process.stderr.write(message + "\n") : out(c.dim(message + "\n")));
|
|
2943
3114
|
}
|
|
2944
3115
|
// one-shot
|
|
2945
3116
|
if (opts.print) {
|
|
2946
3117
|
let headlessLockId = null;
|
|
2947
|
-
const
|
|
2948
|
-
|
|
2949
|
-
|
|
2950
|
-
const
|
|
2951
|
-
|
|
2952
|
-
|
|
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;
|
|
2953
3126
|
try {
|
|
2954
3127
|
const projectContext = loadAgentContext(cwd) || undefined;
|
|
2955
3128
|
// Vision sidecar for headless runs (gateway/cron): without it the computer tool's screenshots come back
|
|
@@ -2958,7 +3131,11 @@ program.action(async (opts) => {
|
|
|
2958
3131
|
const describeImage = async (path, hint, signal) => {
|
|
2959
3132
|
const cap = classifyVision(cfg.provider, cfg.model, cfg.modelVision);
|
|
2960
3133
|
const vp = cfg.visionModel
|
|
2961
|
-
? ((await buildProvider(
|
|
3134
|
+
? ((await buildProvider(cfg, {
|
|
3135
|
+
model: cfg.visionModel,
|
|
3136
|
+
...(cfg.visionBaseURL ? { baseURL: cfg.visionBaseURL } : {}),
|
|
3137
|
+
...(cfg.visionApiKey ? { apiKey: cfg.visionApiKey } : {}),
|
|
3138
|
+
})) ?? null)
|
|
2962
3139
|
: cap === "vision"
|
|
2963
3140
|
? provider
|
|
2964
3141
|
: null;
|
|
@@ -3065,13 +3242,13 @@ program.action(async (opts) => {
|
|
|
3065
3242
|
catch { /* ignore */ }
|
|
3066
3243
|
cfg.model = __fb;
|
|
3067
3244
|
meta.model = __fb;
|
|
3068
|
-
const __rb = await buildProvider(cfg);
|
|
3245
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3069
3246
|
if (__rb)
|
|
3070
3247
|
provider = __rb;
|
|
3071
3248
|
}
|
|
3072
3249
|
else {
|
|
3073
3250
|
cfg.model = meta.model;
|
|
3074
|
-
const __rb = await buildProvider(cfg);
|
|
3251
|
+
const __rb = await buildProvider(cfg, { model: cfg.model });
|
|
3075
3252
|
if (__rb)
|
|
3076
3253
|
provider = __rb;
|
|
3077
3254
|
}
|
|
@@ -3130,7 +3307,11 @@ program.action(async (opts) => {
|
|
|
3130
3307
|
else if (inboundImgs.length && cfg.visionModel) {
|
|
3131
3308
|
let desc = "";
|
|
3132
3309
|
try {
|
|
3133
|
-
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
|
+
});
|
|
3134
3315
|
if (vp)
|
|
3135
3316
|
desc = await describeImages(vp, inboundImgs);
|
|
3136
3317
|
}
|
|
@@ -3179,7 +3360,7 @@ program.action(async (opts) => {
|
|
|
3179
3360
|
headlessHooks = false;
|
|
3180
3361
|
const roleModel = effectiveRoleModel(requestedHeadlessRole.model, cfg.model);
|
|
3181
3362
|
if (roleModel && roleModel !== provider.model) {
|
|
3182
|
-
const selected = await buildProvider(
|
|
3363
|
+
const selected = await buildProvider(cfg, { model: roleModel });
|
|
3183
3364
|
if (!selected) {
|
|
3184
3365
|
process.stderr.write(`hara: role '${opts.role}' requires model '${roleModel}', but that provider is not authenticated.\n`);
|
|
3185
3366
|
process.exitCode = 2;
|
|
@@ -3193,13 +3374,33 @@ program.action(async (opts) => {
|
|
|
3193
3374
|
let structuredSet = false;
|
|
3194
3375
|
const printRunOpts = {
|
|
3195
3376
|
provider: headlessProvider,
|
|
3196
|
-
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
|
+
},
|
|
3197
3386
|
approval: "full-auto",
|
|
3198
3387
|
confirm: async () => true,
|
|
3199
3388
|
projectContext,
|
|
3200
3389
|
memory: memoryDigest(cwd),
|
|
3201
3390
|
continuationSession,
|
|
3202
3391
|
executionContext: taskExecutionContext(task, headlessInteraction, meta?.todos ?? []),
|
|
3392
|
+
taskIntake: {
|
|
3393
|
+
task,
|
|
3394
|
+
current: () => task,
|
|
3395
|
+
onUpdate: (next) => {
|
|
3396
|
+
task = next;
|
|
3397
|
+
},
|
|
3398
|
+
onCheckpoint: (next) => {
|
|
3399
|
+
task = next;
|
|
3400
|
+
if (meta)
|
|
3401
|
+
saveSession(meta, history, task);
|
|
3402
|
+
},
|
|
3403
|
+
},
|
|
3203
3404
|
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
3204
3405
|
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
3205
3406
|
hooks: headlessHooks,
|
|
@@ -3248,7 +3449,7 @@ program.action(async (opts) => {
|
|
|
3248
3449
|
// Long-session safety: auto-compact before saving so a long chat/cron thread never overflows context.
|
|
3249
3450
|
// Silent (no-op notify) in headless mode so nothing leaks into a captured -p reply. Opt-out via config.
|
|
3250
3451
|
if (runOutcome.status === "completed") {
|
|
3251
|
-
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task);
|
|
3452
|
+
await maybeAutoCompact(headlessProvider, history, meta, stats, cfg, () => { }, undefined, task, trackHeadlessOperation);
|
|
3252
3453
|
}
|
|
3253
3454
|
saveSession(meta, history, task); // persist when resuming/continuing; plain -p stays stateless
|
|
3254
3455
|
}
|
|
@@ -3258,19 +3459,11 @@ program.action(async (opts) => {
|
|
|
3258
3459
|
return;
|
|
3259
3460
|
}
|
|
3260
3461
|
finally {
|
|
3261
|
-
|
|
3262
|
-
|
|
3263
|
-
|
|
3264
|
-
|
|
3265
|
-
|
|
3266
|
-
// A truly inert never-settling Promise has no side effect/handle, so normal process exit makes the
|
|
3267
|
-
// PID-backed lease safely reclaimable.
|
|
3268
|
-
void Promise.allSettled([...pendingHeadlessOperations]).then(() => releaseSessionLock(lockId));
|
|
3269
|
-
}
|
|
3270
|
-
else {
|
|
3271
|
-
releaseSessionLock(lockId);
|
|
3272
|
-
}
|
|
3273
|
-
}
|
|
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();
|
|
3274
3467
|
await closeMcp();
|
|
3275
3468
|
}
|
|
3276
3469
|
}
|
|
@@ -3433,13 +3626,13 @@ program.action(async (opts) => {
|
|
|
3433
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`));
|
|
3434
3627
|
cfg.model = __fallback;
|
|
3435
3628
|
meta.model = __fallback;
|
|
3436
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3629
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3437
3630
|
if (__rebuilt)
|
|
3438
3631
|
provider = __rebuilt;
|
|
3439
3632
|
}
|
|
3440
3633
|
else {
|
|
3441
3634
|
cfg.model = meta.model;
|
|
3442
|
-
const __rebuilt = await buildProvider(cfg);
|
|
3635
|
+
const __rebuilt = await buildProvider(cfg, { model: cfg.model });
|
|
3443
3636
|
if (__rebuilt)
|
|
3444
3637
|
provider = __rebuilt;
|
|
3445
3638
|
}
|
|
@@ -3447,6 +3640,19 @@ program.action(async (opts) => {
|
|
|
3447
3640
|
}
|
|
3448
3641
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
3449
3642
|
const persistSession = () => saveSession(meta, history, task);
|
|
3643
|
+
const taskIntakeForRun = () => task
|
|
3644
|
+
? {
|
|
3645
|
+
task,
|
|
3646
|
+
current: () => task,
|
|
3647
|
+
onUpdate: (next) => {
|
|
3648
|
+
task = next;
|
|
3649
|
+
},
|
|
3650
|
+
onCheckpoint: (next) => {
|
|
3651
|
+
task = next;
|
|
3652
|
+
persistSession();
|
|
3653
|
+
},
|
|
3654
|
+
}
|
|
3655
|
+
: undefined;
|
|
3450
3656
|
let requestedWorkspaceSwitch = null;
|
|
3451
3657
|
let requestedSessionSwitch = null;
|
|
3452
3658
|
const relaunchRequestedTarget = async () => {
|
|
@@ -3639,7 +3845,7 @@ program.action(async (opts) => {
|
|
|
3639
3845
|
setSessionForceModel(force);
|
|
3640
3846
|
visionProvider = undefined;
|
|
3641
3847
|
remindedVision = false;
|
|
3642
|
-
const p = await buildProvider(cfg);
|
|
3848
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
3643
3849
|
if (p) {
|
|
3644
3850
|
provider = p;
|
|
3645
3851
|
if (bar.isActive())
|
|
@@ -3981,7 +4187,11 @@ program.action(async (opts) => {
|
|
|
3981
4187
|
const getVisionProvider = async () => {
|
|
3982
4188
|
if (visionProvider !== undefined)
|
|
3983
4189
|
return visionProvider;
|
|
3984
|
-
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
|
+
});
|
|
3985
4195
|
return visionProvider;
|
|
3986
4196
|
};
|
|
3987
4197
|
// lets the computer tool return a screenshot as text (describe via the vision sidecar / a vision main model).
|
|
@@ -4089,6 +4299,7 @@ program.action(async (opts) => {
|
|
|
4089
4299
|
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
4090
4300
|
model: cfg.model,
|
|
4091
4301
|
cwd,
|
|
4302
|
+
agentSlashCommands: loadSkillIndex(cwd).filter((skill) => skill.userInvocable).map((skill) => skill.id),
|
|
4092
4303
|
header: {
|
|
4093
4304
|
version: pkg.version,
|
|
4094
4305
|
modelLabel: `${cfg.provider}:${cfg.model}`,
|
|
@@ -4127,6 +4338,41 @@ program.action(async (opts) => {
|
|
|
4127
4338
|
turnId: interaction?.turnId ?? newTurnInteraction().turnId,
|
|
4128
4339
|
};
|
|
4129
4340
|
}
|
|
4341
|
+
// Type-ahead steering belongs to every executable Agent turn, including `/skill` kickoff turns.
|
|
4342
|
+
// Local slash controls never publish a steer target in App, so they cannot enter this callback.
|
|
4343
|
+
const pendingInput = async () => {
|
|
4344
|
+
const freshMessages = new Map();
|
|
4345
|
+
for (const it of h.drainQueue()) {
|
|
4346
|
+
const r2 = await resolveImages(it.images, h);
|
|
4347
|
+
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
4348
|
+
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
4349
|
+
if (!body.trim() && !attach)
|
|
4350
|
+
continue;
|
|
4351
|
+
const recorded = recordTaskSteering(task, it.expectedTurnId, body || "(image-only steering)");
|
|
4352
|
+
if (!recorded.ok) {
|
|
4353
|
+
h.sink.notice(`(steer rejected: ${recorded.reason})`);
|
|
4354
|
+
continue;
|
|
4355
|
+
}
|
|
4356
|
+
task = recorded.task;
|
|
4357
|
+
const accepted = task.steering?.at(-1);
|
|
4358
|
+
if (accepted?.deliveryState === "pending") {
|
|
4359
|
+
freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4360
|
+
}
|
|
4361
|
+
}
|
|
4362
|
+
const consumed = consumePendingTaskSteering(task);
|
|
4363
|
+
if (!consumed)
|
|
4364
|
+
return [];
|
|
4365
|
+
const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
|
|
4366
|
+
role: "user",
|
|
4367
|
+
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4368
|
+
}));
|
|
4369
|
+
// Write ahead both the transcript projection and the consumed inbox state. Returning [] prevents
|
|
4370
|
+
// runAgent from appending the same messages again after this shared live history is updated.
|
|
4371
|
+
saveSession(meta, [...history, ...out], consumed.task);
|
|
4372
|
+
task = consumed.task;
|
|
4373
|
+
history.push(...out);
|
|
4374
|
+
return [];
|
|
4375
|
+
};
|
|
4130
4376
|
// A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
|
|
4131
4377
|
// is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
|
|
4132
4378
|
if (isSlashCommand(line)) {
|
|
@@ -4249,7 +4495,7 @@ program.action(async (opts) => {
|
|
|
4249
4495
|
cfg.reasoningEffort = chosen.effort;
|
|
4250
4496
|
visionProvider = undefined;
|
|
4251
4497
|
remindedVision = false;
|
|
4252
|
-
const p2 = await buildProvider(cfg);
|
|
4498
|
+
const p2 = await buildProvider(cfg, { model: cfg.model });
|
|
4253
4499
|
if (!p2)
|
|
4254
4500
|
return void h.sink.notice("(could not rebuild provider)");
|
|
4255
4501
|
provider = p2;
|
|
@@ -4261,7 +4507,7 @@ program.action(async (opts) => {
|
|
|
4261
4507
|
setSessionForceModel(force);
|
|
4262
4508
|
visionProvider = undefined; // new model may resolve a different describer / capability
|
|
4263
4509
|
remindedVision = false;
|
|
4264
|
-
const p = await buildProvider(cfg);
|
|
4510
|
+
const p = await buildProvider(cfg, { model: cfg.model });
|
|
4265
4511
|
if (p) {
|
|
4266
4512
|
provider = p;
|
|
4267
4513
|
persistSession(); // persist the session-pinned model so resume restores it
|
|
@@ -4461,7 +4707,7 @@ program.action(async (opts) => {
|
|
|
4461
4707
|
clearTodos();
|
|
4462
4708
|
meta.todos = [];
|
|
4463
4709
|
const skillContent = `Skill \`${sk.id}\`:\n${loadSkillBody(sk)}\n\n---\nEntering ${sk.id} mode${arg ? ` — request: ${arg}` : ""}. Follow this skill now. If it has a workspace or live preview, OPEN it FIRST so any existing progress is visible, then proceed — offer to continue existing work or start fresh.`;
|
|
4464
|
-
const skillInteraction = interaction ?? newTurnInteraction();
|
|
4710
|
+
const skillInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction()).interaction;
|
|
4465
4711
|
if (skillInteraction.kind === "steer") {
|
|
4466
4712
|
const continued = continueTaskExecution(task, skillInteraction);
|
|
4467
4713
|
if (!continued.ok)
|
|
@@ -4482,7 +4728,7 @@ program.action(async (opts) => {
|
|
|
4482
4728
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
4483
4729
|
let skillOutcome;
|
|
4484
4730
|
try {
|
|
4485
|
-
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4731
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ui: { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice }, ask: h.ask, describeImage: describeScreenshot, locate: locateScreenshot }, approval: __skApproval, confirm: h.confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), pendingInput, stats, signal: h.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4486
4732
|
}
|
|
4487
4733
|
catch (e) {
|
|
4488
4734
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
@@ -4506,7 +4752,7 @@ program.action(async (opts) => {
|
|
|
4506
4752
|
line = inlineLeadingPath(line, existsSync);
|
|
4507
4753
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
4508
4754
|
const appr = h.approval;
|
|
4509
|
-
let submittedInteraction = interaction ?? newTurnInteraction();
|
|
4755
|
+
let submittedInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction(), { allowInactive: !!continueCommand }).interaction;
|
|
4510
4756
|
if (resumeTaskPending && task && submittedInteraction.kind === "turn" &&
|
|
4511
4757
|
(hasPendingTaskSteering(task) || requestsTaskContinuation(line))) {
|
|
4512
4758
|
submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
|
|
@@ -4528,43 +4774,6 @@ program.action(async (opts) => {
|
|
|
4528
4774
|
resumeTaskPending = false;
|
|
4529
4775
|
return taskExecutionContext(task, submittedInteraction, meta.todos ?? []);
|
|
4530
4776
|
};
|
|
4531
|
-
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
4532
|
-
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
4533
|
-
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
4534
|
-
const pendingInput = async () => {
|
|
4535
|
-
const freshMessages = new Map();
|
|
4536
|
-
for (const it of h.drainQueue()) {
|
|
4537
|
-
const r2 = await resolveImages(it.images, h);
|
|
4538
|
-
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
4539
|
-
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
4540
|
-
if (!body.trim() && !attach)
|
|
4541
|
-
continue; // image-only message whose image was skipped → nothing to add
|
|
4542
|
-
const recorded = recordTaskSteering(task, it.expectedTurnId, body || "(image-only steering)");
|
|
4543
|
-
if (!recorded.ok) {
|
|
4544
|
-
h.sink.notice(`(steer rejected: ${recorded.reason})`);
|
|
4545
|
-
continue;
|
|
4546
|
-
}
|
|
4547
|
-
task = recorded.task;
|
|
4548
|
-
const accepted = task.steering?.at(-1);
|
|
4549
|
-
if (accepted?.deliveryState === "pending") {
|
|
4550
|
-
freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4551
|
-
}
|
|
4552
|
-
}
|
|
4553
|
-
const consumed = consumePendingTaskSteering(task);
|
|
4554
|
-
if (!consumed)
|
|
4555
|
-
return [];
|
|
4556
|
-
const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
|
|
4557
|
-
role: "user",
|
|
4558
|
-
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4559
|
-
}));
|
|
4560
|
-
// Persist the projected transcript and consumed inbox state first, then update the same live
|
|
4561
|
-
// history synchronously. Returning [] prevents runAgent from appending the messages twice;
|
|
4562
|
-
// loop from appending twice; cancellation/error saves can no longer overwrite the projected input.
|
|
4563
|
-
saveSession(meta, [...history, ...out], consumed.task);
|
|
4564
|
-
task = consumed.task;
|
|
4565
|
-
history.push(...out);
|
|
4566
|
-
return [];
|
|
4567
|
-
};
|
|
4568
4777
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
4569
4778
|
if (appr === "plan") {
|
|
4570
4779
|
// PLAN MODE: read-only investigate; the MODEL signals plan-readiness by calling `exit_plan`
|
|
@@ -4611,6 +4820,7 @@ program.action(async (opts) => {
|
|
|
4611
4820
|
projectContext,
|
|
4612
4821
|
continuationSession,
|
|
4613
4822
|
executionContext,
|
|
4823
|
+
taskIntake: taskIntakeForRun(),
|
|
4614
4824
|
stats,
|
|
4615
4825
|
signal: h.signal,
|
|
4616
4826
|
pendingInput,
|
|
@@ -4664,6 +4874,7 @@ program.action(async (opts) => {
|
|
|
4664
4874
|
projectContext,
|
|
4665
4875
|
continuationSession,
|
|
4666
4876
|
executionContext,
|
|
4877
|
+
taskIntake: taskIntakeForRun(),
|
|
4667
4878
|
stats,
|
|
4668
4879
|
signal: h.signal,
|
|
4669
4880
|
pendingInput,
|
|
@@ -4708,6 +4919,7 @@ program.action(async (opts) => {
|
|
|
4708
4919
|
projectContext,
|
|
4709
4920
|
continuationSession,
|
|
4710
4921
|
executionContext,
|
|
4922
|
+
taskIntake: taskIntakeForRun(),
|
|
4711
4923
|
stats,
|
|
4712
4924
|
signal: h.signal,
|
|
4713
4925
|
pendingInput,
|
|
@@ -4797,7 +5009,7 @@ program.action(async (opts) => {
|
|
|
4797
5009
|
currentTurn = skillTurn;
|
|
4798
5010
|
let skillOutcome;
|
|
4799
5011
|
try {
|
|
4800
|
-
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
5012
|
+
skillOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext: skillExecutionContext, taskIntake: taskIntakeForRun(), stats, signal: skillTurn.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4801
5013
|
}
|
|
4802
5014
|
catch (e) {
|
|
4803
5015
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -4865,7 +5077,7 @@ program.action(async (opts) => {
|
|
|
4865
5077
|
const t0 = Date.now();
|
|
4866
5078
|
let turnOutcome;
|
|
4867
5079
|
try {
|
|
4868
|
-
turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext, stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
5080
|
+
turnOutcome = await runAgent(history, { provider, ctx: { cwd, sandbox, spawn, ask: askUser }, approval, confirm, autoApprove, projectContext, memory: buildMemory(), continuationSession, executionContext, taskIntake: taskIntakeForRun(), stats, signal: turnController.signal, fallback: fbOpt, guardian: guardianOpt, ...agentRunLimits(cfg) });
|
|
4869
5081
|
}
|
|
4870
5082
|
catch (e) {
|
|
4871
5083
|
out(c.red(`\n[error] ${e.message}\n`));
|