@hncode/hncode 0.4.1 → 0.5.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 (65) hide show
  1. package/README.md +3 -16
  2. package/README_CN.md +1 -15
  3. package/bin/hncode +22 -9
  4. package/package.json +3 -2
  5. package/src/agent.js +145 -33
  6. package/src/bootstrap.js +56 -0
  7. package/src/btw-panel.js +224 -0
  8. package/src/cache.js +3 -2
  9. package/src/cell-buffer.js +173 -0
  10. package/src/ci.js +4 -1
  11. package/src/clipboard.js +202 -43
  12. package/src/colors.js +175 -30
  13. package/src/config.js +376 -34
  14. package/src/context-bar.js +237 -0
  15. package/src/doctor.js +320 -0
  16. package/src/experiments.js +122 -0
  17. package/src/file-history.js +112 -0
  18. package/src/file-tree.js +255 -0
  19. package/src/flush-tick.js +56 -0
  20. package/src/fork.js +44 -0
  21. package/src/fx.js +148 -0
  22. package/src/index.js +123 -11
  23. package/src/keybindings.js +229 -0
  24. package/src/llm.js +355 -34
  25. package/src/lsp.js +401 -0
  26. package/src/notebook.js +232 -0
  27. package/src/output-styles.js +183 -0
  28. package/src/patch.js +248 -0
  29. package/src/permissions.js +130 -1
  30. package/src/plan.js +3 -2
  31. package/src/plugin.js +691 -43
  32. package/src/prompt-presets.js +1 -1
  33. package/src/registry-browser.js +317 -0
  34. package/src/remote.js +289 -0
  35. package/src/rollout.js +151 -0
  36. package/src/row-cache.js +143 -0
  37. package/src/schedule.js +294 -0
  38. package/src/session-approvals.js +86 -0
  39. package/src/session.js +178 -8
  40. package/src/shell-safety.js +473 -0
  41. package/src/side-thread.js +240 -0
  42. package/src/skills.js +27 -0
  43. package/src/stream-buffer.js +350 -0
  44. package/src/subagent-control.js +160 -0
  45. package/src/tasks-browser.js +54 -11
  46. package/src/teleport.js +260 -0
  47. package/src/term-caps.js +411 -0
  48. package/src/term.js +283 -8
  49. package/src/tools/agent-control.js +98 -0
  50. package/src/tools/agent-swarm.js +11 -2
  51. package/src/tools/agent.js +35 -1
  52. package/src/tools/bash.js +17 -3
  53. package/src/tools/diagnostics.js +175 -0
  54. package/src/tools/edit.js +25 -10
  55. package/src/tools/grep.js +62 -20
  56. package/src/tools/ignore.js +38 -14
  57. package/src/tools/index.js +10 -0
  58. package/src/tools/notebook.js +113 -0
  59. package/src/tools/read-media-file.js +10 -3
  60. package/src/tools/shell.js +134 -0
  61. package/src/tui.js +16682 -11665
  62. package/src/web-public/app.css +22 -9
  63. package/src/web-public/app.js +107 -54
  64. package/src/web-public/home.css +3 -3
  65. package/src/web-public/login.css +1 -1
package/src/term.js CHANGED
@@ -167,6 +167,113 @@ function isWideByDefault(c) {
167
167
  || (c >= 0x2B1B && c <= 0x2B1C) || c === 0x2B50 || c === 0x2B55;
168
168
  }
169
169
 
