@hncode/hncode 0.5.1 → 0.5.4

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 (60) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +2 -2
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +279 -48
  6. package/src/cache.js +175 -130
  7. package/src/cell-buffer.js +5 -0
  8. package/src/clipboard.js +111 -8
  9. package/src/context-bar.js +46 -5
  10. package/src/doctor.js +7 -2
  11. package/src/git.js +35 -4
  12. package/src/index.js +29 -5
  13. package/src/keybindings.js +25 -3
  14. package/src/llm.js +60 -6
  15. package/src/mcp.js +419 -384
  16. package/src/permissions.js +29 -7
  17. package/src/plugin.js +95 -10
  18. package/src/registry-browser.js +21 -6
  19. package/src/remote.js +307 -289
  20. package/src/session-core.js +203 -0
  21. package/src/session-host.js +213 -0
  22. package/src/session-view.js +110 -0
  23. package/src/settings-schema.js +12 -9
  24. package/src/shell-safety.js +610 -473
  25. package/src/swarm-progress.js +348 -293
  26. package/src/tasks-browser.js +4 -1
  27. package/src/team/constants.js +20 -0
  28. package/src/team/context.js +89 -0
  29. package/src/team/mailbox.js +216 -0
  30. package/src/team/permission.js +121 -0
  31. package/src/team/protocol.js +167 -0
  32. package/src/team/runner.js +462 -0
  33. package/src/team/spawn.js +305 -0
  34. package/src/team/store.js +172 -0
  35. package/src/team/tasks.js +177 -0
  36. package/src/team/team.js +346 -0
  37. package/src/team-panel.js +624 -0
  38. package/src/term-caps.js +457 -410
  39. package/src/term.js +162 -19
  40. package/src/tool-result.js +70 -7
  41. package/src/tools/agent-swarm.js +114 -11
  42. package/src/tools/agent.js +464 -386
  43. package/src/tools/bash.js +53 -14
  44. package/src/tools/diagnostics.js +13 -0
  45. package/src/tools/edit.js +428 -50
  46. package/src/tools/git.js +15 -3
  47. package/src/tools/glob.js +5 -3
  48. package/src/tools/grep.js +19 -5
  49. package/src/tools/ignore.js +17 -3
  50. package/src/tools/index.js +19 -3
  51. package/src/tools/read.js +2 -1
  52. package/src/tools/shell.js +145 -134
  53. package/src/tools/tasks.js +214 -141
  54. package/src/tools/team.js +411 -0
  55. package/src/tools/utils.js +12 -3
  56. package/src/tools/web-search.js +53 -40
  57. package/src/tui.js +7496 -5366
  58. package/src/update.js +320 -115
  59. package/src/web-daemon.js +27 -4
  60. package/src/web-live.js +250 -0
package/src/agent.js CHANGED
@@ -3,9 +3,9 @@
3
3
  // model answers without tool calls (or a step/interrupt condition is hit).
4
4
 
5
5
  import { LLM, setToolsList } from './llm.js';
6
- import { tools, getTool, llmTools } from './tools/index.js';
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;
@@ -140,9 +142,10 @@ function hashKey(s) {
140
142
  h = (h * 0x01000193) >>> 0;
141
143
  }
142
144
  return h.toString(36) + '-' + str.length.toString(36);
143
- return h.toString(36) + '-' + str.length.toString(36);
144
145
  }
145
146
 
147
+ // Render what an Edit actually changed, for the MODEL's tool result.
148
+
146
149
  // Render what an Edit actually changed, for the MODEL's tool result.
147
150
  //
148
151
  // The UI has always received the edit through `editDiff` (see the tool_result
