@nanhara/hara 0.125.1 → 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.
Files changed (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/README.md +22 -8
  3. package/SECURITY.md +6 -4
  4. package/dist/agent/limits.js +2 -2
  5. package/dist/agent/loop.js +413 -77
  6. package/dist/config.js +172 -4
  7. package/dist/gateway/flows-pending.js +14 -36
  8. package/dist/gateway/wecom.js +9 -1
  9. package/dist/index.js +439 -227
  10. package/dist/mcp/client.js +73 -6
  11. package/dist/providers/bounded-turn.js +6 -0
  12. package/dist/providers/factory.js +54 -0
  13. package/dist/providers/models.js +39 -6
  14. package/dist/providers/openai.js +6 -1
  15. package/dist/providers/reasoning.js +12 -0
  16. package/dist/providers/registry.js +1 -0
  17. package/dist/providers/target.js +98 -0
  18. package/dist/security/guardian.js +6 -1
  19. package/dist/security/private-state.js +1 -1
  20. package/dist/security/secrets.js +19 -0
  21. package/dist/serve/protocol.js +7 -2
  22. package/dist/serve/server.js +157 -40
  23. package/dist/serve/sessions.js +11 -2
  24. package/dist/session/operation-drain.js +45 -0
  25. package/dist/session/task.js +78 -0
  26. package/dist/statusbar.js +15 -2
  27. package/dist/tools/agent.js +1 -0
  28. package/dist/tools/all.js +1 -0
  29. package/dist/tools/ask_user.js +8 -3
  30. package/dist/tools/builtin.js +22 -1
  31. package/dist/tools/codebase.js +1 -0
  32. package/dist/tools/computer.js +1 -0
  33. package/dist/tools/cron.js +10 -0
  34. package/dist/tools/external_agent.js +1 -0
  35. package/dist/tools/memory.js +2 -0
  36. package/dist/tools/registry.js +95 -4
  37. package/dist/tools/result-limit.js +172 -2
  38. package/dist/tools/runtime.js +67 -0
  39. package/dist/tools/search.js +3 -0
  40. package/dist/tools/skill.js +1 -0
  41. package/dist/tools/task.js +5 -0
  42. package/dist/tools/todo.js +3 -2
  43. package/dist/tools/web.js +4 -0
  44. package/dist/tui/App.js +49 -17
  45. package/dist/tui/model-picker.js +11 -8
  46. package/package.json +1 -1
@@ -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;
@@ -239,11 +240,11 @@ export async function startServe(opts, deps) {
239
240
  const token = opts.token ?? randomBytes(16).toString("hex");
240
241
  const instanceId = randomUUID();
241
242
  const hub = new SessionHub(deps.store ?? realStore);
242
- const runtimeInfo = (cwd) => {
243
- const live = deps.runtimeInfo?.(cwd);
243
+ const runtimeInfo = (cwd, model) => {
244
+ const live = deps.runtimeInfo?.(cwd, model);
244
245
  return {
245
246
  providerId: live?.providerId ?? deps.providerId,
246
- model: live?.model ?? deps.model,
247
+ model: live?.model ?? model ?? deps.model,
247
248
  effortLevels: live?.effortLevels ?? deps.effortLevels ?? [],
248
249
  };
249
250
  };
@@ -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,
@@ -429,34 +490,25 @@ export async function startServe(opts, deps) {
429
490
  memory: memoryDigest(s.meta.cwd),
430
491
  continuationSession: s.continuationSession,
431
492
  executionContext,
493
+ taskIntake: {
494
+ task: s.task,
495
+ current: () => s.task,
496
+ onUpdate: (next) => {
497
+ s.task = next;
498
+ },
499
+ onCheckpoint: (next) => {
500
+ s.task = next;
501
+ hub.save(s);
502
+ },
503
+ },
432
504
  pendingInput: async () => {
433
505
  materializePendingSteering(s); // helper updates the shared live history after its write-ahead save
434
506
  return [];
435
507
  },
436
508
  stats: s.stats,
437
509
  signal: turnAbort.signal,
438
- onProviderTurn: (turn) => {
439
- s.pendingProviderTurns += 1;
440
- const settled = () => {
441
- s.pendingProviderTurns = Math.max(0, s.pendingProviderTurns - 1);
442
- if (closing)
443
- hub.releaseIdle();
444
- };
445
- void turn.then(settled, settled);
446
- },
447
- onToolRun: (toolRun) => {
448
- s.pendingToolRuns += 1;
449
- const settled = () => {
450
- s.pendingToolRuns = Math.max(0, s.pendingToolRuns - 1);
451
- // `abort === null` means the logical turn already returned. Keep the session busy/locked until
452
- // every late side-effect-capable Promise has physically stopped.
453
- if (s.pendingToolRuns === 0 && s.abort === null)
454
- s.busy = false;
455
- if (closing)
456
- hub.releaseIdle();
457
- };
458
- void toolRun.then(settled, settled);
459
- },
510
+ onProviderTurn: (turn) => observeProviderTurn(s, turn),
511
+ onToolRun: (toolRun) => observeToolRun(s, toolRun),
460
512
  guardian: turnGuardian,
461
513
  ...(deps.runLimits?.(s.meta.cwd) ?? {}),
462
514
  });
@@ -497,7 +549,7 @@ export async function startServe(opts, deps) {
497
549
  }
498
550
  finally {
499
551
  s.abort = null;
500
- s.busy = s.pendingToolRuns > 0;
552
+ s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
501
553
  }
502
554
  };
503
555
  /** Context watermark for a session: how full the model's window was on the last turn. */
@@ -533,7 +585,7 @@ export async function startServe(opts, deps) {
533
585
  if (controller.signal.aborted)
534
586
  return onAbort();
535
587
  // Promise.resolve protects this boundary even if a non-conforming provider throws synchronously.
536
- void Promise.resolve().then(() => {
588
+ const providerTurn = Promise.resolve().then(() => {
537
589
  // The abort can fire after scheduling this microtask but before it runs. Gate the provider call at
538
590
  // the actual invocation boundary so an interrupted/expired compact cannot start a late request.
539
591
  if (controller.signal.aborted)
@@ -545,7 +597,9 @@ export async function startServe(opts, deps) {
545
597
  onText: () => { },
546
598
  signal: controller.signal,
547
599
  });
548
- }).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)));
549
603
  });
