@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/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 { 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 })),
@@ -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
- // A plugin can ship CLI commands (manifest `bin`); they're linked into ~/.hara/bin. Tell the user to PATH it.
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(":").includes(haraBinDir());
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
- // Connecting to MCP executes configured external commands before an MCP tool is selected, so the ordinary
2913
- // per-tool confirmation is too late. Obtain one explicit startup grant for the named server set. askConfirm
2914
- // uses a short-lived Ink prompt and restores stdin, making it safe before either the main TUI or readline REPL
2915
- // 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.
2916
2919
  const mcpAll = { ...pluginMcpServers(), ...cfg.mcpServers }; // user config wins over plugin-contributed servers
2917
- const mcpNames = Object.keys(mcpAll);
2918
- if (!requestedHeadlessRole?.readOnly && mcpNames.length) {
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`));
@@ -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":
@@ -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.
@@ -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