@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/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 { connectMcpServers, closeMcp } from "./mcp/client.js";
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: live.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
- // Connecting to MCP executes configured external commands before an MCP tool is selected, so the ordinary
2915
- // per-tool confirmation is too late. Obtain one explicit startup grant for the named server set. askConfirm
2916
- // uses a short-lived Ink prompt and restores stdin, making it safe before either the main TUI or readline REPL
2917
- // owns the terminal. Non-interactive runs remain closed unless the user opted in before process launch.
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
- const mcpNames = Object.keys(mcpAll);
2920
- if (!requestedHeadlessRole?.readOnly && mcpNames.length) {
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`));
@@ -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.push(activeClient);
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.length = 0;
271
+ clients.clear();
206
272
  }
@@ -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. Best-effort: many endpoints don't implement it [] and the picker
4
- // falls back to typing an id. `fetchImpl` is injected so this stays pure/testable.
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 r = await fetchImpl(url, { headers: { Authorization: `Bearer ${apiKey}` } });
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":
@@ -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 runtime = runtimeInfo(targetCwd);
814
- return reply(rpcResult(id, { models, current: session?.meta.model ?? runtime.model, effort: session?.effort ?? null, effortLevels: runtime.effortLevels }));
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