550
604
  if (controller.signal.aborted || r.stop === "error")
551
605
  return null;
@@ -608,18 +662,46 @@ export async function startServe(opts, deps) {
608
662
  // clients feature-detect up front instead of probing for -32601 per call. `p.capabilities`
609
663
  // (client-declared) is accepted and currently unused — reserved for opt-outs/experimental gating.
610
664
  const methods = [
665
+ "server.shutdown",
611
666
  "session.list", "session.create", "session.resume", "session.send", "session.steer", "session.interrupt", "session.set-model",
612
667
  "session.rename", "session.archive", "session.compact", "session.rewind", "session.context", "session.delete", "session.fork",
613
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",
614
670
  "automation.list", "automation.add", "automation.toggle", "automation.delete",
615
671
  "tasks.list", "approvals.list", "approvals.resolve",
616
672
  ];
617
673
  const runtime = runtimeInfo();
618
- 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
+ }));
619
687
  }
620
688
  if (!authed.has(ws))
621
689
  return reply(rpcError(id, ERR.UNAUTHORIZED, "initialize first"));
622
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
+ }
623
705
  case "session.list":
624
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 })) }));
625
707
  case "session.create": {
@@ -810,8 +892,41 @@ export async function startServe(opts, deps) {
810
892
  const session = typeof p.sessionId === "string" ? hub.get(p.sessionId) : undefined;
811
893
  const targetCwd = typeof p.cwd === "string" && p.cwd ? p.cwd : (session?.meta.cwd ?? opts.cwd);
812
894
  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 }));
