@nanhara/hara 0.125.0 → 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 +47 -0
- package/README.md +16 -5
- package/SECURITY.md +6 -4
- package/dist/agent/loop.js +175 -11
- package/dist/index.js +87 -79
- package/dist/mcp/client.js +72 -6
- package/dist/providers/models.js +39 -6
- package/dist/providers/reasoning.js +12 -0
- package/dist/security/subprocess-env.js +30 -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
|
@@ -13,7 +13,7 @@ import { readFileSync, existsSync, realpathSync, statSync, writeFileSync, rmSync
|
|
|
13
13
|
import { homedir, tmpdir } from "node:os";
|
|
14
14
|
import { fileURLToPath } from "node:url";
|
|
15
15
|
import { randomUUID } from "node:crypto";
|
|
16
|
-
import { dirname, join, relative, resolve } from "node:path";
|
|
16
|
+
import { delimiter, dirname, join, relative, resolve } from "node:path";
|
|
17
17
|
import { loadConfig, configPath, readRawConfig, writeConfigValue, setModelVisionOverride, providerEnvKey, providerDefaultBaseURL, 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";
|
|
@@ -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 })),
|
|
@@ -2655,11 +2656,13 @@ pluginCmd
|
|
|
2655
2656
|
m.mcpServers ? `${Object.keys(m.mcpServers).length} mcp server(s)` : "",
|
|
2656
2657
|
].filter(Boolean);
|
|
2657
2658
|
out(c.green(`Installed ${p.name}@${p.version}${parts.length ? c.dim(" — " + parts.join(", ")) : ""}\n`));
|
|
2658
|
-
//
|
|
2659
|
+
// Hara's model-controlled subprocesses append this directory automatically. The user's independent
|
|
2660
|
+
// interactive shell still needs its own PATH entry when they want to invoke the command directly.
|
|
2659
2661
|
const bins = Object.keys(m.bin ?? {});
|
|
2660
2662
|
if (bins.length) {
|
|
2661
|
-
const onPath = (process.env.PATH ?? "").split(
|
|
2663
|
+
const onPath = (process.env.PATH ?? "").split(delimiter).includes(haraBinDir());
|
|
2662
2664
|
out(c.green(`Linked command(s): ${bins.join(", ")} → ${c.dim(haraBinDir())}\n`));
|
|
2665
|
+
out(c.dim(" available to tool commands started inside Hara automatically\n"));
|
|
2663
2666
|
if (!onPath)
|
|
2664
2667
|
out(c.yellow(` add to PATH once: echo 'export PATH="$HOME/.hara/bin:$PATH"' >> ~/.zshrc && source ~/.zshrc\n`));
|
|
2665
2668
|
}
|
|
@@ -2909,35 +2912,13 @@ program.action(async (opts) => {
|
|
|
2909
2912
|
out(c.yellow(message));
|
|
2910
2913
|
}
|
|
2911
2914
|
const stats = { input: 0, output: 0, lastInput: 0 };
|
|
2912
|
-
//
|
|
2913
|
-
//
|
|
2914
|
-
//
|
|
2915
|
-
//
|
|
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.
|
|
2916
2919
|
const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
|
|
2917
|
-
|
|
2918
|
-
|
|
2919
|
-
const trustedOptIn = process.env.HARA_ALLOW_TRUSTED_EXTENSIONS === "1";
|
|
2920
|
-
const interactiveTerminal = !opts.print && !!stdin.isTTY && !!stdout.isTTY;
|
|
2921
|
-
const approved = trustedOptIn ||
|
|
2922
|
-
(interactiveTerminal &&
|
|
2923
|
-
(await askConfirm(`Start configured MCP server${mcpNames.length === 1 ? "" : "s"} ${mcpNames
|
|
2924
|
-
.slice(0, 8)
|
|
2925
|
-
.map((name) => `'${name.replace(/[\u0000-\u001f\u007f]/g, "").slice(0, 80)}'`)
|
|
2926
|
-
.join(", ")}${mcpNames.length > 8 ? ` and ${mcpNames.length - 8} more` : ""}? ` +
|
|
2927
|
-
"They execute external code outside Hara's protected-file boundary.", false)));
|
|
2928
|
-
if (approved) {
|
|
2929
|
-
await connectMcpServers(mcpAll, (m) => machineOutput ? process.stderr.write(m + "\n") : out(c.dim(m + "\n")), { approved: true });
|
|
2930
|
-
}
|
|
2931
|
-
else {
|
|
2932
|
-
const message = interactiveTerminal
|
|
2933
|
-
? "hara: MCP server startup declined; no configured MCP command was executed.\n"
|
|
2934
|
-
: "hara: MCP servers skipped because this run has no interactive startup approval. " +
|
|
2935
|
-
"Set HARA_ALLOW_TRUSTED_EXTENSIONS=1 before launch only for reviewed servers.\n";
|
|
2936
|
-
if (machineOutput || !interactiveTerminal)
|
|
2937
|
-
process.stderr.write(message);
|
|
2938
|
-
else
|
|
2939
|
-
out(c.dim(message));
|
|
2940
|
-
}
|
|
2920
|
+
if (!requestedHeadlessRole?.readOnly && Object.keys(mcpAll).length) {
|
|
2921
|
+
registerLazyMcpServers(mcpAll, (message) => machineOutput ? process.stderr.write(message + "\n") : out(c.dim(message + "\n")));
|
|
2941
2922
|
}
|
|
2942
2923
|
// one-shot
|
|
2943
2924
|
if (opts.print) {
|
|
@@ -3198,6 +3179,18 @@ program.action(async (opts) => {
|
|
|
3198
3179
|
memory: memoryDigest(cwd),
|
|
3199
3180
|
continuationSession,
|
|
3200
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
|
+
},
|
|
3201
3194
|
...(roleOverride ? { systemOverride: roleOverride } : {}),
|
|
3202
3195
|
...(headlessToolFilter ? { toolFilter: headlessToolFilter } : {}),
|
|
3203
3196
|
hooks: headlessHooks,
|
|
@@ -3445,6 +3438,19 @@ program.action(async (opts) => {
|
|
|
3445
3438
|
}
|
|
3446
3439
|
const history = resumed?.history ? [...resumed.history] : [];
|
|
3447
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;
|
|
3448
3454
|
let requestedWorkspaceSwitch = null;
|
|
3449
3455
|
let requestedSessionSwitch = null;
|
|
3450
3456
|
const relaunchRequestedTarget = async () => {
|
|
@@ -4087,6 +4093,7 @@ program.action(async (opts) => {
|
|
|
4087
4093
|
initialStatus: { sessionName: meta.title || shortId(meta.id), approval, input: stats.input, output: stats.output, ctxPct: 0, agents: 0 },
|
|
4088
4094
|
model: cfg.model,
|
|
4089
4095
|
cwd,
|
|
4096
|
+
agentSlashCommands: loadSkillIndex(cwd).filter((skill) => skill.userInvocable).map((skill) => skill.id),
|
|
4090
4097
|
header: {
|
|
4091
4098
|
version: pkg.version,
|
|
4092
4099
|
modelLabel: `${cfg.provider}:${cfg.model}`,
|
|
@@ -4125,6 +4132,41 @@ program.action(async (opts) => {
|
|
|
4125
4132
|
turnId: interaction?.turnId ?? newTurnInteraction().turnId,
|
|
4126
4133
|
};
|
|
4127
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
|
+
};
|
|
4128
4170
|
// A dropped/pasted file path (`/Users/…/doc.md`, maybe trailing text/images) starts with '/' but
|
|
4129
4171
|
// is NOT a command — treat it as a file to read, not "Unknown command" (see isSlashCommand).
|
|
4130
4172
|
if (isSlashCommand(line)) {
|
|
@@ -4459,7 +4501,7 @@ program.action(async (opts) => {
|
|
|
4459
4501
|
clearTodos();
|
|
4460
4502
|
meta.todos = [];
|
|
4461
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.`;
|
|
4462
|
-
const skillInteraction = interaction ?? newTurnInteraction();
|
|
4504
|
+
const skillInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction()).interaction;
|
|
4463
4505
|
if (skillInteraction.kind === "steer") {
|
|
4464
4506
|
const continued = continueTaskExecution(task, skillInteraction);
|
|
4465
4507
|
if (!continued.ok)
|
|
@@ -4480,7 +4522,7 @@ program.action(async (opts) => {
|
|
|
4480
4522
|
const __skApproval = h.approval === "plan" ? "suggest" : h.approval;
|
|
4481
4523
|
let skillOutcome;
|
|
4482
4524
|
try {
|
|
4483
|
-
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) });
|
|
4484
4526
|
}
|
|
4485
4527
|
catch (e) {
|
|
4486
4528
|
h.sink.notice(`[error] ${e?.message ?? e}`);
|
|
@@ -4504,7 +4546,7 @@ program.action(async (opts) => {
|
|
|
4504
4546
|
line = inlineLeadingPath(line, existsSync);
|
|
4505
4547
|
const ui = { text: h.sink.assistantDelta, reasoning: h.sink.reasoningDelta, tool: h.sink.tool, diff: h.sink.diff, notice: h.sink.notice };
|
|
4506
4548
|
const appr = h.approval;
|
|
4507
|
-
let submittedInteraction = interaction ?? newTurnInteraction();
|
|
4549
|
+
let submittedInteraction = routeTaskInteraction(task, interaction ?? newTurnInteraction(), { allowInactive: !!continueCommand }).interaction;
|
|
4508
4550
|
if (resumeTaskPending && task && submittedInteraction.kind === "turn" &&
|
|
4509
4551
|
(hasPendingTaskSteering(task) || requestsTaskContinuation(line))) {
|
|
4510
4552
|
submittedInteraction = { kind: "steer", expectedTurnId: task.turnId, turnId: submittedInteraction.turnId };
|
|
@@ -4526,43 +4568,6 @@ program.action(async (opts) => {
|
|
|
4526
4568
|
resumeTaskPending = false;
|
|
4527
4569
|
return taskExecutionContext(task, submittedInteraction, meta.todos ?? []);
|
|
4528
4570
|
};
|
|
4529
|
-
// Type-ahead steering: fold messages typed mid-turn into the next model call (codex-style) so a
|
|
4530
|
-
// clarification/addition course-corrects the live task, rather than waiting for a fresh turn.
|
|
4531
|
-
// Shared by every turn below (plan investigate, plan execute, and the regular turn).
|
|
4532
|
-
const pendingInput = async () => {
|
|
4533
|
-
const freshMessages = new Map();
|
|
4534
|
-
for (const it of h.drainQueue()) {
|
|
4535
|
-
const r2 = await resolveImages(it.images, h);
|
|
4536
|
-
const body = await expandMentionsAsync(it.line, cwd, { signal: h.signal }) + (r2.skip ? "" : (r2.extraText ?? ""));
|
|
4537
|
-
const attach = !r2.skip && r2.attach?.length ? r2.attach : undefined;
|
|
4538
|
-
if (!body.trim() && !attach)
|
|
4539
|
-
continue; // image-only message whose image was skipped → nothing to add
|
|
4540
|
-
const recorded = recordTaskSteering(task, it.expectedTurnId, body || "(image-only steering)");
|
|
4541
|
-
if (!recorded.ok) {
|
|
4542
|
-
h.sink.notice(`(steer rejected: ${recorded.reason})`);
|
|
4543
|
-
continue;
|
|
4544
|
-
}
|
|
4545
|
-
task = recorded.task;
|
|
4546
|
-
const accepted = task.steering?.at(-1);
|
|
4547
|
-
if (accepted?.deliveryState === "pending") {
|
|
4548
|
-
freshMessages.set(accepted.id, { role: "user", content: `${INTERJECT_PREFIX}\n\n${body}`, ...(attach ? { images: attach } : {}) });
|
|
4549
|
-
}
|
|
4550
|
-
}
|
|
4551
|
-
const consumed = consumePendingTaskSteering(task);
|
|
4552
|
-
if (!consumed)
|
|
4553
|
-
return [];
|
|
4554
|
-
const out = consumed.entries.map((entry) => freshMessages.get(entry.id) ?? ({
|
|
4555
|
-
role: "user",
|
|
4556
|
-
content: `${INTERJECT_PREFIX}\n\n${entry.content}`,
|
|
4557
|
-
}));
|
|
4558
|
-
// Persist the projected transcript and consumed inbox state first, then update the same live
|
|
4559
|
-
// history synchronously. Returning [] prevents runAgent from appending the messages twice;
|
|
4560
|
-
// loop from appending twice; cancellation/error saves can no longer overwrite the projected input.
|
|
4561
|
-
saveSession(meta, [...history, ...out], consumed.task);
|
|
4562
|
-
task = consumed.task;
|
|
4563
|
-
history.push(...out);
|
|
4564
|
-
return [];
|
|
4565
|
-
};
|
|
4566
4571
|
const turnStart = Date.now(); // for the task-done notification (gated on elapsed)
|
|
4567
4572
|
if (appr === "plan") {
|
|
4568
4573
|
// PLAN MODE: read-only investigate; the MODEL signals plan-readiness by calling `exit_plan`
|
|
@@ -4609,6 +4614,7 @@ program.action(async (opts) => {
|
|
|
4609
4614
|
projectContext,
|
|
4610
4615
|
continuationSession,
|
|
4611
4616
|
executionContext,
|
|
4617
|
+
taskIntake: taskIntakeForRun(),
|
|
4612
4618
|
stats,
|
|
4613
4619
|
signal: h.signal,
|
|
4614
4620
|
pendingInput,
|
|
@@ -4662,6 +4668,7 @@ program.action(async (opts) => {
|
|
|
4662
4668
|
projectContext,
|
|
4663
4669
|
continuationSession,
|
|
4664
4670
|
executionContext,
|
|
4671
|
+
taskIntake: taskIntakeForRun(),
|
|
4665
4672
|
stats,
|
|
4666
4673
|
signal: h.signal,
|
|
4667
4674
|
pendingInput,
|
|
@@ -4706,6 +4713,7 @@ program.action(async (opts) => {
|
|
|
4706
4713
|
projectContext,
|
|
4707
4714
|
continuationSession,
|
|
4708
4715
|
executionContext,
|
|
4716
|
+
taskIntake: taskIntakeForRun(),
|
|
4709
4717
|
stats,
|
|
4710
4718
|
signal: h.signal,
|
|
4711
4719
|
pendingInput,
|
|
@@ -4795,7 +4803,7 @@ program.action(async (opts) => {
|
|
|
4795
4803
|
currentTurn = skillTurn;
|
|
4796
4804
|
let skillOutcome;
|
|
4797
4805
|
try {
|
|
4798
|
-
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) });
|
|
4799
4807
|
}
|
|
4800
4808
|
catch (e) {
|
|
4801
4809
|
out(c.red(`\n[error] ${e.message}\n`));
|
|
@@ -4863,7 +4871,7 @@ program.action(async (opts) => {
|
|
|
4863
4871
|
const t0 = Date.now();
|
|
4864
4872
|
let turnOutcome;
|
|
4865
4873
|
try {
|
|
4866
|
-
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) });
|
|
4867
4875
|
}
|
|
4868
4876
|
catch (e) {
|
|
4869
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":
|
|
@@ -3,7 +3,10 @@
|
|
|
3
3
|
// A user can explicitly pass selected names with HARA_SUBPROCESS_ENV_ALLOW=NAME,OTHER before launching Hara.
|
|
4
4
|
import { redactSensitiveText } from "./secrets.js";
|
|
5
5
|
import { spawn } from "node:child_process";
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
6
7
|
import { platform } from "node:os";
|
|
8
|
+
import { delimiter, join } from "node:path";
|
|
9
|
+
import { normalizePortableWindowsHome } from "../runtime.js";
|
|
7
10
|
const SECRET_NAME = /(?:^|_)(?:API_?KEY|KEY|SECRET|TOKEN|PASSWORD|PASSWD|CREDENTIALS?|COOKIE|JWT|AUTH)(?:_|$)/i;
|
|
8
11
|
const SECRET_EXACT = new Set([
|
|
9
12
|
"DATABASE_URL",
|
|
@@ -27,6 +30,32 @@ function explicitAllow(env) {
|
|
|
27
30
|
.filter((name) => /^[A-Za-z_][A-Za-z0-9_]*$/.test(name))
|
|
28
31
|
.map((name) => name.toUpperCase()));
|
|
29
32
|
}
|
|
33
|
+
function environmentValue(env, name) {
|
|
34
|
+
const key = Object.keys(env).find((candidate) => candidate.toUpperCase() === name);
|
|
35
|
+
return key ? env[key] : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** Installed plugin commands are user-approved Hara extensions. Make them reachable by model-controlled
|
|
38
|
+
* subprocesses without allowing them to shadow an existing system/project command: ~/.hara/bin is appended,
|
|
39
|
+
* never prepended. The interactive shell still owns its own PATH configuration. */
|
|
40
|
+
function appendHaraPluginBin(env) {
|
|
41
|
+
const explicitHome = environmentValue(env, "HOME");
|
|
42
|
+
const fallbackHome = environmentValue(env, "USERPROFILE");
|
|
43
|
+
// Match Hara's portable-home contract: an explicit Git Bash/MSYS HOME wins on Windows too.
|
|
44
|
+
const home = platform() === "win32" && explicitHome
|
|
45
|
+
? normalizePortableWindowsHome(explicitHome)
|
|
46
|
+
: explicitHome ?? fallbackHome;
|
|
47
|
+
if (!home)
|
|
48
|
+
return;
|
|
49
|
+
const pluginBin = join(home, ".hara", "bin");
|
|
50
|
+
if (!existsSync(pluginBin))
|
|
51
|
+
return;
|
|
52
|
+
const pathKey = Object.keys(env).find((candidate) => candidate.toUpperCase() === "PATH") ?? "PATH";
|
|
53
|
+
const current = env[pathKey] ?? "";
|
|
54
|
+
const comparable = (value) => platform() === "win32" ? value.toLowerCase() : value;
|
|
55
|
+
if (current.split(delimiter).some((entry) => comparable(entry) === comparable(pluginBin)))
|
|
56
|
+
return;
|
|
57
|
+
env[pathKey] = current ? `${current}${delimiter}${pluginBin}` : pluginBin;
|
|
58
|
+
}
|
|
30
59
|
/** Build an own copy so callers never mutate process.env. Explicit overrides (for example an MCP server's
|
|
31
60
|
* configured env) are intentional and win after the inherited environment has been scrubbed. */
|
|
32
61
|
export function toolSubprocessEnv(source = process.env, overrides = {}) {
|
|
@@ -48,6 +77,7 @@ export function toolSubprocessEnv(source = process.env, overrides = {}) {
|
|
|
48
77
|
else
|
|
49
78
|
out[name] = value;
|
|
50
79
|
}
|
|
80
|
+
appendHaraPluginBin(out);
|
|
51
81
|
return out;
|
|
52
82
|
}
|
|
53
83
|
/** Redact both recognizable credential syntax and the exact values of secret-named environment variables.
|
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
|