@hncode/hncode 0.5.2 → 0.5.5

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 (45) hide show
  1. package/package.json +38 -38
  2. package/src/agent.js +172 -19
  3. package/src/cache.js +175 -130
  4. package/src/clipboard.js +69 -2
  5. package/src/config.js +10 -3
  6. package/src/index.js +29 -5
  7. package/src/keybindings.js +25 -3
  8. package/src/llm.js +54 -6
  9. package/src/mcp.js +419 -384
  10. package/src/plugin.js +95 -10
  11. package/src/remote.js +307 -289
  12. package/src/session-hub.js +37 -0
  13. package/src/settings-schema.js +2 -0
  14. package/src/shell-safety.js +610 -562
  15. package/src/swarm-progress.js +1 -1
  16. package/src/team/mailbox.js +216 -139
  17. package/src/team/permission.js +121 -0
  18. package/src/team/protocol.js +167 -0
  19. package/src/team/runner.js +127 -12
  20. package/src/team/spawn.js +63 -5
  21. package/src/team/store.js +31 -4
  22. package/src/team/tasks.js +15 -2
  23. package/src/team/team.js +51 -16
  24. package/src/team-panel.js +418 -20
  25. package/src/term-caps.js +457 -410
  26. package/src/term.js +142 -26
  27. package/src/tool-result.js +50 -2
  28. package/src/tools/agent.js +464 -436
  29. package/src/tools/bash.js +53 -14
  30. package/src/tools/diagnostics.js +13 -0
  31. package/src/tools/edit.js +727 -577
  32. package/src/tools/fetch-url.js +118 -5
  33. package/src/tools/glob.js +5 -3
  34. package/src/tools/grep.js +19 -5
  35. package/src/tools/ignore.js +17 -3
  36. package/src/tools/index.js +17 -3
  37. package/src/tools/read.js +13 -2
  38. package/src/tools/shell.js +145 -134
  39. package/src/tools/tasks.js +214 -141
  40. package/src/tools/team.js +13 -0
  41. package/src/tools/utils.js +12 -3
  42. package/src/tools/web-search.js +85 -34
  43. package/src/tui.js +2089 -714
  44. package/src/update.js +38 -3
  45. package/src/web.js +47 -1
package/package.json CHANGED
@@ -1,38 +1,38 @@
1
- {
2
- "name": "@hncode/hncode",
3
- "version": "0.5.2",
4
- "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
- "type": "module",
6
- "main": "src/index.js",
7
- "bin": {
8
- "hncode": "bin/hncode"
9
- },
10
- "files": [
11
- "bin/",
12
- "src/",
13
- "LICENSE",
14
- "README.md",
15
- "README_CN.md",
16
- "package.json"
17
- ],
18
- "scripts": {
19
- "preinstall": "node bin/preinstall.cjs",
20
- "install": "node bin/install.cjs",
21
- "i18n": "node tools/sync-i18n.mjs",
22
- "i18n:check": "node tools/sync-i18n.mjs --check",
23
- "test": "node --test"
24
- },
25
- "engines": {
26
- "node": ">=20"
27
- },
28
- "license": "PolyForm-Noncommercial-1.0.0",
29
- "author": "NiceHello666",
30
- "keywords": [
31
- "cli",
32
- "agent",
33
- "coding",
34
- "ai",
35
- "tui"
36
- ],
37
- "dependencies": {}
38
- }
1
+ {
2
+ "name": "@hncode/hncode",
3
+ "version": "0.5.5",
4
+ "description": "hncode — A cyan-blue TUI AI coding agent with zero runtime dependencies.",
5
+ "type": "module",
6
+ "main": "src/index.js",
7
+ "bin": {
8
+ "hncode": "bin/hncode"
9
+ },
10
+ "files": [
11
+ "bin/",
12
+ "src/",
13
+ "LICENSE",
14
+ "README.md",
15
+ "README_CN.md",
16
+ "package.json"
17
+ ],
18
+ "scripts": {
19
+ "preinstall": "node bin/preinstall.cjs",
20
+ "install": "node bin/install.cjs",
21
+ "i18n": "node tools/sync-i18n.mjs",
22
+ "i18n:check": "node tools/sync-i18n.mjs --check",
23
+ "test": "node --test \"test/**/*.test.mjs\""
24
+ },
25
+ "engines": {
26
+ "node": ">=20"
27
+ },
28
+ "license": "PolyForm-Noncommercial-1.0.0",
29
+ "author": "NiceHello666",
30
+ "keywords": [
31
+ "cli",
32
+ "agent",
33
+ "coding",
34
+ "ai",
35
+ "tui"
36
+ ],
37
+ "dependencies": {}
38
+ }
package/src/agent.js CHANGED
@@ -5,7 +5,7 @@
5
5
  import { LLM, setToolsList } from './llm.js';