895
+ const defaultRuntime = runtimeInfo(targetCwd);
896
+ const current = session?.meta.model ?? defaultRuntime.model;
897
+ const currentRuntime = runtimeInfo(targetCwd, current);
898
+ return reply(rpcResult(id, { models, current, effort: session?.effort ?? null, effortLevels: currentRuntime.effortLevels }));
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));
815
930
  }
816
931
  case "session.set-model": {
817
932
  // per-session model / thinking-effort switch (the composer picker). Rebuilds the session's
@@ -976,9 +1091,9 @@ export async function startServe(opts, deps) {
976
1091
  return reply(rpcResult(id, { sessionId: s.meta.id, ctx: ctxOf(s), notes: s.meta.workingSet?.length ?? 0, history: historyForClient(s.history) }));
977
1092
  }
978
1093
  finally {
979
- s.busy = false;
980
1094
  if (s.abort === compactAbort)
981
1095
  s.abort = null;
1096
+ s.busy = s.pendingProviderTurns > 0 || s.pendingToolRuns > 0;
982
1097
  }
983
1098
  }
984
1099
  case "session.rewind": {
@@ -1005,7 +1120,7 @@ export async function startServe(opts, deps) {
1005
1120
  }
1006
1121
  }
1007
1122
  catch (e) {
1008
- return reply(rpcError(id, ERR.INTERNAL, String(e?.message ?? e)));
1123
+ return reply(rpcError(id, ERR.INTERNAL, redactSensitiveText(String(e?.message ?? e)).text));
1009
1124
  }
1010
1125
  })();
1011
1126
  inFlightRequests.add(task);
@@ -1064,7 +1179,9 @@ export async function startServe(opts, deps) {
1064
1179
  await removeOwnedDiscovery(discoveryDir, discoveryPath, discovery).catch(() => { });
1065
1180
  let quiet = false;
1066
1181
  while (Date.now() < deadline) {
1067
- 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)) {
1068
1185
  quiet = true;
1069
1186
  break;
1070
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
+ }
@@ -3,6 +3,9 @@ export const TASK_SCHEMA_VERSION = 1;
3
3
  export const MAX_TASK_OBJECTIVE_CHARS = 4096;
4
4
  export const MAX_TASK_STEERING_CHARS = 24_000;
5
5
  export const MAX_TASK_STEERING_ENTRIES = 24;
6
+ export const MAX_TASK_BRIEF_GOAL_CHARS = 2_000;
7
+ export const MAX_TASK_BRIEF_LIST_ENTRIES = 12;
8
+ export const MAX_TASK_BRIEF_ITEM_CHARS = 800;
6
9
  function iso(at = new Date()) {
7
10
  return typeof at === "string" ? at : at.toISOString();
8
11
  }