170
+ // ---------------------------------------------------------------------------
171
+ // East Asian AMBIGUOUS width
172
+ // ---------------------------------------------------------------------------
173
+ // Unicode gives every character an East Asian Width class. `A` (Ambiguous) means
174
+ // ONE column on a Western terminal and TWO on a CJK one — the terminal decides, the
175
+ // character does not say. hncode's chrome leans on these heavily, which is why they
176
+ // had to be handled rather than avoided:
177
+ //
178
+ // box rules ─ │ ╭ ╮ ╰ ╯ ┌ ┐ └ ┘ ├ ┤ ┬ ┴ ┼
179
+ // separators · … — –
180
+ // markers ↑ ↓ ← → ❯ ● ○ ✓ ✗
181
+ // math ± × ÷ ≠ ≤ ≥ ∞
182
+ //
183
+ // Measuring them as 1 column on a CJK terminal made every row containing one N columns
184
+ // short, so the row's right edge landed N columns past the panel it belonged to —
185
+ // "the text pushes the wall right". The panel's own background could not hide it
186
+ // either: the overflow is REAL cells on screen, not a measurement disagreement inside
187
+ // one row.
188
+ //
189
+ // Default: follow the LOCALE. A CJK locale is the standard reason a terminal treats
190
+ // EAW=A as wide, and it is the only signal available before anything is drawn — the
191
+ // code page does not say (a UTF-8 Windows console still renders them wide), and no
192
+ // terminal query reports it.
193
+ let ambiguousWide = null; // lazily resolved so a test can set the env first
194
+
195
+ /** EAW=A ranges (Unicode 15), ascending — `inRanges` binary-searches this. */
196
+ const AMBIGUOUS = [
197
+ [0x00A1, 0x00A1], [0x00A4, 0x00A4], [0x00A7, 0x00A8], [0x00AA, 0x00AA],
198
+ [0x00AD, 0x00AE], [0x00B0, 0x00B4], [0x00B6, 0x00BA], [0x00BC, 0x00BF],
199
+ [0x00C6, 0x00C6], [0x00D0, 0x00D0], [0x00D7, 0x00D8], [0x00DE, 0x00E1],
200
+ [0x00E6, 0x00E6], [0x00E8, 0x00EA], [0x00EC, 0x00ED], [0x00F0, 0x00F0],
201
+ [0x00F2, 0x00F3], [0x00F7, 0x00FA], [0x00FC, 0x00FC], [0x00FE, 0x00FE],
202
+ [0x0101, 0x0101], [0x0111, 0x0111], [0x0113, 0x0113], [0x011B, 0x011B],
203
+ [0x0126, 0x0127], [0x012B, 0x012B], [0x0131, 0x0133], [0x0138, 0x0138],
204
+ [0x013F, 0x0142], [0x0144, 0x0144], [0x0148, 0x014B], [0x014D, 0x014D],
205
+ [0x0152, 0x0153], [0x0166, 0x0167], [0x016B, 0x016B], [0x01CE, 0x01CE],
206
+ [0x01D0, 0x01D0], [0x01D2, 0x01D2], [0x01D4, 0x01D4], [0x01D6, 0x01D6],
207
+ [0x01D8, 0x01D8], [0x01DA, 0x01DA], [0x01DC, 0x01DC], [0x0251, 0x0251],
208
+ [0x0261, 0x0261], [0x02C4, 0x02C4], [0x02C7, 0x02C7], [0x02C9, 0x02CB],
209
+ [0x02CD, 0x02CD], [0x02D0, 0x02D0], [0x02D8, 0x02DB], [0x02DD, 0x02DD],
210
+ [0x02DF, 0x02DF], [0x0300, 0x036F], [0x0391, 0x03A1], [0x03A3, 0x03A9],
211
+ [0x03B1, 0x03C1], [0x03C3, 0x03C9], [0x0401, 0x0401], [0x0410, 0x044F],
212
+ [0x0451, 0x0451], [0x2010, 0x2010], [0x2013, 0x2016], [0x2018, 0x2019],
213
+ [0x201C, 0x201D], [0x2020, 0x2022], [0x2024, 0x2027], [0x2030, 0x2030],
214
+ [0x2032, 0x2033], [0x2035, 0x2035], [0x203B, 0x203B], [0x203E, 0x203E],
215
+ [0x2074, 0x2074], [0x207F, 0x207F], [0x2081, 0x2084], [0x20AC, 0x20AC],
216
+ [0x2103, 0x2103], [0x2105, 0x2105], [0x2109, 0x2109], [0x2113, 0x2113],
217
+ [0x2116, 0x2116], [0x2121, 0x2122], [0x2126, 0x2126], [0x212B, 0x212B],
218
+ [0x2153, 0x2154], [0x215B, 0x215E], [0x2160, 0x216B], [0x2170, 0x2179],
219
+ [0x2189, 0x2189], [0x2190, 0x2199], [0x21B8, 0x21B9], [0x21D2, 0x21D2],
220
+ [0x21D4, 0x21D4], [0x21E7, 0x21E7], [0x2200, 0x2200], [0x2202, 0x2203],
221
+ [0x2207, 0x2208], [0x220B, 0x220B], [0x220F, 0x220F], [0x2211, 0x2211],
222
+ [0x2215, 0x2215], [0x221A, 0x221A], [0x221D, 0x2220], [0x2223, 0x2223],
223
+ [0x2225, 0x2225], [0x2227, 0x222C], [0x222E, 0x222E], [0x2234, 0x2237],
224
+ [0x223C, 0x223D], [0x2248, 0x2248], [0x224C, 0x224C], [0x2252, 0x2252],
225
+ [0x2260, 0x2261], [0x2264, 0x2267], [0x226A, 0x226B], [0x226E, 0x226F],
226
+ [0x2282, 0x2283], [0x2286, 0x2287], [0x2295, 0x2295], [0x2299, 0x2299],
227
+ [0x22A5, 0x22A5], [0x22BF, 0x22BF], [0x2312, 0x2312], [0x2460, 0x24E9],
228
+ [0x24EB, 0x254B], [0x2550, 0x2573], [0x2580, 0x258F], [0x2592, 0x2595],
229
+ [0x25A0, 0x25A1], [0x25A3, 0x25A9], [0x25B2, 0x25B3], [0x25B6, 0x25B7],
230
+ [0x25BC, 0x25BD], [0x25C0, 0x25C1], [0x25C6, 0x25C8], [0x25CB, 0x25CB],
231
+ [0x25CE, 0x25D1], [0x25E2, 0x25E5], [0x25EF, 0x25EF], [0x2605, 0x2606],
232
+ [0x2609, 0x2609], [0x260E, 0x260F], [0x261C, 0x261C], [0x261E, 0x261E],
233
+ [0x2640, 0x2640], [0x2642, 0x2642], [0x2660, 0x2661], [0x2663, 0x2665],
234
+ [0x2667, 0x266A], [0x266C, 0x266D], [0x266F, 0x266F], [0x269E, 0x269F],
235
+ [0x26C6, 0x26CD], [0x26CF, 0x26D3], [0x26D5, 0x26E1], [0x26E3, 0x26E3],
236
+ [0x26E8, 0x26E9], [0x26EB, 0x26F1], [0x26F4, 0x26F4], [0x26F6, 0x26F9],
237
+ [0x26FB, 0x26FC], [0x26FE, 0x26FF], [0x273D, 0x273D], [0x2761, 0x2761],
238
+ [0x2776, 0x277F], [0x2B56, 0x2B59], [0x3248, 0x324F], [0xE000, 0xF8FF],
239
+ [0xFE00, 0xFE0F], [0xFFFD, 0xFFFD], [0x1F100, 0x1F10A],
240
+ [0x1F110, 0x1F12D], [0x1F130, 0x1F169], [0x1F170, 0x1F19A],
241
+ ];
242
+
243
+ /**
244
+ * Should EAW=Ambiguous characters measure 2 columns?
245
+ *
246
+ * Resolved once, from (in order):
247
+ * 1. `HNCODE_AMBIGUOUS_WIDTH` — an explicit override, for a terminal whose setting
248
+ * does not match its locale;
249
+ * 2. the locale — `zh`/`ja`/`ko` mean a CJK terminal, which is the standard reason a
250
+ * terminal renders these wide.
251
+ */
252
+ export function detectAmbiguousWide(env = process.env) {
253
+ const raw = env.HNCODE_AMBIGUOUS_WIDTH;
254
+ if (raw !== undefined && raw !== '') return /^(1|true|yes|on)$/i.test(raw);
255
+ let loc = '';
256
+ try { loc = (Intl.DateTimeFormat().resolvedOptions().locale) || ''; } catch { /* no Intl */ }
257
+ const tag = loc || env.LC_ALL || env.LC_CTYPE || env.LANG || '';
258
+ return /^(zh|ja|ko)\b/i.test(tag) || /^(zh|ja|ko)[-_]/i.test(tag);
259
+ }
260
+
261
+ export function isAmbiguousWide() {
262
+ if (ambiguousWide === null) ambiguousWide = detectAmbiguousWide();
263
+ return ambiguousWide;
264
+ }
265
+
266
+ /**
267
+ * Turn the ambiguous-as-wide rule on or off. Clears the width cache, which is keyed by
268
+ * string alone and would otherwise hand back widths measured under the other rule.
269
+ */
270
+ export function setAmbiguousWide(on) {
271
+ const next = !!on;
272
+ if (next === ambiguousWide) return;
273
+ ambiguousWide = next;
274
+ widthCache.clear();
275
+ }
276
+
170
277
  const inRanges = (c, ranges) => {
171
278
  let lo = 0, hi = ranges.length - 1;
172
279
  while (lo <= hi) {
@@ -255,9 +362,46 @@ function clusterGlyphWidth(cluster) {
255
362
  // tokens = ceil(ASCII chars / 4) + non-ASCII chars
256
363
  // ASCII characters average ~4 per token; CJK and other non-ASCII are ~1 token
257
364
  // each. This is far more accurate than a uniform chars/4 ratio.
365
+ // Token estimate. The character walk is O(len), and the context gauge re-prices
366
+ // whole message bodies (a Read result can be hundreds of KB) every turn.
367
+ //
368
+ // A value cache only helps COMPLETED messages: they are immutable, so the same
369
+ // string is measured again and again. It actively HURTS streaming text, where the
370
+ // string grows every frame — every call is a miss, so the cache pays hashing and an
371
+ // insert each time and (once full) an O(n) eviction sweep, measuring 1ms/call vs
372
+ // ~0.001ms for a plain walk (30k growing-text calls: 31s cached vs 30ms uncached).
373
+ //
374
+ // So the cache is limited to SHORT strings: completed metadata and role names hit
375
+ // it, while a long or growing body is just walked directly. At most 4 KB was chosen
376
+ // because a token estimate is only ever re-asked for values that are cheap to hold
377
+ // and genuinely stable; anything larger is better re-walked than hashed + stored.
378
+ const tokCache = new Map();
379
+ const TOK_CACHE_MAX = 20000;
380
+ const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
381
+
258
382
  export function estimateTokens(text) {
383
+ const s = String(text ?? '');
384
+ if (s.length <= TOK_CACHE_MAX_LEN) {
385
+ const hit = tokCache.get(s);
386
+ if (hit !== undefined) return hit;
387
+ let ascii = 0, nonAscii = 0;
388
+ for (const ch of s) {
389
+ if (ch.codePointAt(0) <= 127) ascii++;
390
+ else nonAscii++;
391
+ }
392
+ const out = Math.ceil(ascii / 4) + nonAscii;
393
+ if (tokCache.size >= TOK_CACHE_MAX) {
394
+ // Drop the oldest half in one go: cheaper than one-at-a-time and keeps the
395
+ // recently measured entries that a per-entry eviction would churn.
396
+ let i = 0;
397
+ for (const k of tokCache.keys()) { tokCache.delete(k); if (++i >= TOK_CACHE_MAX / 2) break; }
398
+ }
399
+ tokCache.set(s, out);
400
+ return out;
401
+ }
402
+ // Long body: walk it. No memoise, no map churn.
259
403
  let ascii = 0, nonAscii = 0;
260
- for (const ch of String(text ?? '')) {
404
+ for (const ch of s) {
261
405
  if (ch.codePointAt(0) <= 127) ascii++;
262
406
  else nonAscii++;
263
407
  }
@@ -273,17 +417,148 @@ export function estimateTokens(text) {
273
417
  // estimateRequestOverhead().
274
418
  export function estimateMessagesTokens(messages, cfg) {
275
419
  let total = 0;
276
- for (const m of messages || []) {
277
- total += estimateTokens(m.role || '');
278
- const c = m.content;
279
- total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
280
- if (m.toolCalls) for (const tc of m.toolCalls) {
281
- total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
282
- }
420
+ for (const m of messages || []) total += estimateMessageTokens(m, cfg);
421
+ return total;
422
+ }
423
+
424
+ /**
425
+ * Tokens for ONE message. Split out so callers that walk the history (e.g.
426
+ * planCompaction) can price each message ONCE instead of re-summing the whole
427
+ * array per message — that was O(n²) over the transcript.
428
+ */
429
+ export function estimateMessageTokens(m, cfg) {
430
+ if (!m) return 0;
431
+ let total = estimateTokens(m.role || '');
432
+ const c = m.content;
433
+ total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
434
+ if (m.toolCalls) for (const tc of m.toolCalls) {
435
+ total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
283
436
  }
284
437
  return total;
285
438
  }
286
439
 
440
+ // ---- per-message token memo -----------------------------------------------
441
+ //
442
+ // Message objects -> { sig, tok }. WeakMap, so a message dropped from the history
443
+ // takes its estimate with it: no unbounded growth, and no manual eviction policy.
444
+ const msgTokMemo = new WeakMap();
445
+ //
446
+ // `estimateMessageTokens` above re-measures an immutable message every time it is
447
+ // asked, and the askers are frequent: the context gauge runs at every step, and
448
+ // compaction walks the whole history to find its cut point. A Read result can be
449
+ // hundreds of KB, so re-walking one costs milliseconds (measured: a 300 KB body is
450
+ // ~5.3 ms to price). Completed messages never change, so their estimate never has
451
+ // to be recomputed.
452
+ //
453
+ // The memo is a WeakMap keyed on the MESSAGE OBJECT, which gives two properties for
454
+ // free: it cannot leak (a dropped message takes its entry with it) and it cannot
455
+ // collide (two equal messages are still two objects).
456
+ //
457
+ // Correctness — the one place a stored message changes underneath us —
458
+ // --------------------------------------------------------------------
459
+ // `trimToolResults` rewrites `m.content` IN PLACE, replacing a large body with a
460
+ // short pointer. A memo that trusted object identity alone would keep serving the
461
+ // pre-trim count, and the gauge would claim the context was still full right after
462
+ // the very operation meant to shrink it — the trim would appear to do nothing and
463
+ // the next request would compact again for no reason.
464
+ //
465
+ // So every entry stores a cheap SIGNATURE of the content alongside the number, and
466
+ // a mismatch recomputes. `.length` on a string is O(1), which is what makes this
467
+ // affordable: streaming text (which grows every frame) misses on the signature and
468
+ // is re-walked, exactly as `estimateTokens`' own short-string cache already
469
+ // established. Content that is neither a string nor an array gets a weaker
470
+ // signature and is simply re-measured each time rather than risk a stale answer.
471
+ function msgSignature(m) {
472
+ const c = m.content;
473
+ let s;
474
+ if (typeof c === 'string') s = 's' + c.length;
475
+ else if (c == null) s = 'n';
476
+ else if (Array.isArray(c)) s = 'a' + c.length;
477
+ else s = ''; // unknown shape: never trust a memo
478
+ if (m.toolCalls) s += 't' + m.toolCalls.length;
479
+ return s;
480
+ }
481
+
482
+ /** Drop any memoised estimate for a message whose content was rewritten in place. */
483
+ export function forgetMessageTokens(m) {
484
+ if (m && typeof m === 'object') msgTokMemo.delete(m);
485
+ }
486
+
487
+ /**
488
+ * `estimateMessageTokens`, memoised per message object.
489
+ *
490
+ * Safe to call on STREAMING messages too: the signature check misses on every
491
+ * frame, so a growing body is simply re-measured and never served stale.
492
+ */
493
+ export function messageTokens(m, cfg) {
494
+ if (!m || typeof m !== 'object') return estimateMessageTokens(m, cfg);
495
+ const sig = msgSignature(m);
496
+ if (sig !== '') {
497
+ const hit = msgTokMemo.get(m);
498
+ if (hit !== undefined && hit.sig === sig) return hit.tok;
499
+ }
500
+ const tok = estimateMessageTokens(m, cfg);
501
+ if (sig !== '') msgTokMemo.set(m, { sig, tok });
502
+ return tok;
503
+ }
504
+
505
+ /**
506
+ * A rolling total over a message list that grows by appending.
507
+ *
508
+ * This is the idea borrowed from jcode's `ActiveCharEstimate`
509
+ * (`crates/jcode-base/src/compaction.rs`): keep a running count of the messages
510
+ * already priced, so the common append-only case never rescans history. jcode also
511
+ * notes why the cached value and its staleness flag are bundled into one type
512
+ * rather than two independent fields — a path that updated one without the other
513
+ * silently corrupted token accounting. Here the two are a single number,
514
+ * `_counted`: it is meaningless on its own and is only ever read together with the
515
+ * length it was computed against.
516
+ *
517
+ * `sync()` is O(new messages) when the list was only appended to, and O(n) after a
518
+ * compaction (which REPLACES the array, so the old count describes messages that no
519
+ * longer exist). Correctness does not depend on detecting appends perfectly: the
520
+ * boundary identities are spot-checked and any doubt falls back to a full recount,
521
+ * which is still cheap because the per-message memo makes each step a WeakMap hit.
522
+ */
523
+ export class TokenLedger {
524
+ constructor() {
525
+ this.tokens = 0;
526
+ this._counted = 0;
527
+ this._first = null;
528
+ this._last = null;
529
+ }
530
+
531
+ /** Total tokens for `messages`, reusing the previous count where it is still valid. */
532
+ sync(messages, cfg) {
533
+ const msgs = Array.isArray(messages) ? messages : [];
534
+ const n = msgs.length;
535
+ const canExtend = this._counted > 0 && this._counted <= n
536
+ && this._first === msgs[0]
537
+ && this._last === msgs[this._counted - 1];
538
+ if (canExtend) {
539
+ for (let i = this._counted; i < n; i++) this.tokens += messageTokens(msgs[i], cfg);
540
+ } else {
541
+ // Full recount. Still O(n) WEAK-MAP LOOKUPS rather than O(n) string walks,
542
+ // which is the difference between milliseconds and microseconds.
543
+ let total = 0;
544
+ for (let i = 0; i < n; i++) total += messageTokens(msgs[i], cfg);
545
+ this.tokens = total;
546
+ }
547
+ this._counted = n;
548
+ this._first = n ? msgs[0] : null;
549
+ this._last = n ? msgs[n - 1] : null;
550
+ return this.tokens;
551
+ }
552
+
553
+ /** Forget everything — call after an in-place edit of an already-counted message. */
554
+ reset() {
555
+ this.tokens = 0;
556
+ this._counted = 0;
557
+ this._first = null;
558
+ this._last = null;
559
+ }
560
+ }
561
+
287
562
  // The overhead a request carries BEYOND its messages: the system prompt plus the
288
563
  // tool definitions. Passed the assembled system text so the number matches what is
289
564
  // actually sent.
@@ -0,0 +1,98 @@
1
+ // Tools for controlling a RUNNING subagent: list, message, interrupt, close.
2
+ //
3
+ // WHY THESE ARE SEPARATE FROM TaskList / TaskStop
4
+ // ----------------------------------------------
5
+ // `TaskStop` cancels a background task by task_id, and for an agent that means aborting
6
+ // its controller — the whole run dies. There was no way to do the thing a person actually
7
+ // asks for while watching a subagent work: tell it something, and let it continue. And
8
+ // there was no way to stop ONE subagent of a swarm without stopping the turn that started
9
+ // them, because `TaskStop` needs a task id that only the background path creates.
10
+ //
11
+ // These tools address agents by AGENT id (or run id, or task id — whichever the caller
12
+ // has), which is the identifier `Agent` prints and `AgentSwarm` reports, so the model can
13
+ // act on what it just started without a lookup table.
14
+ //
15
+ // `AgentMessage` uses the same `steer` path as the TUI's Ctrl+S: the message lands when
16
+ // the subagent's in-flight tool calls finish. That is the only safe moment to change a
17
+ // run's instructions, and it is what makes "keep going, but also do X" meaningful.
18
+
19
+ import { listRuns, sendToRun, interruptRun, interruptAll, closeRun, describeRuns } from '../subagent-control.js';
20
+
21
+ export const AgentListSpec = {
22
+ name: 'AgentList',
23
+ description: `List the subagents that are running RIGHT NOW, with the id to address each one.
24
+
25
+ Use this before AgentMessage / AgentInterrupt / AgentClose when you do not already hold the id. A subagent that has finished is not listed — it is no longer controllable, and its result is already in this conversation (or in TaskOutput for a background run).`,
26
+ parameters: { type: 'object', properties: {} },
27
+ async execute() {
28
+ const runs = listRuns();
29
+ if (!runs.length) return 'No subagent is running.';
30
+ return describeRuns().join('\n');
31
+ },
32
+ };
33
+
34
+ export const AgentMessageSpec = {
35
+ name: 'AgentMessage',
36
+ description: `Send a message to a RUNNING subagent without stopping it.
37
+
38
+ The text is injected when the subagent's in-flight tool calls finish — the same point the user's own steering messages land — so it changes the run's instructions rather than interrupting them. Use it to redirect work ("skip the tests for now"), to add a constraint, or to correct a wrong assumption you can see in its stream.
39
+
40
+ Identify the subagent by its agent_id (what \`Agent\` returned, or what AgentSwarm reported), its run id, or its task id. Use AgentList if you have none of them.`,
41
+ parameters: {
42
+ type: 'object',
43
+ properties: {
44
+ agent_id: { type: 'string', description: 'The subagent to message: agent_id, run id, or task_id.' },
45
+ message: { type: 'string', description: 'What to tell it. Delivered as a steering message.' },
46
+ },
47
+ required: ['agent_id', 'message'],
48
+ },
49
+ async execute(args) {
50
+ const r = sendToRun(args.agent_id, args.message);
51
+ return r.ok ? r.message : `Error: ${r.message}`;
52
+ },
53
+ };
54
+
55
+ export const AgentInterruptSpec = {
56
+ name: 'AgentInterrupt',
57
+ description: `Stop ONE running subagent, leaving everything else alone.
58
+
59
+ Prefer this over TaskStop for a subagent: TaskStop works on a background task id and cancels the whole run, while this reaches the subagent directly and needs only the agent id. Omit \`agent_id\` to stop EVERY running subagent, which is the way out when a swarm has gone wrong.
60
+
61
+ Brief the model on what the agent had established before stopping it: a subagent's partial work is not written anywhere you can read afterwards.`,
62
+ parameters: {
63
+ type: 'object',
64
+ properties: {
65
+ agent_id: { type: 'string', description: 'The subagent to stop. Omit to stop all of them.' },
66
+ reason: { type: 'string', description: 'Short reason, for the transcript.' },
67
+ },
68
+ },
69
+ async execute(args) {
70
+ const ref = args && args.agent_id ? String(args.agent_id).trim() : '';
71
+ if (!ref) {
72
+ const all = interruptAll();
73
+ return all.ok
74
+ ? `Interrupted ${all.count} subagent(s): ${all.agents.join(', ')}`
75
+ : 'No subagent is running.';
76
+ }
77
+ const r = interruptRun(ref);
78
+ return r.ok ? r.message : `Error: ${r.message}`;
79
+ },
80
+ };
81
+
82
+ export const AgentCloseSpec = {
83
+ name: 'AgentClose',
84
+ description: `Interrupt a subagent AND remove it from the running list immediately.
85
+
86
+ Use this when you stopped a subagent and no longer want it in AgentList. An interrupted run is removed when its await unwinds, which is asynchronous, so a listing right afterwards can still show it; close removes it at once. The subagent's CONVERSATION is kept, so a later \`Agent\` call can still \`resume\` it.`,
87
+ parameters: {
88
+ type: 'object',
89
+ properties: {
90
+ agent_id: { type: 'string', description: 'The subagent to close.' },
91
+ },
92
+ required: ['agent_id'],
93
+ },
94
+ async execute(args) {
95
+ const r = closeRun(args.agent_id);
96
+ return r.ok ? r.message : `Error: ${r.message}`;
97
+ },
98
+ };
@@ -71,6 +71,7 @@ Each of these is enforced — a violation is rejected before any subagent starts
71
71
  Use enough subagents to keep the work focused and parallel. AgentSwarm supports up to ${MAX_AGENT_SWARM_SUBAGENTS} subagents, and launches are queued automatically, so it is safe to split large tasks into many clear, independent items.
72
72
 
73
73
  When NOT to use AgentSwarm:
74
+ - It only exists while swarm mode is ON (\`/swarm\`). With the mode off the tool is not offered at all and calling it is refused. If a fan-out would genuinely help, say so and ask the user to turn the mode on.
74
75
  - Never call this on your own initiative. It requires the same explicit permission as \`Agent\`: the user, or the applicable AGENTS.md / project instructions, must have asked for sub-agents, delegation, or parallel agent work. Requests for depth, thoroughness, research, investigation, or detailed codebase analysis do NOT count as permission.
75
76
  - Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead, which can run in parallel in one message.
76
77
  - Do not use it to fan out work you could finish yourself in a few steps.
@@ -151,9 +152,17 @@ If \`AgentSwarm\` is called, that call must be the only tool call in the respons
151
152
  } catch (e) {
152
153
  results[i] = `--- #${i + 1} ---\n[error: ${e.message}]`;
153
154
  }
154
- // Stream progress into the task output so a long swarm is inspectable.
155
+ // Report progress down BOTH channels, and they are not interchangeable:
156
+ // * the task buffer feeds TaskOutput and survives a detach;
157
+ // * `ctx.onOutput` is the LIVE sink. The progress block in the transcript
158
+ // reads the cells off it (see the tool_output handler's `[n/m] finished`
159
+ // match), so writing only to the task buffer left every cell but the first
160
+ // stuck on "Queued…" for the whole run — the TUI was told to expect these
161
+ // lines on this channel and never received one.
155
162
  const done = results.filter((r) => r !== null).length;
156
- appendTaskOutput(task, `[${done}/${jobs.length}] finished\n`);
163
+ const line = `[${done}/${jobs.length}] finished\n`;
164
+ appendTaskOutput(task, line);
165
+ if (typeof ctx.onOutput === 'function') ctx.onOutput(line);
157
166
  }
158
167
  };
159
168
  const runAll = Promise.all(Array.from({ length: Math.min(SWARM_CONCURRENCY, jobs.length) }, worker));
@@ -17,6 +17,7 @@
17
17
  // summarise it.
18
18
 
19
19
  import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
20
+ import { registerRun, endRun } from '../subagent-control.js';
20
21
  import { SUBAGENT_TYPES, DEFAULT_SUBAGENT_TYPE, availableTypes, toolsForSubagent } from '../subagent-types.js';
21
22
  import { runShellHooks } from '../hooks.js';
22
23
 
@@ -53,7 +54,7 @@ function systemPromptFor(type, cfg, systemPrompt) {
53
54
  // stream, and each tool it runs) so the TUI can show it under the `Using Agent`
54
55
  // row the way Bash's output is shown. `agentId` labels the lines when several
55
56
  // subagents run at once (AgentSwarm), so the reader can tell them apart.
56
- async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId }) {
57
+ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProgress, agentId, onAgent }) {
57
58
  const { Agent, SYSTEM_PROMPT } = await import('../agent.js');
58
59
  const subCfg = {
59
60
  ...cfg,
@@ -105,6 +106,10 @@ async function runSubagent({ cfg, type, prompt, history, signal, onEvent, onProg
105
106
  if (signal.aborted) throw new Error('Interrupted by user');
106
107
  signal.addEventListener('abort', () => agent.interrupt(), { once: true });
107
108
  }
109
+ // Hand the LIVE agent back to the caller so a control tool can steer or interrupt it
110
+ // while it runs. `subagents` only learns about this run when it finishes, so without
111
+ // this the only way to reach a running subagent would be to interrupt the whole turn.
112
+ if (typeof onAgent === 'function') onAgent(agent);
108
113
  await agent.run();
109
114
  // SHELL HOOK: SubagentStop. The subagent finished (or was interrupted); the
110
115
  // parent-side hook can log/archive it. Never blocks.
@@ -237,12 +242,20 @@ ${availableTypes()}`,
237
242
  const agentId = existing ? existing.id : newAgentId();
238
243
  task.agentId = agentId;
239
244
 
245
+ // Register the run so a control tool can reach it while it is in flight. The handle
246
+ // is created BEFORE the run so both `run_in_background` and the detached-foreground
247
+ // path behave the same, and cleared in `finally` so a settled run can never be
248
+ // listed as controllable.
249
+ let runId = '';
250
+ const early = { agent: null };
251
+
240
252
  // Fire and forget: the task record carries the result when it lands.
241
253
  void (async () => {
242
254
  try {
243
255
  const run = await runSubagent({
244
256
  cfg: subCfg, type, prompt, history,
245
257
  signal: controller.signal,
258
+ onAgent: (agent) => { early.agent = agent; },
246
259
  // No live streaming into the task output: that should read as the
247
260
  // subagent's CONCLUSION. `run.text` below is the cleaned final message.
248
261
  });
@@ -258,8 +271,15 @@ ${availableTypes()}`,
258
271
  settleTask(task, stopped ? 'killed' : 'failed', { stopReason: stopped ? 'Stopped by user' : e.message });
259
272
  appendTaskOutput(task, `\n[error: ${e.message}]`);
260
273
  if (typeof ctx._onBackgroundTaskDone === 'function') ctx._onBackgroundTaskDone(task);
274
+ } finally {
275
+ if (runId) endRun(runId);
261
276
  }
262
277
  })();
278
+ runId = registerRun({
279
+ agentId, taskId: task.taskId, type, description,
280
+ steer: (text) => { if (early.agent) early.agent.steer(text); },
281
+ interrupt: () => controller.abort(),
282
+ });
263
283
 
264
284
  return `agent_id: ${agentId}\ntask_id: ${task.taskId}\nstatus: running\nnext_step: Continue your work; the result arrives in a later message. Use TaskOutput {task_id: "${task.taskId}"} for a non-blocking check or TaskStop to cancel.`;
265
285
  }
@@ -286,8 +306,13 @@ ${availableTypes()}`,
286
306
  // `onProgress` feeds the parent's live-output sink (ctx.onOutput), which the
287
307
  // agent tags with THIS tool call's id — the same path Bash uses, so the TUI
288
308
  // renders the subagent's stream under the `Using Agent` row and Ctrl+O folds it.
309
+ // The live Agent is captured as soon as `runSubagent` builds it, which is what makes
310
+ // this run controllable from another tool call (or the TUI) without waiting for it to
311
+ // finish. `live` is a box because the handle arrives asynchronously.
312
+ const live = { agent: null };
289
313
  const runPromise = runSubagent({
290
314
  cfg: subCfg, type, prompt, history, signal: bgController.signal,
315
+ onAgent: (agent) => { live.agent = agent; },
291
316
  // `_progressTag` is set by AgentSwarm to the item index, so a swarm's
292
317
  // concurrently-running subagents are distinguishable in the live stream.
293
318
  // A plain Agent call has no tag and prints its stream unlabelled.
@@ -297,6 +322,15 @@ ${availableTypes()}`,
297
322
  ctx.onOutput(tag ? text.split('\n').map((l) => (l ? tag + l : l)).join('\n') : text);
298
323
  },
299
324
  });