6
6
  import { tools, combinedTools, getTool, llmTools } from './tools/index.js';
7
7
  import { DELEGATION_TOOLS } from './subagent-types.js';
8
- import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
8
+ import { estimateMessagesTokens, estimateRequestOverhead, estimateTokens, measureToolDefTokens, messageTokens, TokenLedger, forgetMessageTokens } from './term.js';
9
9
  import { runHooks, runPatch, runPatchSync } from './plugin.js';
10
10
  import { loadHooks, runShellHooks } from './hooks.js';
11
11
  import {
@@ -101,7 +101,9 @@ function clipToTokens(text, maxTokens) {
101
101
  // * it is an assistant message that still has tool calls, or
102
102
  // * the message after the cut is a tool result, or
103
103
  // * the prefix ends inside an open tool exchange (more results to come).
104
- function canSplitAfter(messages, index) {
104
+ // Exported for the headless -p path (index.js), which must trim a resumed
105
+ // history on the same legal boundaries the compaction logic uses.
106
+ export function canSplitAfter(messages, index) {
105
107
  if (index < 0 || index >= messages.length) return false;
106
108
  const m = messages[index];
107
109
  if (!m) return false;
@@ -230,6 +232,33 @@ function toolResultLimitsEnabled() {
230
232
  * Normalised here, at the single point every tool result passes through, rather than in the
231
233
  * team tools: a second tool family making the same choice cannot reintroduce the bug.
232
234
  */
235
+ /**
236
+ * Settle `promise`, or reject as soon as `signal` aborts — whichever happens first.
237
+ *
238
+ * A tool is GIVEN the abort signal but cannot be forced to observe it. Built-ins do (Bash kills its
239
+ * process tree); a plugin tool that ignores it kept running, and the caller's `await` kept waiting, so
240
+ * pressing Esc during a slow plugin tool changed nothing on screen until the plugin finished on its own.
241
+ *
242
+ * Racing ends the WAIT regardless. The abandoned promise is left to settle detached — an unobserved
243
+ * rejection would otherwise surface as an unhandled rejection — and the turn moves on, which is what
244
+ * the key was asking for. It cannot stop the plugin's work, and this comment says so rather than
245
+ * implying otherwise.
246
+ */
247
+ function raceWithAbort(promise, signal) {
248
+ if (!signal) return promise;
249
+ if (signal.aborted) return Promise.reject(new Error('aborted'));
250
+ return new Promise((resolve, reject) => {
251
+ const onAbort = () => reject(new Error('aborted'));
252
+ signal.addEventListener('abort', onAbort, { once: true });
253
+ Promise.resolve(promise).then(
254
+ (v) => { signal.removeEventListener('abort', onAbort); resolve(v); },
255
+ (e) => { signal.removeEventListener('abort', onAbort); reject(e); },
256
+ );
257
+ // A tool that resolves AFTER the abort must not become an unhandled rejection.
258
+ Promise.resolve(promise).catch(() => {});
259
+ });
260
+ }
261
+
233
262
  function toolResultText(value) {
234
263
  if (typeof value === 'string') return value;
235
264
  if (value == null) return '';
@@ -333,7 +362,26 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
333
362
  // backed by anything. Off unless /swarm turned the mode on; the Agent tool
334
363
  // stays available for a single, deliberate delegation.
335
364
  .filter((t) => !(t.name === 'AgentSwarm' && !cfg.swarm));
365
+ // Kept as the process-wide FALLBACK for LLM instances that never register
366
+ // their own list (doctor's connectivity probe). Every real agent registers
367
+ // its list on its own LLM below — requests no longer read whichever Agent
368
+ // happened to be constructed last.
336
369
  setToolsList(filtered);
370
+ // THE REAL COST OF THE TOOLS, measured once per agent.
371
+ //
372
+ // The context gauge charged a flat 8 tokens per tool, which is short by an order of magnitude — the
373
+ // schemas of these 68 tools come to about 15 900 tokens. That figure was missing from every
374
+ // usage estimate, so the app's own count ran tens of thousands below the provider's.
375
+ //
376
+ // Measured here because this is the only place that knows the FINAL set: `toolFilter`, the
377
+ // subagent block and the swarm gate above all change what is sent, and a number derived from
378
+ // `cfg.toolFilter` alone would price tools the model never sees.
379
+ this.toolDefTokens = measureToolDefTokens(filtered);
380
+ // ALSO on `cfg`, because the TUI's gauge writers live outside this object and price a request as
381
+ // `estimateMessagesTokens(...) + cfg.toolDefTokens`. Without this line those readers saw `undefined`
382
+ // and silently fell back to zero, so every restore and every /trim reported a number short by the
383
+ // whole tool block — the same fixed gap, from a different cause.
384
+ if (this.cfg && typeof this.cfg === 'object') this.cfg.toolDefTokens = this.toolDefTokens;
337
385
  // A block for the direct-call path too: the model can still emit a tool call
338
386
  // for a tool that is not exposed, so refuse it here rather than at execution.
339
387
  this.noDelegation = !!cfg.noDelegation;
@@ -385,10 +433,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
385
433
  this.cfg = cfg;
386
434
  }
387
435
  this.llm = new LLM(cfg);
436
+ // THIS agent's tool defs ride on THIS instance. The module-global was overwritten
437
+ // by every `new Agent()` in the process — a subagent, swarm member or teammate
438
+ // constructed while a parent request was being built swapped the parent's tool
439
+ // list mid-turn, silently stripping Edit/Bash from its next request.
440
+ this.llm.setTools(filtered);
388
441
  this.stopRequested = false;
389
442
  // Auto-compaction is allowed at most once per TURN (not per step). Reset here so
390
443
  // a caller that reuses one Agent across turns still gets a fresh allowance.
391
444
  this._compactedThisTurn = false;
445
+ // THE PROMPT-CACHE BOUNDARY: how many messages an earlier request of this turn
446
+ // has already sent. Everything below that index is stable and can carry a cache
447
+ // breakpoint. Reset per turn because a new turn opens with a new user message, so
448
+ // the previous turn's boundary would describe a prefix this one never sent.
449
+ this._stableEnd = 0;
392
450
  // Rolling context-token total for `this.messages` (see usedTokens). It holds no
393
451
  // messages of its own, only the count of what it has already priced, so a
394
452
  // compaction that replaces the array simply falls out of the fast path.
@@ -441,10 +499,19 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
441
499
  // rescan priced the entire transcript — Read results included — several times per
442
500
  // step for a number that changes by one message.
443
501
  let total = this._ledger.sync(this.messages, null);
444
- if (Array.isArray(this.cfg.toolFilter)) total += this.cfg.toolFilter.length * 8;
502
+ // THE TOOL DEFINITIONS ARE PART OF WHAT IS SENT, and they are not messages.
503
+ //
504
+ // This charged `toolFilter.length * 8` — and only when a filter was set, so an ordinary turn
505
+ // added NOTHING at all. Measured, the schemas of the 68 registered tools come to about 15 900
506
+ // tokens, which is why the app's own gauge ran tens of thousands below the provider's report.
507
+ //
508
+ // `toolDefTokens` is measured once in the constructor, from the FINAL filtered set.
509
+ total += Number.isFinite(this.toolDefTokens) ? this.toolDefTokens : 0;
445
510
  return total;
446
511
  }
447
512
 
513
+
514
+
448
515
  // Where to cut the history for compaction. Walks back from the end accumulating a
449
516
  // token budget, but only accepts indices the boundary rule allows (so the kept
450
517
  // slice can never begin on an orphan tool result). Returns how many messages to
@@ -531,11 +598,14 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
531
598
  { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
532
599
  { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
533
600
  ];
534
- return { split, dropped };
601
+ // `kept` is returned, not just its length: the caller reports how many messages survived, and it
602
+ // has no other way to know. It was `keep.length` at the call site referring to a local that lives
603
+ // only in here — a ReferenceError raised inside `run()`, which reached unhandledRejection and exited
604
+ // the whole TUI at the moment compaction fired.
605
+ return { split, dropped, kept: keep.length };
535
606
  }
536
607
 
537
608
  async run() {
538
- // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
539
609
  // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
540
610
  // the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
541
611
  // never suppress auto-compaction on the next message. (The constructor also
@@ -591,10 +661,15 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
591
661
  this._truncated = true;
592
662
  break;
593
663
  case 'usage':
594
- // Real token accounting from the provider, as opposed to the estimates
595
- // usedTokens() produces. Accumulated on the AGENT (so it survives across
596
- // the steps of one turn and can be read by anything holding the agent)
597
- // and forwarded for the TUI to fold into the session totals.
664
+ // Provider accounting, accumulated on the AGENT so it survives across the steps of one turn.
665
+ //
666
+ // IT DOES NOT FEED THE CONTEXT GAUGE. `usage.input` was wired into both the gauge and a
667
+ // calibration here, and removed from both: it counts the request that already went out while the
668
+ // estimate counts the one about to, so the number jumped between two definitions depending on
669
+ // which ran last.
670
+ //
671
+ // What it IS used for: cost and `/usage`, where it is the only honest source, and the turn's
672
+ // output counter.
598
673
  this.usage = addUsage(this.usage, e.usage);
599
674
  this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
600
675
  break;
@@ -667,9 +742,38 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
667
742
  // below 85%, so the reply has room to be generated.
668
743
  const maxCtx = this.cfg.maxContextTokens || 512000;
669
744
  let usedNow = this.usedTokens();
745
+ // THE MESSAGE THAT TRIGGERED THIS TURN IS NOT YET IN A REQUEST.
746
+ //
747
+ // The user's message is appended before `run()` is entered, so a fresh turn that pushes the
748
+ // history over the threshold would compact FIRST and then send — summarising a conversation that
749
+ // now includes the message it is about to answer, and paying a summary round-trip before the model
750
+ // has seen the request at all.
751
+ //
752
+ // Excluding the trailing user message from the TRIGGER means the decision is made on what the
753
+ // previous turn left behind, which is the history the message is joining. If adding it would still
754
+ // overflow, `needsRoom` catches that on the NEXT pass — after the model has answered something —
755
+ // and `clipToTokens` bounds the request itself in the meantime.
756
+ //
757
+ // Counted as a subtraction rather than by pruning the array: the message must stay in `messages`,
758
+ // it just must not decide whether to compact.
759
+ let triggerTokens = usedNow;
760
+ for (let i = this.messages.length - 1; i >= 0; i--) {
761
+ const m = this.messages[i];
762
+ if (!m) continue;
763
+ // Only skip a TRAILING run of user messages — a steering note queued mid-turn, or the prompt that
764
+ // opened this turn. A user message further back is part of the history being measured.
765
+ if (m.role !== 'user' || m._harness) break;
766
+ triggerTokens -= messageTokens(m, this.cfg);
767
+ }
768
+ triggerTokens = Math.max(0, triggerTokens);
670
769
  // `reservedContextSize` (kimi): a request leaving less than this much headroom
671
770
  // is compacted even below the trigger ratio, so the REPLY has room to be
672
771
  // generated. A request just under the trigger could otherwise still fail.
772
+ //
773
+ // Measured on `usedNow`, NOT on `triggerTokens`: the headroom question is about the request that
774
+ // will actually be sent, and that one does include the new message. The two thresholds ask
775
+ // different things — "is the history full" versus "will this request fit" — so they read different
776
+ // numbers on purpose.
673
777
  const reserved = this.cfg.reservedContextTokens || 50_000;
674
778
  const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
675
779
  // Trigger ratio is configurable (/auto-compact threshold, config.toml
@@ -682,7 +786,8 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
682
786
  // kept shrinking.
683
787
  const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
684
788
  && !this._compactedThisTurn
685
- && (usedNow >= maxCtx * trigger || needsRoom);
789
+ // `triggerTokens`, not `usedNow`: the history WITHOUT the message that opened this turn.
790
+ && (triggerTokens >= maxCtx * trigger || needsRoom);
686
791
  if (shouldCompact) {
687
792
  const before = usedNow;
688
793
  this._compactedThisTurn = true;
@@ -748,8 +853,18 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
748
853
  this.onEvent({ type: 'stopped' });
749
854
  break;
750
855
  }
856
+ // The summarizer FAILED (requestText swallowed a network/429 error — even
857
+ // after its retry — and returned null) or came back capped-empty. Trimming
858
+ // now would replace real history with "(no summary available)": the exact
859
+ // silent data loss the comment above warns about. Leave the history intact
860
+ // and let the turn continue un-compacted; if the fat request genuinely
861
+ // cannot fit, the provider's context-length error is an honest, recoverable
862
+ // failure instead of a vanished transcript.
863
+ if (!summary && this.messages.length > 0) {
864
+ this.onEvent({ type: 'compaction_cancelled' });
865
+ } else {
751
866
  // NOW trim, at a split point the boundary rule allows.
752
- const { split, dropped: droppedCount } = this.applyCompactionSplit(maxCtx, summary);
867
+ const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
753
868
  // The array was REPLACED, so every count the ledger held describes messages
754
869
  // that no longer exist. `sync` would notice on its own (the first/last
755
870
  // identities no longer match), but resetting here states the intent at the
@@ -757,13 +872,14 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
757
872
  this._ledger.reset();
758
873
  usedNow = this.usedTokens();
759
874
  this.onEvent({
760
- type: 'compacted', before, after: usedNow, kept: keep.length,
875
+ type: 'compacted', before, after: usedNow, kept,
761
876
  dropped: droppedCount, summary,
762
877
  });
763
878
  // SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
764
879
  // summary or record the shrink. Never blocks.
765
880
  await runShellHooks(this.hookConfig, 'PostCompact',
766
- { before, after: usedNow, kept: keep.length, dropped: droppedCount, summary }, this.cfg.workspace);
881
+ { before, after: usedNow, kept, dropped: droppedCount, summary }, this.cfg.workspace);
882
+ }
767
883
  }
768
884
  // ---- tool-result trimming ----
769
885
  // The cheap pass, and the one that actually runs in practice: a history is
@@ -820,11 +936,31 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
820
936
  }
821
937
  }
822
938
 
939
+ // THE CACHE BOUNDARY: everything BEFORE this index was already sent in an
940
+ // earlier request of this turn and has not changed since. It is captured
941
+ // here — AFTER the previous step's tool results are committed, BEFORE the
942
+ // steering drain appends anything new — so it marks exactly the prefix the
943
+ // provider can reuse.
944
+ //
945
+ // This used to be derived from the message COUNT ("keep the last two"), which
946
+ // is the wrong question. One agent step appends an assistant message plus one
947
+ // tool result PER call, so a step that read four files left five fresh
948
+ // messages; a breakpoint two back sat in the middle of that and the provider
949
+ // had to re-prefill all of it. Counting the boundary from the previous
950
+ // request instead means a step of any size leaves exactly one stable prefix.
951
+ // A COMPACTION REPLACED THE ARRAY, so the boundary measured against the old
952
+ // one no longer describes anything in this one. Clamping would place the
953
+ // breakpoint at a message that never existed when the value was taken.
954
+ if (this._stableEnd > this.messages.length) this._stableEnd = 0;
955
+ if (this._stableEnd !== this.messages.length) this._stableEnd = this.messages.length;
956
+ const cacheEnd = this._stableEnd - 1;
957
+
958
+
823
959
  // Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
824
- // message array being sent (and may mutate it); `onAfterRequest` fires once
960
+ // message array being sent (and may mutate it); `onAfterResponse` fires once
825
961
  // the stream has been consumed.
826
962
  await runHooks('onBeforeRequest', this.messages, this.cfg);
827
- await this.llm.request(this.messages, handle);
963
+ await this.llm.request(this.messages, handle, cacheEnd);
828
964
  await runHooks('onAfterRequest', this.messages, this.cfg);
829
965
 
830
966
  // An abort ends the turn: keep whatever text arrived, then stop.
@@ -961,13 +1097,20 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
961
1097
  return { ok: false, result, editDiff: null, media: false };
962
1098
  }