@@ -16,12 +19,42 @@ function validId(value) {
16
19
  function validTimestamp(value) {
17
20
  return typeof value === "string" && value.length > 0 && Number.isFinite(Date.parse(value));
18
21
  }
22
+ function boundedList(value, fallback) {
23
+ if (!Array.isArray(value))
24
+ return [fallback];
25
+ const out = value
26
+ .filter((item) => typeof item === "string")
27
+ .map((item) => boundedText(item, MAX_TASK_BRIEF_ITEM_CHARS))
28
+ .filter(Boolean)
29
+ .slice(0, MAX_TASK_BRIEF_LIST_ENTRIES);
30
+ return out.length ? out : [fallback];
31
+ }
32
+ function validBriefList(value) {
33
+ return Array.isArray(value) &&
34
+ value.length > 0 &&
35
+ value.length <= MAX_TASK_BRIEF_LIST_ENTRIES &&
36
+ value.every((item) => typeof item === "string" && item.length > 0 && item.length <= MAX_TASK_BRIEF_ITEM_CHARS);
37
+ }
19
38
  export function newTurnInteraction() {
20
39
  return { kind: "turn", turnId: randomUUID() };
21
40
  }
22
41
  export function newSteerInteraction(expectedTurnId) {
23
42
  return { kind: "steer", expectedTurnId, turnId: randomUUID() };
24
43
  }
44
+ /** Resolve a UI-delivery hint against authoritative task state. `steer` is never itself proof that an
45
+ * executable task is running: controls also occupy the composer briefly, and a real turn may finish between
46
+ * enqueue and dequeue. Preserve the submitted turn id but promote late input to a new turn. Only an explicit
47
+ * continuation path may opt into reopening a paused/completed task; stale live-turn ids remain hard errors
48
+ * in `continueTaskExecution`. */
49
+ export function routeTaskInteraction(task, interaction, options = {}) {
50
+ const steerable = !!task && (task.status === "running" || options.allowInactive === true);
51
+ if (interaction.kind !== "steer" || steerable)
52
+ return { interaction, recoveredMissingTask: false };
53
+ return {
54
+ interaction: { kind: "turn", turnId: interaction.turnId },
55
+ recoveredMissingTask: true,
56
+ };
57
+ }
25
58
  export function createTaskExecution(objective, turnId, at = new Date()) {
26
59
  const now = iso(at);
27
60
  return {
@@ -35,6 +68,35 @@ export function createTaskExecution(objective, turnId, at = new Date()) {
35
68
  startedAt: now,
36
69
  };
37
70
  }
71
+ /** Attach or revise the explicit understanding checkpoint. Revision is intentional: steering may add a
72
+ * constraint or convert an investigation into an approved change, while the original request remains intact. */
73
+ export function applyTaskBrief(task, input, at = new Date()) {
74
+ if (!task)
75
+ return { ok: false, reason: "there is no task to brief" };
76
+ if (task.status !== "running")
77
+ return { ok: false, reason: `task ${task.id} is ${task.status}, not running` };
78
+ const intent = input.intent;
79
+ if (intent !== "answer" && intent !== "investigate" && intent !== "change") {
80
+ return { ok: false, reason: "intent must be answer, investigate, or change" };
81
+ }
82
+ if (typeof input.goal !== "string" || !input.goal.trim()) {
83
+ return { ok: false, reason: "goal must be a non-empty string" };
84
+ }
85
+ const now = iso(at);
86
+ const brief = {
87
+ intent,
88
+ goal: boundedText(input.goal, MAX_TASK_BRIEF_GOAL_CHARS),
89
+ constraints: boundedList(input.constraints, "preserve unrelated user work and stated boundaries"),
90
+ acceptance: boundedList(input.acceptance, intent === "change" ? "the requested change is verified" : "the answer is supported by relevant evidence"),
91
+ steps: boundedList(input.steps, intent === "change" ? "inspect, change, and verify" : "inspect and report"),
92
+ createdAt: now,
93
+ };
94
+ return {
95
+ ok: true,
96
+ brief,
97
+ task: { ...task, brief, updatedAt: now },
98
+ };
99
+ }
38
100
  export function continueTaskExecution(task, interaction, at = new Date()) {
39
101
  if (!task)
40
102
  return { ok: false, reason: "there is no task to steer" };
@@ -171,6 +233,9 @@ export function taskExecutionContext(task, interaction, todos = []) {
171
233
  steeringNote,
172
234
  "Conversation messages provide evidence and refinements, but the task objective above remains authoritative until an explicit new task starts.",
173
235
  ];
236
+ // The accepted brief is deliberately absent here. `taskExecutionContext` is the stable per-interaction
237
+ // identity/recovery snapshot, while runAgent composes the current brief dynamically on every model round.
238
+ // Duplicating it here would leave the pre-run version in the prompt after a mid-run task_intake revision.
174
239
  if (todos.length) {
175
240
  lines.push("## Persisted execution checkpoint", ...todos.slice(0, 24).map((todo) => {
176
241
  const mark = todo.status === "done" ? "done" : todo.status === "in_progress" ? "in progress" : "pending";
@@ -189,6 +254,7 @@ export function formatTaskExecution(task) {
189
254
  `task ${task.id.slice(0, 8)} · ${task.status}`,
190
255
  `turn ${task.turnId.slice(0, 8)} · outcome ${task.lastOutcome ?? "running"}`,
191
256
  `objective: ${task.objective}`,
257
+ `brief: ${task.brief ? `${task.brief.intent} · ${task.brief.goal}` : "(not accepted yet)"}`,
192
258
  `steering: ${task.steering?.length ?? 0}`,
193
259
  ].join("\n");
194
260
  }
@@ -205,6 +271,18 @@ export function isTaskExecution(value) {
205
271
  (task.endedAt !== undefined && !validTimestamp(task.endedAt)) ||
206
272
  (task.lastOutcome !== undefined && task.lastOutcome !== "completed" && task.lastOutcome !== "error" && task.lastOutcome !== "empty" && task.lastOutcome !== "halted" && task.lastOutcome !== "interrupted"))
207
273
  return false;
274
+ if (task.brief !== undefined) {
275
+ if (!task.brief || typeof task.brief !== "object" || Array.isArray(task.brief))
276
+ return false;
277
+ const brief = task.brief;
278
+ if ((brief.intent !== "answer" && brief.intent !== "investigate" && brief.intent !== "change") ||
279
+ typeof brief.goal !== "string" || brief.goal.length === 0 || brief.goal.length > MAX_TASK_BRIEF_GOAL_CHARS ||
280
+ !validBriefList(brief.constraints) ||
281
+ !validBriefList(brief.acceptance) ||
282
+ !validBriefList(brief.steps) ||
283
+ !validTimestamp(brief.createdAt))
284
+ return false;
285
+ }
208
286
  if (task.steering === undefined)
209
287
  return true;
210
288
  if (!Array.isArray(task.steering) || task.steering.length > MAX_TASK_STEERING_ENTRIES)
package/dist/statusbar.js CHANGED
@@ -13,10 +13,23 @@ const fmtTok = (n) => (n >= 1000 ? `${(n / 1000).toFixed(1)}k` : `${n}`);
13
13
  const truncate = (s, max) => (s.length <= max ? s : s.slice(0, Math.max(0, max - 1)) + "…");
14
14
  const rule = (n) => c.dim("─".repeat(Math.max(0, n)));
15
15
  export function contextWindow(model) {
16
- const m = model.toLowerCase();
16
+ // Provider prefixes are common (`qwen/qwen3.7-plus`). Match the actual model id and prefer exact
17
+ // documented Coding Plan families over broad words such as "coder"/"max": those previously labeled
18
+ // qwen3-coder-next and qwen3-max as 1M, causing the context guard to overfeed their 262k windows.
19
+ const m = model.toLowerCase().split("/").at(-1) ?? model.toLowerCase();
17
20
  if (/haiku/.test(m))
18
21
  return 200_000;
19
- if (/(opus|sonnet|fable)|claude-4|qwen3|glm-[45]|max-2026|coder|1m/.test(m))
22
+ if (/^qwen3\.[567]-plus(?:-|$)/.test(m) || /^qwen3-coder-plus(?:-|$)/.test(m))
23
+ return 1_000_000;
24
+ if (/^(?:qwen3-max-2026-01-23|qwen3-coder-next)(?:-|$)/.test(m) || /^kimi-k2\.5(?:-|$)/.test(m))
25
+ return 262_144;
26
+ if (/^glm-(?:5|4\.7)(?:-|$)/.test(m))
27
+ return 202_752;
28
+ if (/^minimax-m2\.5(?:-|$)/.test(m))
29
+ return 196_608;
30
+ if (/qwen3\.6[-:]27b/.test(m))
31
+ return 262_144;
32
+ if (/(opus|sonnet|fable)|claude-4|1m/.test(m))
20
33
  return 1_000_000;
21
34
  return 200_000;
22
35
  }
@@ -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.";
package/dist/tools/all.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // never empty when runAgent plans a turn — an unregistered tool is silently unplannable, which shows up
4
4
  // as "the model called write_file and nothing happened".
5
5
  import "./builtin.js"; // read_file / write_file / bash / job
6
+ import "./runtime.js"; // tool_search / tool_result_read
6
7
  import "./edit.js"; // edit_file
7
8
  import "./search.js"; // grep / glob / ls
8
9
  import "./patch.js"; // apply_patch
@@ -6,11 +6,11 @@
6
6
  // the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
7
7
  // tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
8
8
  // itself triggers the approval gate (the interaction IS the prompt).
9
- import { registerTool } from "./registry.js";
9
+ import { getTool, registerTool } from "./registry.js";
10
10
  /** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
11
11
  * keeps going on its own judgment rather than re-asking or stalling. */
12
12
  export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
13
- registerTool({
13
+ const definition = {
14
14
  name: "ask_user",
15
15
  description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
16
16
  "Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
@@ -23,6 +23,7 @@ registerTool({
23
23
  "In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
24
24
  "blocking, so prefer making a reasonable call over asking when context already answers the question.",
25
25
  kind: "read", // the prompt itself is the interaction; never route it through the approval gate
26
+ classify: () => ({ effect: "interactive", concurrencySafe: false }),
26
27
  input_schema: {
27
28
  type: "object",
28
29
  properties: {
@@ -65,4 +66,8 @@ registerTool({
65
66
  return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
66
67
  }
67
68
  },
68
- });
69
+ };
70
+ registerTool(definition);
71
+ /** Exact registered identity used by the agent loop to grant the engine-owned prompt its pausable
72
+ * active-budget semantics. A plugin that later shadows the public name does not inherit that authority. */
73
+ export const askUserTool = getTool(definition.name);
@@ -13,6 +13,7 @@ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelP
13
13
  import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
14
14
  import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
15
15
  import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
16
+ import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
16
17
  import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
17
18
  const MAX = 100_000;
18
19
  /** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
@@ -123,6 +124,7 @@ registerTool({
123
124
  required: ["path"],
124
125
  },
125
126
  kind: "read",
127
+ concurrencySafe: true,
126
128
  async run(input, ctx) {
127
129
  const p = abs(input.path, ctx.cwd);
128
130
  const denied = sensitiveFileError(p, "read");
@@ -211,8 +213,21 @@ registerTool({
211
213
  required: ["command"],
212
214
  },
213
215
  kind: "exec",
216
+ classify(input) {
217
+ const command = typeof input?.command === "string" ? input.command : "";
218
+ const parts = command ? splitCompound(command) : null;
219
+ const readOnly = !Boolean(input?.background)
220
+ && !!parts?.length
221
+ && parts.every(isReadOnlyCommand);
222
+ return readOnly
223
+ ? { effect: "read", concurrencySafe: true }
224
+ : { effect: "exec", concurrencySafe: false };
225
+ },
214
226
  requiresProjectWorkspace: true,
215
227
  async run(input, ctx) {
228
+ if (input.background !== undefined && typeof input.background !== "boolean") {
229
+ return "Error: `background` must be a boolean (true or false), not a string or another truthy value.";
230
+ }
216
231
  const protectedReason = sensitiveShellCommandReason(String(input.command ?? ""), ctx.cwd);
217
232
  if (protectedReason) {
218
233
  return (`Blocked: shell command crosses Hara's protected secret boundary (${protectedReason}). ` +
@@ -307,7 +322,13 @@ registerTool({
307
322
  },
308
323
  required: ["action"],
309
324
  },
310
- kind: "read", // manages only the agent's own background jobs; safe to run unconfirmed
325
+ kind: "exec", // conservative default; list/tail are downgraded per input, kill remains approval-gated
326
+ classify(input) {
327
+ const action = String(input?.action ?? "");
328
+ return action === "list" || action === "tail"
329
+ ? { effect: "read", concurrencySafe: true }
330
+ : { effect: "exec", concurrencySafe: false, destructive: action === "kill" };
331
+ },
311
332
  async run(input) {
312
333
  const action = String(input.action);
313
334
  if (action === "list") {