325
+ // Registered here so a foreground subagent is reachable by AgentMessage / AgentInterrupt
326
+ // too — that is precisely the case where a user wants to redirect one: it is the run
327
+ // they are watching. Cleared when the run settles, however it settles.
328
+ let runId = registerRun({
329
+ agentId, type, description,
330
+ steer: (text) => { if (live.agent) live.agent.steer(text); },
331
+ interrupt: () => bgController.abort(),
332
+ });
333
+ void runPromise.catch(() => {}).finally(() => { if (runId) { endRun(runId); runId = ''; } });
300
334
  if (ctx) {
301
335
  ctx._foreground = {
302
336
  kind: 'Agent',
package/src/tools/bash.js CHANGED
@@ -7,6 +7,7 @@ import fs from 'node:fs';
7
7
  import cp from 'node:child_process';
8
8
  import { resolvePath, truncateBuf } from './utils.js';
9
9
  import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
10
+ import { shellSpawnArgs } from './shell.js';
10
11
 
11
12
  // kimi-code's timeout policy (agent-core-v2/agent/tools/os/bash/bash.ts).
12
13
  export const DEFAULT_TIMEOUT_S = 60;
@@ -137,10 +138,19 @@ export const spec = {
137
138
  const cap = bg ? MAX_BACKGROUND_TIMEOUT_S : MAX_TIMEOUT_S;
138
139
  const def = bg ? DEFAULT_BACKGROUND_TIMEOUT_S : DEFAULT_TIMEOUT_S;
139
140
  const timeoutMs = args.disable_timeout ? undefined : Math.min(args.timeout ?? def, cap) * 1000;
140
- const command = args.command;
141
+ const command = args.command;
141
142
  // stdin is 'ignore' (NUL / /dev/null): leaving it as an open pipe made any
142
143
  // command that reads stdin (`cat`, `npm init`, …) block until the timeout.
143
- const child = cp.spawn('pwsh', ['-Command', command], { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
144
+ //
145
+ // The shell is RESOLVED, not hardcoded: `spawn('pwsh', …)` failed outright on a
146
+ // box with only the built-in Windows PowerShell (see tools/shell.js). `env` is
147
+ // passed explicitly rather than relying on Node's implicit inheritance, which is
148
+ // what made the tool depend on pwsh having been added to PATH by hand.
149
+ let shell;
150
+ try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
151
+ const child = cp.spawn(shell.bin, shell.args, {
152
+ cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
153
+ });
144
154
 
145
155
  const chunks = [];
146
156
  let settled = false;
@@ -288,7 +298,11 @@ function spawnBackground(args, cwd, ctx) {
288
298
  // task's output file instead, so the process survives and TaskOutput can
289
299
  // still read it. (In the TUI the parent rarely exits, but headless -p mode
290
300
  // does, and a background job should outlive the prompt.)
291
- const child = cp.spawn('pwsh', ['-Command', command], { cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'] });
301
+ let shell;
302
+ try { shell = shellSpawnArgs(command); } catch (e) { return `Error: ${e.message}`; }
303
+ const child = cp.spawn(shell.bin, shell.args, {
304
+ cwd, detached: true, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env },
305
+ });
292
306
  const task = createTask(ctx, 'process', {
293
307
  description: args.description || args.command,
294
308
  command: args.command,