963
1099
 
964
- // Permission check before executing each tool
1100
+ // Permission check before executing each tool.
1101
+ //
1102
+ // The hook may answer with a BOOLEAN (the original contract) or with `{ approved, error }`. The
1103
+ // object form exists for the team mailbox, where a refusal travels with a reason the model can act
1104
+ // on: "Blocked by user" tells it nothing about what to try instead, and a member that has been
1105
+ // refused for a specific reason can re-plan.
965
1106
  let result = null;
966
1107
  let ok = true;
967
1108
  if (this.onApproval) {
968
- const approved = await this.onApproval(tc.name, tc.args);
1109
+ const verdict = await this.onApproval(tc.name, tc.args);
1110
+ const approved = verdict && typeof verdict === 'object' ? verdict.approved : verdict;
969
1111
  if (!approved) {
970
- result = `Blocked by user: tool "${tc.name}" was rejected.`;
1112
+ const why = verdict && typeof verdict === 'object' && verdict.error ? `: ${verdict.error}` : '.';
1113
+ result = `Blocked by user: tool "${tc.name}" was rejected${why}`;
971
1114
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
972
1115
  return { ok: false, result, editDiff: null, media: false };
973
1116
  }
@@ -1004,7 +1147,17 @@ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
1004
1147
  try { const out = await runPatch('toolDispatch', { name: tc.name, args: tc.args, ctx: this.ctx }, (c) => c); if (out) { _args = out.args !== undefined ? out.args : tc.args; if (out.skip) _skip = true; } } catch (e) { console.error('[hncode-plugin] toolDispatch error:', e.message); }
1005
1148
  if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
1006
1149
  else {
1007
- const raw = await tool.execute(_args, this.ctx);
1150
+ // RACE THE ABORT SIGNAL, do not merely pass it.
1151
+ //
1152
+ // `ctx.signal` is handed to every tool, but nothing enforces that a tool LISTENS. A built-in
1153
+ // does — Bash kills its process tree — while a plugin tool that ignores it kept running, and
1154
+ // this `await` kept waiting for it: Esc reached `interrupt()`, aborted the controller, and the
1155
+ // row still said "Using …" until the plugin happened to finish.
1156
+ //
1157
+ // Aborting the signal now ends the wait immediately whatever the tool is doing. The abandoned
1158
+ // promise keeps running detached — a plugin that ignores the signal cannot be forced to stop —
1159
+ // but the TURN is no longer held by it, which is what pressing Esc actually asked for.
1160
+ const raw = await raceWithAbort(tool.execute(_args, this.ctx), this.toolAbort.signal);
1008
1161
  // `null` means the tool returned a structured MEDIA result: keep the object so the
1009
1162
  // image blocks can be built from it. Only a plain value is stringified.
1010
1163
  const text = toolResultText(raw);