@nanhara/hara 0.125.3 → 0.126.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -175,6 +175,7 @@ export async function connectMcpServers(servers, log, options = {}) {
175
175
  description: t.description ?? `${name}/${t.name}`,
176
176
  input_schema: schema,
177
177
  kind: "exec",
178
+ visibility: "deferred",
178
179
  trustBoundary: "external",
179
180
  async run(input, ctx) {
180
181
  if (!ctx.ask && process.env.HARA_ALLOW_TRUSTED_EXTENSIONS !== "1") {
@@ -33,6 +33,12 @@ export async function boundedProviderTurn(provider, args, options) {
33
33
  // microtask starts. Re-check here so an already-cancelled auxiliary call never incurs a request/cost.
34
34
  .then(() => controller.signal.aborted ? errorResult(`${label} cancelled`) : provider.turn({ ...args, signal: controller.signal }))
35
35
  .catch((error) => errorResult(error instanceof Error ? error.message : String(error)));
36
+ try {
37
+ options.onProviderTurn?.(turn);
38
+ }
39
+ catch {
40
+ // Observability must never weaken the provider boundary.
41
+ }
36
42
  const result = await Promise.race([turn, stopped]);
37
43
  clearTimeout(timer);
38
44
  parent?.removeEventListener("abort", onParentAbort);
@@ -0,0 +1,54 @@
1
+ // Shared provider construction for the interactive CLI, Desktop serve, gateway approval/judge calls, and
2
+ // connection tests. Every path must interpret auth:none/OAuth/wire-protocol targets identically.
3
+ import { providerIsLocal } from "../config.js";
4
+ import { getValidQwenAuth } from "./qwen-oauth.js";
5
+ import { createAnthropicProvider } from "./anthropic.js";
6
+ import { createOpenAIProvider } from "./openai.js";
7
+ import { resolvePlatform } from "./registry.js";
8
+ export async function createProviderForTarget(target, reasoningEffort) {
9
+ const { provider, apiKey, model, baseURL } = target;
10
+ if (provider === "qwen-oauth") {
11
+ const auth = await getValidQwenAuth();
12
+ if (!auth)
13
+ return null;
14
+ return createOpenAIProvider({
15
+ apiKey: auth.accessToken,
16
+ baseURL: auth.baseURL,
17
+ model,
18
+ label: provider,
19
+ reasoningEffort,
20
+ });
21
+ }
22
+ // The OpenAI SDK requires a non-empty constructor value even when a compatible local endpoint has no
23
+ // authentication. This sentinel never leaves the process for cloud targets and local targets have already
24
+ // discarded all user credentials at target resolution.
25
+ const transportKey = apiKey ?? (providerIsLocal(provider) ? "hara-local-no-secret" : undefined);
26
+ if (!transportKey)
27
+ return null;
28
+ const wire = resolvePlatform(provider, baseURL).wireApi;
29
+ if (wire === "anthropic") {
30
+ return createAnthropicProvider({ apiKey: transportKey, model, baseURL, reasoningEffort });
31
+ }
32
+ if (wire === "responses") {
33
+ return {
34
+ id: provider,
35
+ model,
36
+ async turn() {
37
+ return {
38
+ text: "",
39
+ toolUses: [],
40
+ stop: "error",
41
+ errorMsg: "This endpoint uses the OpenAI Responses API, which hara doesn't speak yet. Point hara at an OpenAI-compatible chat endpoint (…/compatible-mode/v1 or …/v1) or an Anthropic-compatible endpoint (…/apps/anthropic).",
42
+ };
43
+ },
44
+ };
45
+ }
46
+ return createOpenAIProvider({
47
+ apiKey: transportKey,
48
+ model,
49
+ baseURL,
50
+ label: provider,
51
+ reasoningEffort,
52
+ omitAuthorization: providerIsLocal(provider),
53
+ });
54
+ }
@@ -80,7 +80,12 @@ export function toOpenAI(system, history) {
80
80
  }
81
81
  /** OpenAI-compatible provider (works with OpenAI, Qwen/DashScope, GLM, Kimi, …). */
82
82
  export function createOpenAIProvider(opts) {
83
- const client = new OpenAI({ apiKey: opts.apiKey, maxRetries: 4, ...(opts.baseURL ? { baseURL: opts.baseURL } : {}) });
83
+ const client = new OpenAI({
84
+ apiKey: opts.apiKey,
85
+ maxRetries: 4,
86
+ ...(opts.baseURL ? { baseURL: opts.baseURL } : {}),
87
+ ...(opts.omitAuthorization ? { defaultHeaders: { Authorization: null } } : {}),
88
+ });
84
89
  return {
85
90
  id: opts.label ?? "openai",
86
91
  model: opts.model,
@@ -32,6 +32,7 @@ const BY_PROVIDER = {
32
32
  glm: { wireApi: "chat", reasoning: "none", cache: "auto" }, // Zhipu native /paas/v4 — different thinking param; leave alone (its /anthropic endpoint resolves via baseURL)
33
33
  deepseek: { wireApi: "chat", reasoning: "deepseek", cache: "auto" }, // V4: thinking:{type} + reasoning_effort(high|max)
34
34
  ollama: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local; `think` toggles reasoning
35
+ lmstudio: { wireApi: "chat", reasoning: "ollama_think", cache: "none" }, // local OpenAI-compatible server
35
36
  openai: { wireApi: "chat", reasoning: "reasoning_effort", cache: "auto" },
36
37
  openrouter: { wireApi: "chat", reasoning: "none", cache: "auto" },
37
38
  "hara-gateway": { wireApi: "chat", reasoning: "none", cache: "auto" },
@@ -0,0 +1,98 @@
1
+ import { providerDefaultBaseURL, providerDefaultModel, providerEnvKey, isProviderId, providerIsLocal, } from "../config.js";
2
+ import { getProfile, PERSONAL_ID, resolveActive, } from "../profile/profile.js";
3
+ /** Resolve the identity profile for the same cwd used to load config. */
4
+ export function profileForConfig(cfg) {
5
+ const resolution = resolveActive(cfg.cwd);
6
+ if (resolution.id !== PERSONAL_ID) {
7
+ const profile = getProfile(resolution.id);
8
+ if (profile)
9
+ return { profile, resolution };
10
+ }
11
+ return {
12
+ resolution: resolution.id === PERSONAL_ID
13
+ ? resolution
14
+ : { id: PERSONAL_ID, source: "fallback" },
15
+ profile: {
16
+ id: PERSONAL_ID,
17
+ kind: "byok",
18
+ label: "Personal",
19
+ provider: cfg.provider,
20
+ apiKey: cfg.apiKey,
21
+ baseURL: cfg.baseURL,
22
+ defaultModel: cfg.model,
23
+ },
24
+ };
25
+ }
26
+ /**
27
+ * Resolve one BYOK/local transport target.
28
+ *
29
+ * A named Profile is an identity boundary: its vendor, credential and endpoint must not be replaced by
30
+ * the always-populated personal/global config. Explicit HARA_* values remain one-shot overrides. The
31
+ * personal profile and explicit sidecars continue to use the merged config.
32
+ */
33
+ export function resolveByokProviderTarget(cfg, profile, sidecarOverride, env = process.env) {
34
+ const personalOrOverride = profile.id === "personal" || sidecarOverride;
35
+ const profileProvider = profile.provider && profile.provider !== "hara-gateway" ? profile.provider : "anthropic";
36
+ const environmentProvider = isProviderId(env.HARA_PROVIDER) && env.HARA_PROVIDER !== "hara-gateway"
37
+ ? env.HARA_PROVIDER
38
+ : undefined;
39
+ const provider = personalOrOverride
40
+ ? (cfg.provider !== "hara-gateway" ? cfg.provider : profileProvider)
41
+ : environmentProvider ?? profileProvider;
42
+ const namedProviderChanged = !personalOrOverride && provider !== profileProvider;
43
+ const envKey = providerEnvKey(provider);
44
+ const providerEnvApiKey = envKey ? env[envKey] : undefined;
45
+ const candidateApiKey = personalOrOverride
46
+ ? cfg.apiKey ?? profile.apiKey
47
+ : env.HARA_API_KEY ?? providerEnvApiKey ?? (namedProviderChanged ? undefined : profile.apiKey);
48
+ // Ollama/LM Studio declare auth:none. Never forward a stale cloud key (including HARA_API_KEY) to a
49
+ // loopback process that happens to occupy the configured port.
50
+ const apiKey = providerIsLocal(provider) ? undefined : candidateApiKey;
51
+ const baseURL = personalOrOverride
52
+ ? cfg.baseURL ?? profile.baseURL ?? providerDefaultBaseURL(provider)
53
+ : env.HARA_BASE_URL
54
+ ?? (namedProviderChanged ? undefined : profile.baseURL)
55
+ ?? providerDefaultBaseURL(provider);
56
+ const profileModel = profile.model || profile.defaultModel || "";
57
+ const model = personalOrOverride
58
+ ? cfg.model || env.HARA_MODEL || profileModel
59
+ : env.HARA_MODEL
60
+ || (namedProviderChanged ? "" : profileModel)
61
+ || providerDefaultModel(provider);
62
+ return { provider, apiKey, baseURL, model };
63
+ }
64
+ /**
65
+ * Apply an explicit runtime selection (session model, role model, vision/route sidecar, fallback).
66
+ *
67
+ * A provider switch starts a fresh credential/endpoint boundary; absent fields use that provider's public
68
+ * defaults and never inherit the previous profile's key or host.
69
+ */
70
+ export function overrideProviderTarget(base, override) {
71
+ if (!override)
72
+ return base;
73
+ const owns = (key) => Object.prototype.hasOwnProperty.call(override, key);
74
+ const provider = override.provider ?? base.provider;
75
+ const providerChanged = provider !== base.provider;
76
+ const apiKey = providerIsLocal(provider)
77
+ ? undefined
78
+ : owns("apiKey")
79
+ ? override.apiKey
80
+ : providerChanged
81
+ ? undefined
82
+ : base.apiKey;
83
+ const baseURL = owns("baseURL")
84
+ ? override.baseURL
85
+ : providerChanged
86
+ ? providerDefaultBaseURL(provider)
87
+ : base.baseURL;
88
+ const model = owns("model") && override.model
89
+ ? override.model
90
+ : providerChanged
91
+ ? providerDefaultModel(provider)
92
+ : base.model;
93
+ return { provider, apiKey, baseURL, model };
94
+ }
95
+ /** Gateway profiles own their default, while an explicit session/role model remains selectable. */
96
+ export function resolveGatewayModel(cfg, profile, env = process.env, requestedModel) {
97
+ return env.HARA_MODEL || requestedModel || profile.model || profile.defaultModel || cfg.model;
98
+ }
@@ -223,7 +223,12 @@ export async function guardianVeto(provider, action, history, opts = {}) {
223
223
  history: [{ role: "user", content: prompt }],
224
224
  tools: [],
225
225
  onText: () => { },
226
- }, { timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, signal: opts.signal, label: "security guardian" });
226
+ }, {
227
+ timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
228
+ signal: opts.signal,
229
+ label: "security guardian",
230
+ onProviderTurn: opts.onProviderTurn,
231
+ });
227
232
  if (r.stop === "error")
228
233
  return { decision: "allow", reason: "" }; // fail-open on model error
229
234
  return parseVerdict(r.text);
@@ -8,7 +8,7 @@ import { basename, dirname, join, resolve } from "node:path";
8
8
  import { FileReadLimitError, MAX_EDIT_READ_BYTES, decodeUtf8Strict, openVerifiedRegularFileNoFollow, verifyOpenedRegularFileSync, } from "../fs-read.js";
9
9
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
10
10
  import { sameOpenedFileIdentity } from "../fs-identity.js";
11
- const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin"]);
11
+ const PRIVATE_TREES = new Set(["sessions", "checkpoints", "index", "gateway", "cron", "weixin", "tool-results"]);
12
12
  const tightenedHomes = new Set();
13
13
  const DEFAULT_MIGRATION_CAP = 50_000;
14
14
  function isMissing(error) {
@@ -87,6 +87,25 @@ export function redactSensitiveText(text) {
87
87
  }
88
88
  return { text: out, redactions };
89
89
  }
90
+ /** Remove caller-known secret values before pattern matching. Provider/server errors may echo an opaque key
91
+ * that has no recognizable prefix, so field-name and token-shape heuristics alone cannot uphold no-echo APIs. */
92
+ export function redactKnownSecrets(text, secrets) {
93
+ let out = text;
94
+ const exactHits = [];
95
+ for (const value of secrets) {
96
+ if (!value)
97
+ continue;
98
+ const variants = new Set([value, encodeURIComponent(value)]);
99
+ for (const variant of variants) {
100
+ if (!variant || !out.includes(variant))
101
+ continue;
102
+ out = out.split(variant).join("***");
103
+ exactHits.push("known-secret");
104
+ }
105
+ }
106
+ const patterned = redactSensitiveText(out);
107
+ return { text: patterned.text, redactions: [...exactHits, ...patterned.redactions] };
108
+ }
90
109
  /** Structured tool/config data often carries an opaque value that has no recognizable token prefix. In that
91
110
  * case the FIELD name is the evidence (`apiKey`, `access_token`, `FEISHU_APP_SECRET`, …). Keep this narrow
92
111
  * enough not to erase ordinary fields such as `tokenCount` or `secretary`. */
@@ -3,8 +3,10 @@
3
3
  // Everything here is PURE (parse + frame builders + error codes) and unit-tested.
4
4
  //
5
5
  // Client → server requests:
6
- // initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,
6
+ // initialize {token,capabilities?} → {name,version,protocol,cwd,provider,model,setupState,
7
7
  // capabilities:{methods:[…]}} (feature detection)
8
+ // server.shutdown {} → {accepted:true} (authenticated graceful local shutdown;
9
+ // BUSY while any client work/approval is active)
8
10
  // session.list {cwd?} → {sessions:[{id,title,cwd,model,updatedAt}]}
9
11
  // session.create {cwd?,approval?} → {sessionId,model}
10
12
  // session.resume {sessionId} → {sessionId,model,history:[{role,text}]}
@@ -19,6 +21,9 @@
19
21
  // automation.list {} → {jobs:[{id,name,mode,enabled,lastRunAt,lastStatus,…}],
20
22
  // sessions:[{id,title,source,sourceName,updatedAt,…}]}
21
23
  // models.list {} → {models:[…], current, effortLevels:[…]}
24
+ // settings.providers.list {} → redacted provider catalog + current profile state
25
+ // settings.providers.test {provider,model,…} → {ok,models,error?} (credential is ephemeral)
26
+ // settings.providers.save {provider,model,…} → redacted state (credential is never returned)
22
27
  // automation.add {name,schedule,task,mode?,cwd?,tz?} → {id,name,schedule}
23
28
  // automation.toggle {id,enabled} → {id,enabled}
24
29
  // automation.delete {id} → {id,deleted}
@@ -37,7 +42,7 @@ export const ERR = {
37
42
  PARAMS: -32602,
38
43
  INTERNAL: -32603,
39
44
  UNAUTHORIZED: -32001, // initialize first (or bad token)
40
- BUSY: -32002, // session already has a turn in flight
45
+ BUSY: -32002, // requested operation conflicts with active server/session work
41
46
  NO_SESSION: -32003, // unknown/expired sessionId
42
47
  LOCKED: -32004, // session held by another live hara process (single-writer lock)
43
48
  };
@@ -34,6 +34,7 @@ import { parseFrame, rpcResult, rpcError, rpcNotify, ERR, PROTOCOL_VERSION } fro
34
34
  import { readModelContextFileSync } from "../fs-read.js";
35
35
  import { optionalPosixOpenFlag } from "../fs-open-flags.js";
36
36
  import { sameOpenedFileIdentity } from "../fs-identity.js";
37
+ import { redactSensitiveText, redactSensitiveValue } from "../security/secrets.js";
37
38
  import { consumePendingTaskSteering, createTaskExecution, continueTaskExecution, finishTaskExecution, newSteerInteraction, newTurnInteraction, recordTaskSteering, requestsTaskContinuation, taskExecutionContext, } from "../session/task.js";
38
39
  const APPROVAL_TIMEOUT_MS = 300_000; // an unanswered approval denies after 5 min (never hangs a turn)
39
40
  const COMPACT_TIMEOUT_MS = 60_000;
@@ -251,12 +252,11 @@ export async function startServe(opts, deps) {
251
252
  const fresh = deps.buildProviderFor
252
253
  ? await deps.buildProviderFor(session.meta.model, session.effort, session.meta.cwd)
253
254
  : await deps.buildSessionProvider(session.meta.cwd);
254
- if (!fresh)
255
+ if (!fresh || fresh.model !== session.meta.model)
255
256
  return false;
256
257
  session.provider = fresh;
257
258
  session.meta.provider = fresh.id;
258
- // Preserve the user's pinned model in meta; provider factories should honor it, but fail closed if they do not.
259
- return fresh.model === session.meta.model;
259
+ return true;
260
260
  };
261
261
  const wss = new WebSocketServer({ host: opts.host, port: opts.port, maxPayload: 10 * 1024 * 1024 });
262
262
  await new Promise((res, rej) => {
@@ -267,8 +267,66 @@ export async function startServe(opts, deps) {
267
267
  const authed = new Set();
268
268
  const pendingApprovals = new Map();
269
269
  const inFlightRequests = new Set();
270
+ // Physical provider/tool work can outlive its logical timeout. Keep a process-level ledger independent
271
+ // of SessionHub membership so detach/delete cannot make an updater believe the old engine is quiescent.
272
+ const activeOperations = new Set();
270
273
  let closing = false;
271
274
  let closePromise = null;
275
+ const trackActiveOperation = (operation) => {
276
+ activeOperations.add(operation);
277
+ const settled = () => {
278
+ activeOperations.delete(operation);
279
+ if (closing)
280
+ hub.releaseIdle();
281
+ };
282
+ void operation.then(settled, settled);
283
+ return operation;
284
+ };
285
+ const releaseSessionBusyIfIdle = (session) => {
286
+ if (session.abort === null &&
287
+ session.pendingProviderTurns === 0 &&
288
+ session.pendingToolRuns === 0) {
289
+ session.busy = false;
290
+ }
291
+ };
292
+ const observeProviderTurn = (session, turn) => {
293
+ session.pendingProviderTurns += 1;
294
+ trackActiveOperation(turn);
295
+ const settled = () => {
296
+ session.pendingProviderTurns = Math.max(0, session.pendingProviderTurns - 1);
297
+ // A logical timeout/interrupt may return before a non-cooperative provider physically settles.
298
+ // Retain the per-session lease so a second turn cannot share that provider instance concurrently.
299
+ releaseSessionBusyIfIdle(session);
300
+ if (closing)
301
+ hub.releaseIdle();
302
+ };
303
+ void turn.then(settled, settled);
304
+ };
305
+ const observeToolRun = (session, toolRun) => {
306
+ session.pendingToolRuns += 1;
307
+ trackActiveOperation(toolRun);
308
+ const settled = () => {
309
+ session.pendingToolRuns = Math.max(0, session.pendingToolRuns - 1);
310
+ // `abort === null` means the logical turn already returned. Keep the session busy/locked until
311
+ // every late side-effect-capable Promise has physically stopped.
312
+ releaseSessionBusyIfIdle(session);
313
+ if (closing)
314
+ hub.releaseIdle();
315
+ };
316
+ void toolRun.then(settled, settled);
317
+ };
318
+ /** An RPC-requested shutdown is a cooperative handoff (for example, before a Desktop update), not a
319
+ * force-stop. Refuse it while ANY client still owns live work. `inFlightRequests` covers async work that
320
+ * has not attached a session yet (provider factories/settings/filesystem scans); the session fields cover
321
+ * turns, compaction, provider reconfiguration, and physically late provider/tool promises. */
322
+ const hasActiveClientWork = () => inFlightRequests.size > 0 ||
323
+ activeOperations.size > 0 ||
324
+ pendingApprovals.size > 0 ||
325
+ hub.active().some((session) => session.busy ||
326
+ session.configuring ||
327
+ session.abort !== null ||
328
+ session.pendingProviderTurns > 0 ||
329
+ session.pendingToolRuns > 0);
272
330
  const broadcast = (method, params) => {
273
331
  const frame = rpcNotify(method, params);
274
332
  for (const ws of authed)
@@ -384,8 +442,8 @@ export async function startServe(opts, deps) {
384
442
  if (signal.aborted)
385
443
  finish(false);
386
444
  else {
387
- // `signal` is runAgent's combined Esc + total-deadline signal. Listening only to turnAbort would
388
- // leave the approval map and Desktop prompt stale when the internal lifecycle deadline fires.
445
+ // `signal` composes the owning turn cancellation with runAgent's lifecycle cancellation. Listening
446
+ // only to turnAbort would leave the approval map and Desktop prompt stale after an internal stop.
389
447
  signal.addEventListener("abort", onAbort, { once: true });
390
448
  broadcast("approval.request", { sessionId, approvalId, question: q });
391
449
  }
@@ -419,7 +477,10 @@ export async function startServe(opts, deps) {
419
477
  cwd: s.meta.cwd,
420
478
  sandbox: deps.sandbox,
421
479
  todoScope: sessionId,
422
- spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal),
480
+ spawn: (t, role, signal) => deps.spawnSubagent(s.provider, s.meta.cwd, s.projectContext, s.stats, t, role, signal, {
481
+ onProviderTurn: (turn) => observeProviderTurn(s, turn),
482
+ onToolRun: (toolRun) => observeToolRun(s, toolRun),
483
+ }),
423
484
  ui: sink,
424
485
  },
425
486
  approval: s.approval,
@@ -446,28 +507,8 @@ export async function startServe(opts, deps) {
446
507
  },
447
508
  stats: s.stats,
448
509
  signal: turnAbort.signal,
449
- onProviderTurn: (turn) => {
450
- s.pendingProviderTurns += 1;
451
- const settled = () => {
452
- s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
453
- if (closing)
454
- hub.releaseIdle();
455
- };
456
- void turn.then(settled, settled);
457
- },
458
- onToolRun: (toolRun) => {
459
- s.pendingToolRuns += 1;
460
- const settled = () => {
461
- s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
462
- // `abort === null` means the logical turn already returned. Keep the session busy/locked until
463
- // every late side-effect-capable Promise has physically stopped.
464
- if (s.pendingToolRuns === 0 && s.abort === null)
465
- s.busy = false;
466
- if (closing)
467
- hub.releaseIdle();
468
- };
469
- void toolRun.then(settled, settled);
470
- },
510
+ onProviderTurn: (turn) => observeProviderTurn(s, turn),
511
+ onToolRun: (toolRun) => observeToolRun(s, toolRun),
471
512
  guardian: turnGuardian,
472
513
  ...(deps.runLimits?.(s.meta.cwd) ?? {}),
473
514
  });
@@ -508,7 +549,7 @@ export async function startServe(opts, deps) {
508
549
  }
509
550
  finally {
510
551
  s.abort = null;
511
- s.busy = s.pendingToolRuns > 0;
552
+ s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
512
553
  }
513
554
  };
514
555
  /** Context watermark for a session: how full the model's window was on the last turn. */
@@ -544,7 +585,7 @@ export async function startServe(opts, deps) {
544
585
  if (controller.signal.aborted)
545
586
  return onAbort();
546
587
  // Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
547
- void Promise.resolve().then(() => {
588
+ const providerTurn = Promise.resolve().then(() => {
548
589
  // The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
549
590
  // the actual invocation boundary so an interrupted/expired compact cannot start a late request.
550
591
  if (controller.signal.aborted)
@@ -556,7 +597,9 @@ export async function startServe(opts, deps) {
556
597
  onText: () => { },
557
598
  signal: controller.signal,
558
599
  });
559
- }).then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
600
+ });
601
+ observeProviderTurn(s, providerTurn);
602
+ void providerTurn.then((result) => finish(() => resolve(result)), (error) => finish(() => reject(error)));
560
603
  });
561
604
  if (controller.signal.aborted || r.stop === "error")
562
605
  return null;
@@ -619,18 +662,46 @@ export async function startServe(opts, deps) {
619
662
  // clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
620
663
  // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
621
664
  const methods = [
665
+ "server.shutdown",
622
666
  "session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
623
667
  "session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
624
668
  "approval.reply", "plugins.list", "plugins.set", "skills.list", "models.list", "files.search", "project.panels",
669
+ "settings.providers.list", "settings.providers.test", "settings.providers.save",
625
670
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
626
671
  "tasks.list", "approvals.list", "approvals.resolve",
627
672
  ];
628
673
  const runtime = runtimeInfo();
629
- return reply(rpcResult(id, { name: "hara", version: deps.version, protocol: PROTOCOL_VERSION, cwd: opts.cwd, provider: runtime.providerId, model: runtime.model, capabilities: { methods } }));
674
+ const setupState = deps.providerSettings
675
+ ? (deps.providerSettings(opts.cwd).current.authenticated ? "ready" : "needs-credentials")
676
+ : "ready";
677
+ return reply(rpcResult(id, {
678
+ name: "hara",
679
+ version: deps.version,
680
+ protocol: PROTOCOL_VERSION,
681
+ cwd: opts.cwd,
682
+ provider: runtime.providerId,
683
+ model: runtime.model,
684
+ setupState,
685
+ capabilities: { methods },
686
+ }));
630
687
  }
631
688
  if (!authed.has(ws))
632
689
  return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
633
690
  switch (req.method) {
691
+ case "server.shutdown": {
692
+ // The updater's stop request must never abort another client's turn or dismiss its approval.
693
+ // The current shutdown request is not inserted into inFlightRequests until this synchronous
694
+ // branch returns, so any entry observed here belongs to another request. Once accepted, close
695
+ // admission atomically before replying/scheduling close: no new work can race into the gap.
696
+ if (hasActiveClientWork()) {
697
+ return reply(rpcError(id, ERR.BUSY, "server has active work — retry shutdown after all sessions and approvals are idle"));
698
+ }
699
+ closing = true;
700
+ reply(rpcResult(id, { accepted: true }));
701
+ const shutdown = setTimeout(() => void close(), 0);
702
+ shutdown.unref();
703
+ return;
704
+ }
634
705
  case "session.list":
635
706
  return reply(rpcResult(id, { sessions: hub.list(typeof p.cwd === "string" ? p.cwd : undefined).filter((m) => !m.archived || p.archived === true).map((m) => ({ id: m.id, title: m.title, cwd: m.cwd, model: m.model, updatedAt: m.updatedAt, source: m.source ?? "interactive", sourceName: m.sourceName, archived: m.archived ?? false })) }));
636
707
  case "session.create": {
@@ -826,6 +897,37 @@ export async function startServe(opts, deps) {
826
897
  const currentRuntime = runtimeInfo(targetCwd, current);
827
898
  return reply(rpcResult(id, { models, current, effort: session?.effort ?? null, effortLevels: currentRuntime.effortLevels }));
828
899
  }
900
+ case "settings.providers.list": {
901
+ if (!deps.providerSettings)
902
+ return reply(rpcError(id, ERR.METHOD, "provider settings not supported by this server"));
903
+ const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
904
+ return reply(rpcResult(id, redactSensitiveValue(deps.providerSettings(targetCwd)).value));
905
+ }
906
+ case "settings.providers.test":
907
+ case "settings.providers.save": {
908
+ const callback = req.method === "settings.providers.test" ? deps.testProviderSettings : deps.saveProviderSettings;
909
+ if (!callback)
910
+ return reply(rpcError(id, ERR.METHOD, "provider settings not supported by this server"));
911
+ if (typeof p.provider !== "string" ||
912
+ typeof p.model !== "string" ||
913
+ (p.baseURL !== undefined && typeof p.baseURL !== "string") ||
914
+ (p.apiKey !== undefined && typeof p.apiKey !== "string") ||
915
+ (p.clearApiKey !== undefined && typeof p.clearApiKey !== "boolean") ||
916
+ (p.activatePersonal !== undefined && typeof p.activatePersonal !== "boolean")) {
917
+ return reply(rpcError(id, ERR.PARAMS, "provider + model required; optional baseURL/apiKey/clearApiKey/activatePersonal have invalid types"));
918
+ }
919
+ const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : opts.cwd;
920
+ const input = {
921
+ provider: p.provider,
922
+ model: p.model,
923
+ ...(p.baseURL !== undefined ? { baseURL: p.baseURL } : {}),
924
+ ...(p.apiKey !== undefined ? { apiKey: p.apiKey } : {}),
925
+ ...(p.clearApiKey !== undefined ? { clearApiKey: p.clearApiKey } : {}),
926
+ ...(p.activatePersonal !== undefined ? { activatePersonal: p.activatePersonal } : {}),
927
+ };
928
+ const result = await callback(input, targetCwd);
929
+ return reply(rpcResult(id, redactSensitiveValue(result).value));
930
+ }
829
931
  case "session.set-model": {
830
932
  // per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
831
933
  // provider; takes effect on the NEXT turn. Refused mid-turn.
@@ -989,9 +1091,9 @@ export async function startServe(opts, deps) {
989
1091
  return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
990
1092
  }
991
1093
  finally {
992
- s.busy = false;
993
1094
  if (s.abort === compactAbort)
994
1095
  s.abort = null;
1096
+ s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
995
1097
  }
996
1098
  }
997
1099
  case "session.rewind": {
@@ -1018,7 +1120,7 @@ export async function startServe(opts, deps) {
1018
1120
  }
1019
1121
  }
1020
1122
  catch (e) {
1021
- return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
1123
+ return reply(rpcError(id, ERR.INTERNAL, redactSensitiveText(String(e?.message ?? e)).text));
1022
1124
  }
1023
1125
  })();
1024
1126
  inFlightRequests.add(task);
@@ -1077,7 +1179,9 @@ export async function startServe(opts, deps) {
1077
1179
  await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
1078
1180
  let quiet = false;
1079
1181
  while (Date.now() < deadline) {
1080
- if (inFlightRequests.size === 0 && hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0 && session.pendingToolRuns === 0)) {
1182
+ if (inFlightRequests.size === 0 &&
1183
+ activeOperations.size === 0 &&
1184
+ hub.active().every((session) => !session.busy && !session.configuring && session.pendingProviderTurns === 0 && session.pendingToolRuns === 0)) {
1081
1185
  quiet = true;
1082
1186
  break;
1083
1187
  }
@@ -95,7 +95,12 @@ export class SessionHub {
95
95
  * when resume attached successfully but live provider validation failed before the client got a handle. */
96
96
  detach(id) {
97
97
  const live = this.sessions.get(id);
98
- if (!live || live.busy || live.configuring)
98
+ if (!live ||
99
+ live.busy ||
100
+ live.configuring ||
101
+ live.abort !== null ||
102
+ live.pendingProviderTurns > 0 ||
103
+ live.pendingToolRuns > 0)
99
104
  return false;
100
105
  this.sessions.delete(id);
101
106
  this.store.release(id);
@@ -213,7 +218,11 @@ export class SessionHub {
213
218
  * "gone" on success, "busy" when a turn is running, "missing" when unknown/held elsewhere. */
214
219
  delete(id) {
215
220
  const live = this.sessions.get(id);
216
- if (live?.busy || live?.configuring)
221
+ if (live?.busy ||
222
+ live?.configuring ||
223
+ (live?.abort ?? null) !== null ||
224
+ (live?.pendingProviderTurns ?? 0) > 0 ||
225
+ (live?.pendingToolRuns ?? 0) > 0)
217
226
  return "busy";
218
227
  const ok = this.store.delete(id);
219
228
  if (!ok)
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A dynamic physical-operation drain for session leases.
3
+ *
4
+ * A one-time `Promise.allSettled([...pending])` snapshot is insufficient: an observed outer tool may
5
+ * start and register a nested provider after shutdown begins. This drain checks the live Set after every
6
+ * settlement and releases only once it remains empty at a microtask boundary.
7
+ */
8
+ export function createPhysicalOperationDrain(onDrained) {
9
+ const pending = new Set();
10
+ let closing = false;
11
+ let drained = false;
12
+ let drainQueued = false;
13
+ const requestDrain = () => {
14
+ if (!closing || drained || drainQueued || pending.size > 0)
15
+ return;
16
+ drainQueued = true;
17
+ queueMicrotask(() => {
18
+ drainQueued = false;
19
+ if (!closing || drained || pending.size > 0)
20
+ return;
21
+ drained = true;
22
+ onDrained();
23
+ });
24
+ };
25
+ const observe = (operation) => {
26
+ if (drained) {
27
+ throw new Error("cannot observe a physical operation after its session lease drained");
28
+ }
29
+ pending.add(operation);
30
+ const settled = () => {
31
+ pending.delete(operation);
32
+ requestDrain();
33
+ };
34
+ void operation.then(settled, settled);
35
+ return operation;
36
+ };
37
+ return {
38
+ observe,
39
+ close: () => {
40
+ closing = true;
41
+ requestDrain();
42
+ },
43
+ pendingCount: () => pending.size,
44
+ };
45
+ }
@@ -31,6 +31,7 @@ registerTool({
31
31
  required: ["task"],
32
32
  },
33
33
  kind: "read", // parallel-safe: multiple agent() calls in a turn run concurrently
34
+ concurrencySafe: true,
34
35
  async run(input, ctx) {
35
36
  if (!ctx.spawn)
36
37
  return "Error: sub-agents are not available in this context.";