@nanhara/hara 0.125.1 → 0.125.3
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 +37 -0
- package/README.md +16 -5
- package/SECURITY.md +6 -4
- package/dist/agent/loop.js +175 -11
- package/dist/index.js +82 -76
- package/dist/mcp/client.js +72 -6
- package/dist/providers/models.js +39 -6
- package/dist/providers/reasoning.js +12 -0
- package/dist/serve/server.js +18 -5
- package/dist/session/task.js +78 -0
- package/dist/statusbar.js +15 -2
- package/dist/tools/builtin.js +3 -0
- package/dist/tui/App.js +44 -16
- package/dist/tui/model-picker.js +11 -8
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -76,7 +76,7 @@ import { collectRepoChunksAsync, collectDirChunksAsync, buildIndex, indexPath, i
|
|
|
76
76
|
import { searchHybrid } from "./search/hybrid.js";
|
|
77
77
|
import { expandMentionsAsync, fileCandidates, isSlashCommand, inlineLeadingPath } from "./context/mentions.js";
|
|
78
78
|
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";
|
|
79
|
+
import { consumePendingTaskSteering, continueTaskExecution, createTaskExecution, finishTaskExecution, formatTaskExecution, hasPendingTaskSteering, newSteerInteraction, newTurnInteraction, recordTaskSteering, recoverTaskExecution, routeTaskInteraction, requestsTaskContinuation, taskExecutionContext, } from "./session/task.js";
|
|
80
80
|
import { displaySessionCwd, resolveSessionResumeTarget } from "./session/resume.js";
|
|
81
81
|
import { setSessionForceModel, isSessionForceModel, effectiveRoleModel } from "./session/session-model.js";
|
|
82
82
|
import { loadGlobalRoles, loadRoles, roleToolFilter, scaffoldRoles, subagentToolFilter } from "./org/roles.js";
|
|
@@ -85,7 +85,7 @@ import { loadSkillIndex, loadSkillBody, scaffoldSkills, globalSkillsDir } from "
|
|
|
85
85
|
import { installPlugin, uninstallPlugin, listInstalled, enabledPlugins, setPluginEnabled, pluginMcpServers, pluginHooks, haraBinDir } from "./plugins/plugins.js";
|
|
86
86
|
import { routeByKeywords, buildDispatchPrompt, parseRoleId } from "./org/router.js";
|
|
87
87
|
import { decompose, topoOrder, topoWaves, savePlan, loadPlan, atomPrompt, verify, runCheck } from "./org/planner.js";
|
|
88
|
-
import {
|
|
88
|
+
import { closeMcp, registerLazyMcpServers } from "./mcp/client.js";
|
|
89
89
|
import { sandboxSupported, runShell } from "./sandbox.js";
|
|
90
90
|
import { undoLast } from "./undo.js";
|
|
91
91
|
import { scaffoldAssets, assetsDir, assetSearchRoots } from "./recall.js";
|
|
@@ -1982,13 +1982,14 @@ program
|
|
|
1982
1982
|
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1983
1983
|
return listModels(live.baseURL ?? providerDefaultBaseURL(live.provider), live.apiKey ?? "");
|
|
1984
1984
|
},
|
|
1985
|
-
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning).filter((e) => !!e),
|
|
1986
|
-
runtimeInfo: (targetCwd) => {
|
|
1985
|
+
effortLevels: levelsFor(resolvePlatform(cfg.provider, cfg.baseURL ?? providerDefaultBaseURL(cfg.provider)).reasoning, cfg.model).filter((e) => !!e),
|
|
1986
|
+
runtimeInfo: (targetCwd, selectedModel) => {
|
|
1987
1987
|
const live = loadConfig({ cwd: targetCwd ?? cwd });
|
|
1988
|
+
const model = selectedModel ?? live.model;
|
|
1988
1989
|
return {
|
|
1989
1990
|
providerId: live.provider,
|
|
1990
|
-
model
|
|
1991
|
-
effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning).filter((e) => !!e),
|
|
1991
|
+
model,
|
|
1992
|
+
effortLevels: levelsFor(resolvePlatform(live.provider, live.baseURL ?? providerDefaultBaseURL(live.provider)).reasoning, model).filter((e) => !!e),
|
|
1992
1993
|
};
|
|
1993
1994
|
},
|
|
1994
1995
|
runLimits: (targetCwd) => agentRunLimits(loadConfig({ cwd: targetCwd ?? cwd })),
|
|
@@ -2911,35 +2912,13 @@ program.action(async (opts) => {
|
|
|
2911
2912
|
out(c.yellow(message));
|
|
2912
2913
|
}
|
|
2913
2914
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
2916
|
-
//
|
|
2917
|
-
//
|
|
2915
|
+
// Advertise configured MCP capabilities without starting any subprocess or blocking startup for permission.
|
|
2916
|
+
// The model can call `mcp_connect` when a task first needs ONE server; that external-boundary tool goes
|
|
2917
|
+
// through the ordinary interactive grant, and the newly discovered tools appear on the next model round.
|
|
2918
|
+
// Read-only headless roles do not receive the launcher at all.
|
|
2918
2919
|
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
|
-
}
|
|
2920
|
+
if (!requestedHeadlessRole?.readOnly && Object.keys(mcpAll).length) {
|
|
2921
|
+
registerLazyMcpServers(mcpAll, (message) => machineOutput ? process.stderr.write(message + "\n") : out(c.dim(message + "\n")));
|
|
2943
2922
|
}
|
|
2944
2923
|
// one-shot
|
|
2945
2924
|
if (opts.print) {
|
|
@@ -3200,6 +3179,18 @@ program.action(async (opts) => {
|
|
|
3200
3179
|
memory: memoryDigest(cwd),
|
|
3201
3180
|
continuationSession,
|
|
3202
3181
|
executionContext: taskExecutionContext(task, headlessInteraction, meta?.todos ?? []),
|
|
3182
|
+
taskIntake: {
|
|
3183
|
+
task,
|
|
3184
|
+
current: () => task,
|
|
3185
|
+
onUpdate: (next) => {
|
|
3186
|
+
task = next;
|
|
3187
|
+
},
|
|
3188
|
+
onCheckpoint: (next) => {
|
|
3189
|
+
task = next;
|
|
3190
|
+
if (meta)
|
|
3191
|
+
saveSession(meta, history, task);
|
|
3192
|
+
},
|
|
3193
|
+
},
|
|
3203
3194
|
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
3204
3195
|
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
3205
3196
|
hooks: headlessHooks,
|
|
@@ -3447,6 +3438,19 @@ program.action(async (opts) => {
|
|
|
3447
3438
|
}
|
|
3448
3439
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
3449
3440
|
const persistSession = () => saveSession(meta, history, task);
|
|
3441
|
+
const taskIntakeForRun = () => task
|
|
3442
|
+
? {
|
|
3443
|
+
task,
|
|
3444
|
+
current: () => task,
|
|
3445
|
+
onUpdate: (next) => {
|
|
3446
|
+
task = next;
|
|
3447
|
+
},
|
|
3448
|
+
onCheckpoint: (next) => {
|
|
3449
|
+
task = next;
|
|
3450
|
+
persistSession();
|
|
3451
|
+
},
|
|
3452
|
+
}
|
|
3453
|
+
: undefined;
|
|
3450
3454
|
let requestedWorkspaceSwitch = null;
|
|
3451
3455
|
let requestedSessionSwitch = null;
|
|
3452
3456
|
const relaunchRequestedTarget = async () => {
|
|
@@ -4089,6 +4093,7 @@ program.action(async (opts) => {
|
|
|
4089
4093
|
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
4090
4094
|
model: cfg.model,
|
|
4091
4095
|
cwd,
|
|
4096
|
+
agentSlashCommands: loadSkillIndex(cwd).filter((skill) => skill.userInvocable).map((skill) => skill.id),
|
|
4092
4097
|
header: {
|
|
4093
4098
|
version: pkg.version,
|
|
4094
4099
|
modelLabel: `${cfg.provider}:${cfg.model}`,
|
|
@@ -4127,6 +4132,41 @@ program.action(async (opts) => {
|
|
|
4127
4132
|
turnId: interaction?.turnId ?? newTurnInteraction().turnId,
|
|
4128
4133
|
};
|
|
4129
4134
|
}
|
|
4135
|
+
// Type-ahead steering belongs to every executable Agent turn, including `/skill` kickoff turns.
|
|
4136
|
+
// Local slash controls never publish a steer target in App, so they cannot enter this callback.
|
|
4137
|
+
const pendingInput = async () => {
|
|
4138
|
+
const freshMessages = new Map();
|
|
4139
|
+
for (const it of h.drainQueue()) {
|
|
4140
|
+
const r2 = await resolveImages(it.images, h);
|
|
4141
|
+
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
4142
|
+
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
4143
|
+
if (!body.trim() && !attach)
|
|
4144
|
+
continue;
|
|
4145
|
+
const recorded = recordTaskSteering(task, it.expectedTurnId, body || "(image-only steering)");
|
|
4146
|
+
if (!recorded.ok) {
|
|
4147
|
+
h.sink.notice(`(steer rejected: ${recorded.reason})`);
|
|
4148
|
+
continue;
|
|
4149
|
+
}
|
|
4150
|
+
task = recorded.task;
|
|
4151
|
+
const accepted = task.steering?.at(-1);
|
|
4152
|
+
if (accepted?.deliveryState === "pending") {
|
|
4153
|
+
freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4154
|
+
}
|
|
4155
|
+
}
|
|
4156
|
+
const consumed = consumePendingTaskSteering(task);
|
|
4157
|
+
if (!consumed)
|
|
4158
|
+
return [];
|
|
4159
|
+
const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
|
|
4160
|
+
role: "user",
|
|
4161
|
+
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4162
|
+
}));
|
|
4163
|
+
// Write ahead both the transcript projection and the consumed inbox state. Returning [] prevents
|
|
4164
|
+
// runAgent from appending the same messages again after this shared live history is updated.
|
|
4165
|
+
saveSession(meta, [...history, ...out], consumed.task);
|
|
4166
|
+
task = consumed.task;
|
|
4167
|
+
history.push(...out);
|
|
4168
|
+
return [];
|
|
4169
|
+
};
|
|
4130
4170
|
// A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
|
|
4131
4171
|
// is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
|
|
4132
4172
|
if (isSlashCommand(line)) {
|
|
@@ -4461,7 +4501,7 @@ program.action(async (opts) => {
|
|
|
4461
4501
|
clearTodos();
|
|
4462
4502
|
meta.todos = [];
|
|
4463
4503
|
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();
|
|
4504
|
+
const skillInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction()).interaction;
|
|
4465
4505
|
if (skillInteraction.kind === "steer") {
|
|
4466
4506
|
const continued = continueTaskExecution(task, skillInteraction);
|
|
4467
4507
|
if (!continued.ok)
|
|
@@ -4482,7 +4522,7 @@ program.action(async (opts) => {
|
|
|
4482
4522
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
4483
4523
|
let skillOutcome;
|
|
4484
4524
|
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) });
|
|
4525
|
+
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
4526
|
}
|
|
4487
4527
|
catch (e) {
|
|
4488
4528
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
@@ -4506,7 +4546,7 @@ program.action(async (opts) => {
|
|
|
4506
4546
|
line = inlineLeadingPath(line, existsSync);
|
|
4507
4547
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
4508
4548
|
const appr = h.approval;
|
|
4509
|
-
let submittedInteraction = interaction ?? newTurnInteraction();
|
|
4549
|
+
let submittedInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction(), { allowInactive: !!continueCommand }).interaction;
|
|
4510
4550
|
if (resumeTaskPending && task && submittedInteraction.kind === "turn" &&
|
|
4511
4551
|
(hasPendingTaskSteering(task) || requestsTaskContinuation(line))) {
|
|
4512
4552
|
submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
|
|
@@ -4528,43 +4568,6 @@ program.action(async (opts) => {
|
|
|
4528
4568
|
resumeTaskPending = false;
|
|
4529
4569
|
return taskExecutionContext(task, submittedInteraction, meta.todos ?? []);
|
|
4530
4570
|
};
|
|
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
4571
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
4569
4572
|
if (appr === "plan") {
|
|
4570
4573
|
// PLAN MODE: read-only investigate; the MODEL signals plan-readiness by calling `exit_plan`
|
|
@@ -4611,6 +4614,7 @@ program.action(async (opts) => {
|
|
|
4611
4614
|
projectContext,
|
|
4612
4615
|
continuationSession,
|
|
4613
4616
|
executionContext,
|
|
4617
|
+
taskIntake: taskIntakeForRun(),
|
|
4614
4618
|
stats,
|
|
4615
4619
|
signal: h.signal,
|
|
4616
4620
|
pendingInput,
|
|
@@ -4664,6 +4668,7 @@ program.action(async (opts) => {
|
|
|
4664
4668
|
projectContext,
|
|
4665
4669
|
continuationSession,
|
|
4666
4670
|
executionContext,
|
|
4671
|
+
taskIntake: taskIntakeForRun(),
|
|
4667
4672
|
stats,
|
|
4668
4673
|
signal: h.signal,
|
|
4669
4674
|
pendingInput,
|
|
@@ -4708,6 +4713,7 @@ program.action(async (opts) => {
|
|
|
4708
4713
|
projectContext,
|
|
4709
4714
|
continuationSession,
|
|
4710
4715
|
executionContext,
|
|
4716
|
+
taskIntake: taskIntakeForRun(),
|
|
4711
4717
|
stats,
|
|
4712
4718
|
signal: h.signal,
|
|
4713
4719
|
pendingInput,
|
|
@@ -4797,7 +4803,7 @@ program.action(async (opts) => {
|
|
|
4797
4803
|
currentTurn = skillTurn;
|
|
4798
4804
|
let skillOutcome;
|
|
4799
4805
|
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) });
|
|
4806
|
+
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
4807
|
}
|
|
4802
4808
|
catch (e) {
|
|
4803
4809
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -4865,7 +4871,7 @@ program.action(async (opts) => {
|
|
|
4865
4871
|
const t0 = Date.now();
|
|
4866
4872
|
let turnOutcome;
|
|
4867
4873
|
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) });
|
|
4874
|
+
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
4875
|
}
|
|
4870
4876
|
catch (e) {
|
|
4871
4877
|
out(c.red(`\n[error] ${e.message}\n`));
|
package/dist/mcp/client.js
CHANGED
|
@@ -4,11 +4,12 @@ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
|
|
|
4
4
|
import { registerTool } from "../tools/registry.js";
|
|
5
5
|
import { redactToolSubprocessOutput, toolSubprocessEnv } from "../security/subprocess-env.js";
|
|
6
6
|
import { sensitiveStructuredInputReason } from "../security/sensitive-files.js";
|
|
7
|
-
const clients =
|
|
7
|
+
const clients = new Map();
|
|
8
8
|
const DEFAULT_STARTUP_TIMEOUT_MS = 10_000;
|
|
9
9
|
const MIN_STARTUP_TIMEOUT_MS = 50;
|
|
10
10
|
const MAX_STARTUP_TIMEOUT_MS = 60_000;
|
|
11
11
|
const safeDiagnosticName = (name) => name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 128) || "server";
|
|
12
|
+
const NPM_UNKNOWN_USER_CONFIG_NOISE = /^npm\s+warn\s+Unknown user config "(?:always-auth|home)"(?:\s|\(|\.|$)/i;
|
|
12
13
|
function startupTimeout(value) {
|
|
13
14
|
if (value === undefined || !Number.isFinite(value))
|
|
14
15
|
return DEFAULT_STARTUP_TIMEOUT_MS;
|
|
@@ -60,6 +61,12 @@ function mcpStderrRedactor(name, explicitEnv, log) {
|
|
|
60
61
|
.replace(/\r?\n$/, "");
|
|
61
62
|
if (!clean)
|
|
62
63
|
return;
|
|
64
|
+
// npm 11 prints these host-user `.npmrc` deprecation notices before an npx-backed MCP server writes
|
|
65
|
+
// anything. They are neither server diagnostics nor actionable inside Hara, and repeating them in the
|
|
66
|
+
// TUI makes a healthy MCP startup look broken. Keep the filter deliberately exact: all other npm/MCP
|
|
67
|
+
// warnings and every error still cross the bounded redactor. Never read or rewrite the user's `.npmrc`.
|
|
68
|
+
if (NPM_UNKNOWN_USER_CONFIG_NOISE.test(clean))
|
|
69
|
+
return;
|
|
63
70
|
const message = `mcp: ${safeName} stderr: ${clean}`;
|
|
64
71
|
if (emitted + message.length > totalLimit) {
|
|
65
72
|
log(`mcp: ${safeName} stderr: [further diagnostics omitted]`);
|
|
@@ -124,15 +131,20 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
124
131
|
return 0;
|
|
125
132
|
}
|
|
126
133
|
const timeoutMs = startupTimeout(options.timeoutMs);
|
|
127
|
-
const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs };
|
|
134
|
+
const requestOptions = { timeout: timeoutMs, maxTotalTimeout: timeoutMs, signal: options.signal };
|
|
128
135
|
let count = 0;
|
|
129
136
|
for (const [name, cfg] of Object.entries(servers)) {
|
|
130
137
|
const diagnosticName = safeDiagnosticName(name);
|
|
138
|
+
if (clients.has(name)) {
|
|
139
|
+
log(`mcp: ${diagnosticName} already connected`);
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
131
142
|
let flushStderr;
|
|
132
143
|
let transport;
|
|
133
144
|
let client;
|
|
134
145
|
let stage = "connect";
|
|
135
146
|
try {
|
|
147
|
+
options.signal?.throwIfAborted();
|
|
136
148
|
transport = new StdioClientTransport({
|
|
137
149
|
command: cfg.command,
|
|
138
150
|
args: cfg.args ?? [],
|
|
@@ -152,8 +164,10 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
152
164
|
client = new Client({ name: "hara", version: "0.4.0" }, { capabilities: {} });
|
|
153
165
|
await client.connect(transport, requestOptions);
|
|
154
166
|
stage = "list tools";
|
|
167
|
+
options.signal?.throwIfAborted();
|
|
155
168
|
const activeClient = client;
|
|
156
169
|
const { tools } = await activeClient.listTools(undefined, requestOptions);
|
|
170
|
+
options.signal?.throwIfAborted();
|
|
157
171
|
for (const t of tools) {
|
|
158
172
|
const schema = t.inputSchema ?? { type: "object", properties: {} };
|
|
159
173
|
registerTool({
|
|
@@ -169,7 +183,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
169
183
|
const protectedReason = sensitiveStructuredInputReason(input, ctx.cwd);
|
|
170
184
|
if (protectedReason)
|
|
171
185
|
return `Blocked: MCP input names protected ${protectedReason}.`;
|
|
172
|
-
const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} });
|
|
186
|
+
const res = await activeClient.callTool({ name: t.name, arguments: input ?? {} }, undefined, { signal: ctx.signal });
|
|
173
187
|
const blocks = Array.isArray(res?.content) ? res.content : [];
|
|
174
188
|
const text = blocks.map((b) => (b?.type === "text" ? b.text : JSON.stringify(b))).join("\n");
|
|
175
189
|
return redactToolSubprocessOutput(text || "(no output)", process.env, cfg.env ?? {});
|
|
@@ -177,7 +191,7 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
177
191
|
});
|
|
178
192
|
count++;
|
|
179
193
|
}
|
|
180
|
-
clients.
|
|
194
|
+
clients.set(name, activeClient);
|
|
181
195
|
log(`mcp: ${diagnosticName} → ${tools.length} tool(s)`);
|
|
182
196
|
}
|
|
183
197
|
catch (e) {
|
|
@@ -193,8 +207,60 @@ export async function connectMcpServers(servers, log, options = {}) {
|
|
|
193
207
|
}
|
|
194
208
|
return count;
|
|
195
209
|
}
|
|
210
|
+
/** Register a zero-side-effect launcher for configured MCP servers. The model sees only this bounded index
|
|
211
|
+
* until a relevant task calls it; connecting one server dynamically registers its real tools for the next
|
|
212
|
+
* provider round (runAgent rebuilds tool specs every round). */
|
|
213
|
+
export function registerLazyMcpServers(servers, log) {
|
|
214
|
+
const names = Object.keys(servers);
|
|
215
|
+
if (!names.length)
|
|
216
|
+
return;
|
|
217
|
+
const display = names
|
|
218
|
+
.slice(0, 24)
|
|
219
|
+
.map((name) => safeDiagnosticName(name))
|
|
220
|
+
.join(", ");
|
|
221
|
+
const more = names.length > 24 ? `, … (${names.length - 24} more)` : "";
|
|
222
|
+
registerTool({
|
|
223
|
+
name: "mcp_connect",
|
|
224
|
+
description: "Connect exactly one configured MCP server when the current task first needs it. " +
|
|
225
|
+
"Do not call speculatively: launching it executes reviewed external code outside Hara's protected-file boundary. " +
|
|
226
|
+
`Configured servers: ${display}${more}. After success, its mcp__<server>__* tools appear on the next turn.`,
|
|
227
|
+
input_schema: {
|
|
228
|
+
type: "object",
|
|
229
|
+
properties: {
|
|
230
|
+
server: {
|
|
231
|
+
type: "string",
|
|
232
|
+
enum: names,
|
|
233
|
+
description: "Exact configured server name to connect.",
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
required: ["server"],
|
|
237
|
+
},
|
|
238
|
+
kind: "exec",
|
|
239
|
+
trustBoundary: "external",
|
|
240
|
+
async run(input, ctx) {
|
|
241
|
+
const name = typeof input?.server === "string" ? input.server : "";
|
|
242
|
+
const cfg = servers[name];
|
|
243
|
+
if (!cfg) {
|
|
244
|
+
return `Error: unknown MCP server '${safeDiagnosticName(name)}'. Available: ${display}${more}.`;
|
|
245
|
+
}
|
|
246
|
+
if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
|
|
247
|
+
return ("Blocked: MCP is a trusted extension outside Hara's file boundary and is disabled in " +
|
|
248
|
+
"non-interactive runs. Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only after reviewing the server.");
|
|
249
|
+
}
|
|
250
|
+
if (clients.has(name))
|
|
251
|
+
return `MCP server '${safeDiagnosticName(name)}' is already connected.`;
|
|
252
|
+
const runtimeLog = ctx.ui ? (message) => ctx.ui.notice(message) : log;
|
|
253
|
+
const count = await connectMcpServers({ [name]: cfg }, runtimeLog, { approved: true, signal: ctx.signal });
|
|
254
|
+
if (!clients.has(name)) {
|
|
255
|
+
return `Error: MCP server '${safeDiagnosticName(name)}' failed to connect. Review its redacted startup diagnostics.`;
|
|
256
|
+
}
|
|
257
|
+
return (`Connected MCP server '${safeDiagnosticName(name)}'; ${count} tool(s) are now available under ` +
|
|
258
|
+
`mcp__${safeDiagnosticName(name)}__*. Continue with the specific tool needed for the task.`);
|
|
259
|
+
},
|
|
260
|
+
});
|
|
261
|
+
}
|
|
196
262
|
export async function closeMcp() {
|
|
197
|
-
for (const cl of clients) {
|
|
263
|
+
for (const cl of clients.values()) {
|
|
198
264
|
try {
|
|
199
265
|
await cl.close();
|
|
200
266
|
}
|
|
@@ -202,5 +268,5 @@ export async function closeMcp() {
|
|
|
202
268
|
/* ignore */
|
|
203
269
|
}
|
|
204
270
|
}
|
|
205
|
-
clients.
|
|
271
|
+
clients.clear();
|
|
206
272
|
}
|
package/dist/providers/models.js
CHANGED
|
@@ -1,21 +1,54 @@
|
|
|
1
|
+
// Alibaba Coding Plan's documented exact ids (verified 2026-07-18). Live `/models` remains authoritative;
|
|
2
|
+
// this list is only a usability fallback because the coding endpoint/key combinations do not all enumerate.
|
|
3
|
+
// Keep exact casing: Coding Plan explicitly forbids guessing compatible/version-like aliases.
|
|
4
|
+
export const CODING_PLAN_FALLBACK_MODELS = Object.freeze([
|
|
5
|
+
"qwen3.7-plus",
|
|
6
|
+
"qwen3.6-plus",
|
|
7
|
+
"kimi-k2.5",
|
|
8
|
+
"glm-5",
|
|
9
|
+
"MiniMax-M2.5",
|
|
10
|
+
"qwen3.5-plus",
|
|
11
|
+
"qwen3-max-2026-01-23",
|
|
12
|
+
"qwen3-coder-next",
|
|
13
|
+
"qwen3-coder-plus",
|
|
14
|
+
"glm-4.7",
|
|
15
|
+
]);
|
|
16
|
+
export function codingPlanFallbackModels(baseURL) {
|
|
17
|
+
if (!baseURL)
|
|
18
|
+
return [];
|
|
19
|
+
try {
|
|
20
|
+
const host = new URL(baseURL).hostname.toLowerCase();
|
|
21
|
+
return host === "coding.dashscope.aliyuncs.com" || host === "coding-intl.dashscope.aliyuncs.com"
|
|
22
|
+
? [...CODING_PLAN_FALLBACK_MODELS]
|
|
23
|
+
: [];
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return [];
|
|
27
|
+
}
|
|
28
|
+
}
|
|
1
29
|
// Model discovery — "what can this key run?" A coding-plan / OpenAI-compatible key usually exposes many
|
|
2
30
|
// models (Qwen, GLM, Kimi, …) via `GET {baseURL}/models`; the /model picker lists them so you switch by
|
|
3
|
-
// arrow keys, not by memorizing ids.
|
|
4
|
-
//
|
|
31
|
+
// arrow keys, not by memorizing ids. Live results win. A bounded request falls back to Alibaba's documented
|
|
32
|
+
// exact Coding Plan ids only on the two official coding hosts; other endpoints keep the existing [] →
|
|
33
|
+
// type-an-id behavior. `fetchImpl` is injected so this stays pure/testable.
|
|
5
34
|
export async function listModels(baseURL, apiKey, fetchImpl = fetch) {
|
|
6
35
|
if (!baseURL)
|
|
7
36
|
return []; // SDK-default hosts (anthropic/openai) — no custom endpoint to enumerate
|
|
37
|
+
const fallback = codingPlanFallbackModels(baseURL);
|
|
8
38
|
try {
|
|
9
39
|
const url = baseURL.replace(/\/+$/, "") + "/models";
|
|
10
|
-
const
|
|
40
|
+
const headers = {};
|
|
41
|
+
if (apiKey)
|
|
42
|
+
headers.Authorization = `Bearer ${apiKey}`;
|
|
43
|
+
const r = await fetchImpl(url, { headers, signal: AbortSignal.timeout(5_000) });
|
|
11
44
|
if (!r.ok)
|
|
12
|
-
return
|
|
45
|
+
return fallback;
|
|
13
46
|
const j = (await r.json());
|
|
14
47
|
const ids = (j?.data ?? []).map((m) => m?.id).filter((x) => typeof x === "string" && x.length > 0);
|
|
15
48
|
// Stable order + de-dup so the picker list doesn't jump around between opens.
|
|
16
|
-
return [...new Set(ids)].sort((a, b) => a.localeCompare(b));
|
|
49
|
+
return ids.length ? [...new Set(ids)].sort((a, b) => a.localeCompare(b)) : fallback;
|
|
17
50
|
}
|
|
18
51
|
catch {
|
|
19
|
-
return
|
|
52
|
+
return fallback;
|
|
20
53
|
}
|
|
21
54
|
}
|
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
export function isReasoningModel(model) {
|
|
9
9
|
return /^(o1|o3|o4|gpt-5)/i.test(model);
|
|
10
10
|
}
|
|
11
|
+
/** Endpoint capability is not enough: Alibaba's Coding Plan serves qwen3-coder-next/plus on the same
|
|
12
|
+
* DashScope URL as thinking models, but documents both coder ids as not supporting thinking mode. */
|
|
13
|
+
export function supportsReasoningStyle(style, model = "") {
|
|
14
|
+
if (style === "none")
|
|
15
|
+
return false;
|
|
16
|
+
if (style === "enable_thinking" && /^qwen3-coder-(?:next|plus)(?:-|$)/i.test(model.split("/").at(-1) ?? model)) {
|
|
17
|
+
return false;
|
|
18
|
+
}
|
|
19
|
+
return true;
|
|
20
|
+
}
|
|
11
21
|
/** Translate the dial into request params to MERGE into the wire body (chat/responses styles). Returns an
|
|
12
22
|
* empty object — leave the request untouched — when the dial is UNSET (keep the model's own default; zero
|
|
13
23
|
* impact, the safe default) or the style/model has nothing to add. Anthropic's `thinking_budget` is built
|
|
@@ -17,6 +27,8 @@ export function reasoningParams(style, effort, model = "") {
|
|
|
17
27
|
return {};
|
|
18
28
|
switch (style) {
|
|
19
29
|
case "enable_thinking":
|
|
30
|
+
if (!supportsReasoningStyle(style, model))
|
|
31
|
+
return {};
|
|
20
32
|
// off → false (stop the thinking phase, fast); any explicit level → true (keep it on).
|
|
21
33
|
return { enable_thinking: effort !== "off" };
|
|
22
34
|
case "ollama_think":
|
package/dist/serve/server.js
CHANGED
|
@@ -239,11 +239,11 @@ export async function startServe(opts, deps) {
|
|
|
239
239
|
const token = opts.token ?? randomBytes(16).toString("hex");
|
|
240
240
|
const instanceId = randomUUID();
|
|
241
241
|
const hub = new SessionHub(deps.store ?? realStore);
|
|
242
|
-
const runtimeInfo = (cwd) => {
|
|
243
|
-
const live = deps.runtimeInfo?.(cwd);
|
|
242
|
+
const runtimeInfo = (cwd, model) => {
|
|
243
|
+
const live = deps.runtimeInfo?.(cwd, model);
|
|
244
244
|
return {
|
|
245
245
|
providerId: live?.providerId ?? deps.providerId,
|
|
246
|
-
model: live?.model ?? deps.model,
|
|
246
|
+
model: live?.model ?? model ?? deps.model,
|
|
247
247
|
effortLevels: live?.effortLevels ?? deps.effortLevels ?? [],
|
|
248
248
|
};
|
|
249
249
|
};
|
|
@@ -429,6 +429,17 @@ export async function startServe(opts, deps) {
|
|
|
429
429
|
memory: memoryDigest(s.meta.cwd),
|
|
430
430
|
continuationSession: s.continuationSession,
|
|
431
431
|
executionContext,
|
|
432
|
+
taskIntake: {
|
|
433
|
+
task: s.task,
|
|
434
|
+
current: () => s.task,
|
|
435
|
+
onUpdate: (next) => {
|
|
436
|
+
s.task = next;
|
|
437
|
+
},
|
|
438
|
+
onCheckpoint: (next) => {
|
|
439
|
+
s.task = next;
|
|
440
|
+
hub.save(s);
|
|
441
|
+
},
|
|
442
|
+
},
|
|
432
443
|
pendingInput: async () => {
|
|
433
444
|
materializePendingSteering(s); // helper updates the shared live history after its write-ahead save
|
|
434
445
|
return [];
|
|
@@ -810,8 +821,10 @@ export async function startServe(opts, deps) {
|
|
|
810
821
|
const session = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
|
|
811
822
|
const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (session?.meta.cwd ?? opts.cwd);
|
|
812
823
|
const models = deps.listModels ? await deps.listModels(targetCwd).catch(() => []) : [];
|
|
813
|
-
const
|
|
814
|
-
|
|
824
|
+
const defaultRuntime = runtimeInfo(targetCwd);
|
|
825
|
+
const current = session?.meta.model ?? defaultRuntime.model;
|
|
826
|
+
const currentRuntime = runtimeInfo(targetCwd, current);
|
|
827
|
+
return reply(rpcResult(id, { models, current, effort: session?.effort ?? null, effortLevels: currentRuntime.effortLevels }));
|
|
815
828
|
}
|
|
816
829
|
case "session.set-model": {
|
|
817
830
|
// per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
|