@@ -166,10 +169,18 @@ export function formatEditDiffForModel(diff, maxLines = 400) {
166
169
  const out = [];
167
170
  out.push('');
168
171
  out.push('--- diff ---');
172
+ // Unchanged lines above and below, unmarked, so the change can be LOCATED. Without them this
173
+ // was a quoted block with no indication of where in the file it landed — and identical-looking
174
+ // code appears in many places, so neither the model nor the user could confirm the edit hit
175
+ // the intended one rather than a same-text block further down.
176
+ const before = (diff.context && Array.isArray(diff.context.before)) ? diff.context.before : [];
177
+ const after = (diff.context && Array.isArray(diff.context.after)) ? diff.context.after : [];
178
+ for (const c of before) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
169
179
  let n = startLine;
170
180
  for (const l of oldLines) out.push(' ' + String(n++).padStart(4) + ' - ' + l);
171
181
  n = startLine;
172
182
  for (const l of newLines) out.push(' ' + String(n++).padStart(4) + ' + ' + l);
183
+ for (const c of after) out.push(' ' + String(c.n).padStart(4) + ' ' + c.text);
173
184
  // A whole-file edit (a Write-sized `new`) must not be echoed back in full.
174
185
  if (out.length > maxLines + 3) {
175
186
  const hidden = out.length - (maxLines + 3);
@@ -205,6 +216,64 @@ function toolResultLimitsEnabled() {
205
216
  return !/^(0|off|false|no)$/i.test(String(process.env.HNCODE_TOOL_RESULT_LIMITS || ''));
206
217
  }
207
218
 
219
+ /**
220
+ * A tool's return value, as the STRING the model can be sent.
221
+ *
222
+ * Every tool in `src/tools/` returns a plain string except the team tools, which return
223
+ * `{ content }` / `{ error }`. That object went into the `tool` message verbatim, and a provider
224
+ * that expects a string rejects the NEXT request with `missing type field in content item at
225
+ * index 0` — so a teammate's first `SendMessage` worked and then the conversation died, one
226
+ * message in. The symptom was a team that says hello and then never answers again.
227
+ *
228
+ * Returns `null` for the one shape that must stay an OBJECT: `ReadMediaFile` returns
229
+ * `{ text, media: [...] }`, and the media parts travel as structured blocks the caller builds
230
+ * from the object. Stringifying that would attach no image at all, so the caller keeps it.
231
+ *
232
+ * Normalised here, at the single point every tool result passes through, rather than in the
233
+ * team tools: a second tool family making the same choice cannot reintroduce the bug.
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
+
262
+ function toolResultText(value) {
263
+ if (typeof value === 'string') return value;
264
+ if (value == null) return '';
265
+ if (typeof value === 'object') {
266
+ // Leave a media result alone — the caller needs the parts, not a sentence about them.
267
+ if (Array.isArray(value.media)) return null;
268
+ if (typeof value.content === 'string') return value.content;
269
+ if (typeof value.error === 'string') return value.error;
270
+ if (typeof value.text === 'string') return value.text;
271
+ try { return JSON.stringify(value); } catch { return String(value); }
272
+ }
273
+ return String(value);
274
+ }
275
+
276
+
208
277
 
209
278
  export const SYSTEM_PROMPT = `You are hncode, a coding agent in the user's terminal on ${process.platform}.
210
279
  You work inside a workspace directory using the tools provided.
@@ -280,9 +349,9 @@ export class Agent {
280
349
  // even if the filter would have allowed them. The catalog already excludes
281
350
  // them from every profile; this is the hard backstop that also covers the
282
351
  // `null` (= everything) case, so a subagent can never spawn another agent.
283
- const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
284
- ? tools.filter((t) => cfg.toolFilter.includes(t.name))
285
- : tools
352
+ const filtered = (Array.isArray(cfg.toolFilter) && cfg.toolFilter.length
353
+ ? combinedTools().filter((t) => cfg.toolFilter.includes(t.name))
354
+ : combinedTools()
286
355
  )
287
356
  .filter((t) => !(cfg.noDelegation && DELEGATION_TOOLS.includes(t.name)))
288
357
  // AgentSwarm is gated behind swarm mode, like Agent is gated behind the user
@@ -293,7 +362,26 @@ export class Agent {
293
362
  // backed by anything. Off unless /swarm turned the mode on; the Agent tool
294
363
  // stays available for a single, deliberate delegation.
295
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.
296
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;
297
385
  // A block for the direct-call path too: the model can still emit a tool call
298
386
  // for a tool that is not exposed, so refuse it here rather than at execution.
299
387
  this.noDelegation = !!cfg.noDelegation;
@@ -345,10 +433,20 @@ export class Agent {
345
433
  this.cfg = cfg;
346
434
  }
347
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);
348
441
  this.stopRequested = false;
349
442
  // Auto-compaction is allowed at most once per TURN (not per step). Reset here so
350
443
  // a caller that reuses one Agent across turns still gets a fresh allowance.
351
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;
352
450
  // Rolling context-token total for `this.messages` (see usedTokens). It holds no
353
451
  // messages of its own, only the count of what it has already priced, so a
354
452
  // compaction that replaces the array simply falls out of the fast path.
@@ -401,10 +499,19 @@ export class Agent {
401
499
  // rescan priced the entire transcript — Read results included — several times per
402
500
  // step for a number that changes by one message.
403
501
  let total = this._ledger.sync(this.messages, null);
404
- 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;
405
510
  return total;
406
511
  }
407
512
 
513
+
514
+
408
515
  // Where to cut the history for compaction. Walks back from the end accumulating a
409
516
  // token budget, but only accepts indices the boundary rule allows (so the kept
410
517
  // slice can never begin on an orphan tool result). Returns how many messages to
@@ -420,9 +527,10 @@ export class Agent {
420
527
  const used = this.usedTokens();
421
528
  const maxSize = Math.max(1, Math.floor((used || (maxCtx || 512000) * 0.85) * keepRatio));
422
529
  const msgs = this.messages || [];
423
- // Never cut inside the LEADING system block. `msgs[0]` is the assembled system
424
- // prompt, and canSplitAfter() would happily allow a split right after it — which
425
- // would drop the prompt itself and leave the request with no instructions.
530
+ // How far the LEADING system block runs. A cut before `floor` would land inside it,
531
+ // splitting the assembled system prompt across the drop boundary. The block itself is
532
+ // carried over by the trim (renamed `leadingSystem` there), because a cut can only
533
+ // ever land at or after its end.
426
534
  let floor = 0;
427
535
  while (floor < msgs.length && msgs[floor] && msgs[floor].role === 'system') floor++;
428
536
  let recent = 1;
@@ -435,12 +543,68 @@ export class Agent {
435
543
  // call. It is also the memoised form, so repeating this walk after a large tool
436
544
  // result is a WeakMap hit rather than a re-measure.
437
545
  size += messageTokens(msgs[split], null);
438
- if (split > floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
546
+ // The guard the leading system block needs, and the reverse of the `split > floor`
547
+ // this used to test. A cut at `split` drops `[0..split-1]`, so `split >= floor`
548
+ // keeps the cut at or after the block's end and never inside it — which is what
549
+ // the block's comment above asks for, and what `split > floor` inverted.
550
+ if (split >= floor && canSplitAfter(msgs, split - 1)) bestSplit = split;
439
551
  if (size >= maxSize && bestSplit !== undefined) break;
440
552
  }
441
553
  return bestSplit ?? 0;
442
554
  }
443
555
 
556
+ /**
557
+ * Trim the history at a legal cut and append the summary + continuation note.
558
+ *
559
+ * Extracted from `run()` so it can be tested against the REAL implementation. A test that
560
+ * re-implemented these four lines was green against a duplicated system prompt — the copy
561
+ * carried its own guard, which is the failure mode of testing a restatement.
562
+ *
563
+ * Returns `{ split, dropped }`.
564
+ *
565
+ * Shape: `[ ...lead, ...kept, summary, continuation ]`.
566
+ *
567
+ * The summary is a USER message wrapped in a `<system-reminder>` (kimi's shape), not a
568
+ * `system` one. Two reasons:
569
+ * * the TUI rebuilds the system prompt from scratch each turn and SKIPS every `system`
570
+ * entry in the stored history, so a system summary was dropped before the model saw it;
571
+ * * the turn-end save filters `system` out of the session, so it was not persisted either
572
+ * — history trimmed AND summary lost at both ends, which made "compaction" pure data loss.
573
+ * The continuation note goes LAST so the model's next move is "keep going" rather than
574
+ * "answer the summary".
575
+ */
576
+ applyCompactionSplit(maxCtx, summary) {
577
+ const split = this.computeCompactSplit(maxCtx);
578
+ const keep = this.messages.slice(split);
579
+ const dropped = this.messages.length - keep.length;
580
+ // The LEADING system block is carried over explicitly. It sits at the head of the array
581
+ // and the cut drops everything before `split`, so it would otherwise be taken by every
582
+ // compaction — leaving the request with no instructions, which is the loss the `floor`
583
+ // guard in computeCompactSplit exists to prevent.
584
+ let leadingSystem = 0;
585
+ while (leadingSystem < this.messages.length
586
+ && this.messages[leadingSystem] && this.messages[leadingSystem].role === 'system') leadingSystem++;
587
+ // Only what the cut actually REMOVED. `keep` is `slice(split)`, so at `split === 0` it
588
+ // still contains the whole array including the block — prepending the block then sends the
589
+ // system prompt twice, and `llm.js` concatenates every system message into the request, so
590
+ // it would stay doubled in every compacted request from then on.
591
+ const lead = split > 0 ? this.messages.slice(0, leadingSystem) : [];
592
+ const summaryText = summary
593
+ ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
594
+ : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
595
+ this.messages = [
596
+ ...lead,
597
+ ...keep,
598
+ { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
599
+ { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
600
+ ];
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 };
606
+ }
607
+
444
608
  async run() {
445
609
  // Clear any interrupt flag left from a previous (interrupted) turn. This runs at
446
610
  // the start of EVERY turn, so a stalled Esc/Ctrl-C from an earlier compaction can
@@ -497,10 +661,15 @@ export class Agent {
497
661
  this._truncated = true;
498
662
  break;
499
663
  case 'usage':
500
- // Real token accounting from the provider, as opposed to the estimates
501
- // usedTokens() produces. Accumulated on the AGENT (so it survives across
502
- // the steps of one turn and can be read by anything holding the agent)
503
- // 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.
504
673
  this.usage = addUsage(this.usage, e.usage);
505
674
  this.onEvent({ type: 'usage', usage: e.usage, total: this.usage });
506
675
  break;
@@ -573,9 +742,38 @@ export class Agent {
573
742
  // below 85%, so the reply has room to be generated.
574
743
  const maxCtx = this.cfg.maxContextTokens || 512000;
575
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);
576
769
  // `reservedContextSize` (kimi): a request leaving less than this much headroom
577
770
  // is compacted even below the trigger ratio, so the REPLY has room to be
578
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.
579
777
  const reserved = this.cfg.reservedContextTokens || 50_000;
580
778
  const needsRoom = reserved > 0 && reserved < maxCtx && usedNow + reserved >= maxCtx;
581
779
  // Trigger ratio is configurable (/auto-compact threshold, config.toml
@@ -588,7 +786,8 @@ export class Agent {
588
786
  // kept shrinking.
589
787
  const shouldCompact = this.cfg.autoCompact !== false && maxCtx > 0
590
788
  && !this._compactedThisTurn
591
- && (usedNow >= maxCtx * trigger || needsRoom);
789
+ // `triggerTokens`, not `usedNow`: the history WITHOUT the message that opened this turn.
790
+ && (triggerTokens >= maxCtx * trigger || needsRoom);
592
791
  if (shouldCompact) {
593
792
  const before = usedNow;
594
793
  this._compactedThisTurn = true;
@@ -654,30 +853,18 @@ export class Agent {
654
853
  this.onEvent({ type: 'stopped' });
655
854
  break;
656
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 {
657
866
  // NOW trim, at a split point the boundary rule allows.
658
- const split = this.computeCompactSplit(maxCtx);
659
- const keep = this.messages.slice(split);
660
- const droppedCount = this.messages.length - keep.length;
661
- // Shape: [ ...kept, summary, continuation ].
662
- //
663
- // The summary is a USER message wrapped in a <system-reminder> (kimi's
664
- // shape), not a `system` message. Two reasons:
665
- // * the TUI rebuilds the system prompt from scratch each turn and SKIPS
666
- // every `system` entry in the stored history, so a system summary was
667
- // dropped before the model ever saw it;
668
- // * the turn-end save filters `system` out of the session, so it was not
669
- // persisted either — history trimmed AND summary lost at both ends,
670
- // which made "compaction" pure data loss.
671
- // The continuation note goes LAST so the model's next move is "keep going"
672
- // rather than "answer the summary".
673
- const summaryText = summary
674
- ? `${COMPACTION_SUMMARY_PREFIX}\n${summary}`
675
- : `${COMPACTION_SUMMARY_PREFIX}\n(no summary available)`;
676
- this.messages = [
677
- ...keep,
678
- { role: 'user', _harness: true, content: wrapSystemReminder(summaryText) },
679
- { role: 'user', _harness: true, content: wrapSystemReminder(COMPACTION_CONTINUATION_TEXT) },
680
- ];
867
+ const { split, dropped: droppedCount, kept } = this.applyCompactionSplit(maxCtx, summary);
681
868
  // The array was REPLACED, so every count the ledger held describes messages
682
869
  // that no longer exist. `sync` would notice on its own (the first/last
683
870
  // identities no longer match), but resetting here states the intent at the
@@ -685,13 +872,14 @@ export class Agent {
685
872
  this._ledger.reset();
686
873
  usedNow = this.usedTokens();
687
874
  this.onEvent({
688
- type: 'compacted', before, after: usedNow, kept: keep.length,
875
+ type: 'compacted', before, after: usedNow, kept,
689
876
  dropped: droppedCount, summary,
690
877
  });
691
878
  // SHELL HOOK: PostCompact. Fires after the trim, so a hook can archive the
692
879
  // summary or record the shrink. Never blocks.
693
880
  await runShellHooks(this.hookConfig, 'PostCompact',
694
- { before, after: usedNow, kept: keep.length, dropped: droppedCount, summary }, this.cfg.workspace);
881
+ { before, after: usedNow, kept, dropped: droppedCount, summary }, this.cfg.workspace);
882
+ }
695
883
  }
696
884
  // ---- tool-result trimming ----
697
885
  // The cheap pass, and the one that actually runs in practice: a history is
@@ -748,11 +936,31 @@ export class Agent {
748
936
  }
749
937
  }
750
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
+
751
959
  // Plugin hooks around the model round-trip. `onBeforeRequest` sees the exact
752
- // message array being sent (and may mutate it); `onAfterRequest` fires once
960
+ // message array being sent (and may mutate it); `onAfterResponse` fires once
753
961
  // the stream has been consumed.
754
962
  await runHooks('onBeforeRequest', this.messages, this.cfg);
755
- await this.llm.request(this.messages, handle);
963
+ await this.llm.request(this.messages, handle, cacheEnd);
756
964
  await runHooks('onAfterRequest', this.messages, this.cfg);
757
965
 
758
966
  // An abort ends the turn: keep whatever text arrived, then stop.
@@ -889,13 +1097,20 @@ export class Agent {
889
1097
  return { ok: false, result, editDiff: null, media: false };
890
1098
  }
891
1099
 
892
- // 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.
893
1106
  let result = null;
894
1107
  let ok = true;
895
1108
  if (this.onApproval) {
896
- 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;
897
1111
  if (!approved) {
898
- 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}`;
899
1114
  this.onEvent({ type: 'tool_use', id: tc.id, name: tc.name, args: tc.args });
900
1115
  return { ok: false, result, editDiff: null, media: false };
901
1116
  }
@@ -931,7 +1146,23 @@ export class Agent {
931
1146
  let _args = tc.args; let _skip = false;
932
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); }
933
1148
  if (_skip) { result = '[skipped by plugin: ' + tc.name + ']'; ok = true; }
934
- else result = await tool.execute(_args, this.ctx);
1149
+ else {
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);
1161
+ // `null` means the tool returned a structured MEDIA result: keep the object so the
1162
+ // image blocks can be built from it. Only a plain value is stringified.
1163
+ const text = toolResultText(raw);
1164
+ result = text === null ? raw : text;
1165
+ }
935
1166
  } catch (err) {
936
1167
  result = `Error running ${tc.name}: ${err.message}`;
937
1168
  ok = false;