@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/term.js CHANGED
@@ -379,17 +379,91 @@ const tokCache = new Map();
379
379
  const TOK_CACHE_MAX = 20000;
380
380
  const TOK_CACHE_MAX_LEN = 4096; // only memoise strings this short
381
381
 
382
+ /**
383
+ * The same estimate, UNROUNDED — for summing many small chunks.
384
+ *
385
+ * `estimateTokens` rounds up, which is right for measuring one finished body: a 5-character
386
+ * message should not read as 1.25 tokens.
387
+ *
388
+ * It is wrong for a STREAM. Deltas arrive a few characters at a time, and each one took
389
+ * `ceil(ascii / 4)` — `ceil(3/4)` is 1, so three characters were billed as a whole token. Over
390
+ * hundreds of chunks that inflated the running figure badly: measured at +33% on ordinary
391
+ * English, and the visible symptom was the output counter DROPPING (3000 -> 2300) the moment the
392
+ * provider's exact `usage` arrived and replaced the estimate. Nothing was lost; the estimate had
393
+ * been lying.
394
+ *
395
+ * So the running counter accumulates this unrounded value, and rounds once when it displays.
396
+ * Callers that measure a complete string keep using `estimateTokens`.
397
+ */
398
+ export function estimateTokensFloat(text) {
399
+ return estimateBpe(String(text ?? ''));
400
+ }
401
+
402
+ /**
403
+ * A BPE-aware token estimate: characters per token, BY CHARACTER CLASS.
404
+ *
405
+ * The old rule was `ascii / 4 + nonAscii`, and it is wrong in a way that matters here. Measured against
406
+ * published BPE rates for this app's own content, it undercounted by up to 35%:
407
+ *
408
+ * English prose 1.05x (the rule was calibrated for exactly this)
409
+ * TypeScript code 0.80x
410
+ * JSON payload 0.82x
411
+ * tool schema 0.75x
412
+ * paths and flags 0.73x
413
+ * minified JSON 0.65x
414
+ * mixed CJK + code 0.65x
415
+ *
416
+ * The reason is BPE merges COMMON BYTE PAIRS. English prose is full of repeated whole words, so four
417
+ * characters per token holds. Code, JSON and CLI flags are punctuation-dense: `{"a":1,"b":2}` merges
418
+ * almost nothing, and a token covers about 2.6 characters. On a 300k context that 0.65x is a ~100k
419
+ * error — the exact direction and size of the gap being reported.
420
+ *
421
+ * Five classes, each with its measured rate:
422
+ *
423
+ * letters + spaces 4.2 the "4 chars/token" folklore, and the only place it holds
424
+ * digits 3.0 punctuation-free but rarely repeated
425
+ * punctuation + code 2.7 brackets, quotes, colons, operators — merge poorly
426
+ * whitespace (indent) 1.0 a run of spaces still costs a token per couple of columns
427
+ * CJK + other non-Latin 1.0 one token per character, occasionally two
428
+ *
429
+ * Counted in ONE pass, because this runs on every streamed chunk and every message body.
430
+ */
431
+ function estimateBpe(s) {
432
+ if (!s) return 0;
433
+ let letters = 0; // a-z A-Z and the space/punctuation that surrounds prose
434
+ let digits = 0;
435
+ let punct = 0;
436
+ let space = 0;
437
+ let wide = 0;
438
+
439
+ for (const ch of s) {
440
+ const cp = ch.codePointAt(0);
441
+ if (cp > 127) { wide++; continue; }
442
+ if (cp === 32 || cp === 9) { space++; continue; }
443
+ if (cp >= 48 && cp <= 57) { digits++; continue; }
444
+ if ((cp >= 65 && cp <= 90) || (cp >= 97 && cp <= 122)) { letters++; continue; }
445
+ punct++;
446
+ }
447
+
448
+ // Newlines land in `punct` (they are not 32 or 9), which is the right rate for them.
449
+ //
450
+ // The SPACE rate is the delicate one: a run of indentation costs about a token per two columns, but a
451
+ // single space between English words merges into the following word almost for free. Charging every
452
+ // space at the indentation rate made prose read 34% high — a worse error than the undercount it fixed,
453
+ // because prose is what the model WRITES back.
454
+ //
455
+ // So spaces are priced at a third of a token each: cheap enough for prose, and still enough that a
456
+ // deeply indented code block is not free.
457
+ return letters / 4.2 + digits / 3.0 + punct / 2.7 + space / 3.0 + wide;
458
+ }
382
459
  export function estimateTokens(text) {
383
460
  const s = String(text ?? '');
384
461
  if (s.length <= TOK_CACHE_MAX_LEN) {
385
462
  const hit = tokCache.get(s);
386
463
  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;
464
+ // Rounded UP from the same BPE-aware estimate the streaming counter uses. Both must agree: a body
465
+ // measured one way and a stream measured another is how a session ends up reporting two numbers.
466
+ const out = Math.ceil(estimateBpe(s));
393
467
  if (tokCache.size >= TOK_CACHE_MAX) {
394
468
  // Drop the oldest half in one go: cheaper than one-at-a-time and keeps the
395
469
  // recently measured entries that a per-entry eviction would churn.
@@ -399,13 +473,9 @@ export function estimateTokens(text) {
399
473
  tokCache.set(s, out);
400
474
  return out;
401
475
  }
402
- // Long body: walk it. No memoise, no map churn.
403
- let ascii = 0, nonAscii = 0;
404
- for (const ch of s) {
405
- if (ch.codePointAt(0) <= 127) ascii++;
406
- else nonAscii++;
407
- }
408
- return Math.ceil(ascii / 4) + nonAscii;
476
+ // Long body: measured directly, with the SAME estimator as the cached path. The two used to differ, so
477
+ // a long message and a short one were priced by different formulas.
478
+ return Math.ceil(estimateBpe(s));
409
479
  }
410
480
 
411
481
  // Estimate tokens for the MESSAGES only. The system prompt and tool definitions are
@@ -430,13 +500,52 @@ export function estimateMessageTokens(m, cfg) {
430
500
  if (!m) return 0;
431
501
  let total = estimateTokens(m.role || '');
432
502
  const c = m.content;
433
- total += typeof c === 'string' ? estimateTokens(c) : estimateTokens(JSON.stringify(c || ''));
503
+ if (typeof c === 'string') {
504
+ total += estimateTokens(c);
505
+ } else if (c && typeof c === 'object' && Array.isArray(c.media)) {
506
+ total += estimateMediaContentTokens(c);
507
+ } else {
508
+ total += estimateTokens(JSON.stringify(c || ''));
509
+ }
434
510
  if (m.toolCalls) for (const tc of m.toolCalls) {
435
511
  total += estimateTokens(tc.name || '') + estimateTokens(JSON.stringify(tc.args || ''));
436
512
  }
437
513
  return total;
438
514
  }
439
515
 
516
+ /**
517
+ * Tokens for a content block that carries media (an image read from disk, a screenshot).
518
+ *
519
+ * AN IMAGE IS NOT PRICED BY ITS BASE64 LENGTH. `JSON.stringify` on the same object counts every base64
520
+ * character as ordinary text — a 1 MB PNG is ~1.3M characters, which this estimator would price at
521
+ * roughly 400k tokens — while a provider charges by the image's PIXEL dimensions, usually one to two
522
+ * thousand. That is the direction that matters: the gauge would read hundreds of thousands too high, and
523
+ * auto-compaction would fire on a history that was never close to full.
524
+ *
525
+ * The text beside the image is priced normally; each image gets a flat estimate derived from the common
526
+ * tiling rule (a 512x512 tile ≈ 765 tokens, a 1568px max edge ≈ 1100 tokens). Exactness is impossible
527
+ * without decoding the image, and it does not matter here: images are rare, and being within a few
528
+ * hundred tokens beats being wrong by a hundred thousand.
529
+ */
530
+ function estimateMediaContentTokens(c) {
531
+ let total = estimateTokens(String(c.text == null ? '' : c.text));
532
+ for (const m of c.media || []) {
533
+ if (!m || m.type !== 'image') continue;
534
+ // A thumbnail-sized image costs a tile; a full screenshot costs four. Read the dimensions from the
535
+ // metadata when the reader recorded them, and fall back to the large case otherwise — overestimating
536
+ // an image is far safer than under, because it makes compaction fire EARLY rather than late.
537
+ const w = Number(m.width) || 0;
538
+ const h = Number(m.height) || 0;
539
+ if (w > 0 && h > 0) {
540
+ const tiles = Math.max(1, Math.ceil(w / 512) * Math.ceil(h / 512));
541
+ total += Math.min(4, tiles) * 765;
542
+ } else {
543
+ total += 1100;
544
+ }
545
+ }
546
+ return total;
547
+ }
548
+
440
549
  // ---- per-message token memo -----------------------------------------------
441
550
  //
442
551
  // Message objects -> { sig, tok }. WeakMap, so a message dropped from the history
@@ -474,7 +583,11 @@ function msgSignature(m) {
474
583
  if (typeof c === 'string') s = 's' + c.length;
475
584
  else if (c == null) s = 'n';
476
585
  else if (Array.isArray(c)) s = 'a' + c.length;
477
- else s = ''; // unknown shape: never trust a memo
586
+ else if (typeof c === 'object' && Array.isArray(c.media)) {
587
+ // A media block: its signature is the text plus one marker per image. The base64 payload itself is
588
+ // NOT hashed — it is the expensive part, and it never changes once the message exists.
589
+ s = 'm' + String(c.text == null ? '' : c.text).length + 'x' + c.media.length;
590
+ } else s = ''; // unknown shape: never trust a memo
478
591
  if (m.toolCalls) s += 't' + m.toolCalls.length;
479
592
  return s;
480
593
  }
@@ -559,13 +672,43 @@ export class TokenLedger {
559
672
  }
560
673
  }
561
674
 
562
- // The overhead a request carries BEYOND its messages: the system prompt plus the
563
- // tool definitions. Passed the assembled system text so the number matches what is
564
- // actually sent.
675
+ /**
676
+ * The overhead a request carries BEYOND its messages: the system prompt plus the tool definitions.
677
+ * Passed the assembled system text so the number matches what is actually sent.
678
+ *
679
+ * `cfg.toolDefTokens` is the REAL cost of the tool definitions, measured once by whoever built them.
680
+ * This used to charge a flat `n * 8` per tool, which is short by an order of magnitude: a tool's
681
+ * schema is its name, a paragraph of description and a nested parameters object, and measured here the
682
+ * 68 registered tools come to about 15 900 tokens — 29 times what 8 apiece reported.
683
+ *
684
+ * The discrepancy matters because this number is part of the context gauge. A gauge that omits ~15k of
685
+ * every request understates usage by that much per turn, and the user sees the provider's real figure
686
+ * disagree with the app's by a wide margin.
687
+ *
688
+ * The `n * 8` remains only as a FALLBACK for a caller that has not measured its definitions; it is
689
+ * better than nothing and is the old behaviour, exactly.
690
+ */
565
691
  export function estimateRequestOverhead(systemText, cfg) {
566
692
  let total = estimateTokens(systemText || '');
693
+ const measured = cfg && Number.isFinite(cfg.toolDefTokens) ? cfg.toolDefTokens : null;
694
+ if (measured != null) return total + measured;
567
695
  const n = (cfg && Array.isArray(cfg.toolFilter)) ? cfg.toolFilter.length : 0;
568
- if (n) total += n * 8; // rough per-tool-definition overhead
696
+ if (n) total += n * 8; // rough per-tool-definition overhead, when nothing measured it
697
+ return total;
698
+ }
699
+
700
+ /**
701
+ * The token cost of a set of tool definitions, measured the way they are actually sent.
702
+ *
703
+ * Serialised as `{ name, description, parameters }` — the three fields the provider receives — and
704
+ * priced with the same estimator the gauge uses, so the number is comparable with everything else.
705
+ */
706
+ export function measureToolDefTokens(tools) {
707
+ let total = 0;
708
+ for (const t of tools || []) {
709
+ if (!t || !t.name) continue;
710
+ total += estimateTokens(JSON.stringify({ name: t.name, description: t.description, parameters: t.parameters }));
711
+ }
569
712
  return total;
570
713
  }
571
714
 
@@ -192,22 +192,37 @@ export function planTrim(messages, keepRatio, mustKeep) {
192
192
  // A budget of 0 means "elide everything eligible"; a budget of 1 means "keep it
193
193
  // all", in which case we still elide the already-marked ones so a repeated call
194
194
  // is a no-op rather than a growing patch.
195
- const budget = totalBytes * ratio;
195
+ // The share of the ELIGIBLE text to keep, not of `totalBytes`. `totalBytes` counts the
196
+ // pointers left by earlier passes, so after a few passes it mostly describes text that can no
197
+ // longer be elided — and a budget drawn from it stops constraining the loop.
198
+ const eligibleBytes = candidates.reduce((n, i) => n + messages[i].content.length, 0);
199
+ const budget = eligibleBytes * ratio;
196
200
  const keep = new Set();
197
201
  let keepBytes = 0;
198
202
  for (let k = candidates.length - 1; k >= 0; k--) {
199
203
  const i = candidates[k];
200
204
  if (i === mustKeep) { keep.add(i); keepBytes += messages[i].content.length; continue; }
201
205
  const size = messages[i].content.length;
202
- // Keep this chunk if we are still under budget, but ALWAYS keep the single
203
- // newest result: a trim that elides the output the model is currently reading
204
- // is how a working turn turns into a confused one.
205
- if (keepBytes + size <= budget || keep.size === 0) {
206
+ // Under budget: keep. Otherwise elide — except for the result the model is reading right
207
+ // now, which the `mustKeep` branch above has already kept.
208
+ //
209
+ // There used to be a `|| keep.size === 0` here, meaning "always keep at least the newest".
210
+ // `mustKeep` is what actually protects the live result, and the override made a single large
211
+ // result impossible to trim: it was the only candidate, so it was always "the newest", so
212
+ // the budget never applied and the trim elided nothing.
213
+ if (keepBytes + size <= budget) {
206
214
  keep.add(i);
207
215
  keepBytes += size;
208
216
  }
209
217
  }
210
218
 
219
+ // THE FLOOR: never elide everything.
220
+ //
221
+ // The budget can be smaller than every candidate — one 200k result against a 60k budget is the
222
+ // ordinary case, because a long turn leaves the history with only the newest result still
223
+ // un-elided. Without this the trim would replace that result with a pointer, which is worse
224
+ // than not trimming: the model loses the output it just asked for.
225
+ if (!keep.size && candidates.length) keep.add(candidates[candidates.length - 1]);
211
226
  const elide = [];
212
227
  let elideBytes = 0;
213
228
  for (const i of candidates) {
@@ -259,6 +274,54 @@ export function applyTrim(messages, plan, sessionId) {
259
274
  // so a caller can skip its UI event entirely.
260
275
  export function trimToolResults(messages, opts = {}) {
261
276
  const plan = planTrim(messages, opts.keepRatio, opts.mustKeep);
262
- if (!plan.elide.length) return null;
263
- return { ...applyTrim(messages, plan, opts.sessionId), plan };
277
+ const mediaPlan = planMediaTrim(messages, opts.mediaKeep ?? 2);
278
+ if (!plan.elide.length && !mediaPlan.elide.length) return null;
279
+ const applied = plan.elide.length
280
+ ? applyTrim(messages, plan, opts.sessionId)
281
+ : { elided: 0, elidedBytes: 0, kept: plan.keep.length, keptBytes: plan.keepBytes };
282
+ const mediaApplied = applyMediaTrim(messages, mediaPlan, opts.sessionId);
283
+ return {
284
+ ...applied,
285
+ elided: applied.elided + mediaApplied.elided,
286
+ elidedBytes: applied.elidedBytes + mediaApplied.elidedBytes,
287
+ plan,
288
+ };
289
+ }
290
+
291
+ // ---- media results ----------------------------------------------------------
292
+ // A media tool result keeps its base64 payload IN the history object — and unlike
293
+ // a string body, planTrim cannot elide it, so every screenshot was re-serialized
294
+ // into every request body, every session save and every cache-prefix walk until a
295
+ // full compaction dropped it. Keep only the newest `mediaKeep` media results;
296
+ // older ones spill their text (if any) and lose the bytes, same contract as the
297
+ // string path: saved to disk first, pointer left behind.
298
+
299
+ function planMediaTrim(messages, keep) {
300
+ const idx = [];
301
+ if (Array.isArray(messages)) {
302
+ for (let i = 0; i < messages.length; i++) {
303
+ const m = messages[i];
304
+ if (m && m.role === 'tool' && m.content && typeof m.content === 'object'
305
+ && Array.isArray(m.content.media) && m.content.media.length) idx.push(i);
306
+ }
307
+ }
308
+ const elide = idx.slice(0, Math.max(0, idx.length - Math.max(0, keep)));
309
+ return { elide };
310
+ }
311
+
312
+ function applyMediaTrim(messages, mediaPlan, sessionId) {
313
+ const out = { elided: 0, elidedBytes: 0 };
314
+ for (const i of mediaPlan.elide) {
315
+ const m = messages[i];
316
+ let bytes = 0;
317
+ for (const part of m.content.media) bytes += (part && part.data ? part.data.length : 0);
318
+ const text = String(m.content.text || '');
319
+ const spilled = spillResult(sessionId, `trim-${m.toolCallId || i}`, text || '[media only]');
320
+ m.content = `[Media result elided to save context — ${fmtBytes(bytes)} of image data from ${m.name || 'tool'}`
321
+ + (spilled ? `; the description was saved to: ${spilled.file}` : '; the description could not be saved')
322
+ + '. Re-run the tool if you need the image again.]';
323
+ out.elided++;
324
+ out.elidedBytes += bytes;
325
+ }
326
+ return out;
264
327
  }
@@ -18,9 +18,25 @@ import { createTask, appendTaskOutput, settleTask } from '../agent-task.js';
18
18
 
19
19
  export const PROMPT_TEMPLATE_PLACEHOLDER = '{{item}}';
20
20
  export const MAX_AGENT_SWARM_SUBAGENTS = 128;
21
- // How many subagents run at once. kimi queues launches automatically; we use a
22
- // small fixed window so a 128-item swarm cannot stampede the machine.
23
- const SWARM_CONCURRENCY = 4;
21
+ // How many subagents run at once. Each worker awaits ONE subagent at a time, so this is the
22
+ // number of models generating concurrently — the reason a swarm exists at all, given that
23
+ // `Agent` calls in one message are run in sequence.
24
+ const SWARM_CONCURRENCY = 7;
25
+
26
+ /**
27
+ * Median of a list of numbers, or 0 when there is nothing to go on.
28
+ *
29
+ * Median rather than mean: one agent that loops for a hundred steps would drag a mean far
30
+ * above what a typical agent costs, and every other bar would then sit near empty while its
31
+ * agent was in fact nearly done.
32
+ */
33
+ function medianSteps(counts) {
34
+ if (!Array.isArray(counts) || !counts.length) return 0;
35
+ const sorted = counts.slice().sort((a, b) => a - b);
36
+ const mid = Math.floor(sorted.length / 2);
37
+ return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2;
38
+ }
39
+
24
40
 
25
41
  // Validate and expand the swarm request. Returns { prompts } or { error }.
26
42
  export function planSwarm(args) {
@@ -35,6 +51,22 @@ export function planSwarm(args) {
35
51
  ? Object.entries(args.resume_agent_ids).map(([id, p]) => [String(id).trim(), String(p).trim()])
36
52
  : [];
37
53
 
54
+ // The SHAPE is checked before the counts, so the message names the field that is wrong. An
55
+ // array of ids used to be silently dropped, and the caller was then told to provide "a
56
+ // resume_agent_ids map" — which it had given, in the only shape it knew.
57
+ if (args.resume_agent_ids !== undefined
58
+ && (typeof args.resume_agent_ids !== 'object' || Array.isArray(args.resume_agent_ids))) {
59
+ return { error: 'Error: `resume_agent_ids` must be an object mapping agent_id to prompt, such as {"<agent_id>": "continue"}. An array of ids is not accepted.' };
60
+ }
61
+ if (args.items !== undefined && !Array.isArray(args.items)) {
62
+ return { error: 'Error: `items` must be an array of strings.' };
63
+ }
64
+ // Blank entries were dropped above. Two blank items is a request the caller THINKS it made, and
65
+ // "provide at least 2 items" reads as a refusal of something that was provided.
66
+ const rawCount = Array.isArray(args.items) ? args.items.length : 0;
67
+ if (rawCount > 0 && items.length < rawCount && items.length + resumeMap.length < 2) {
68
+ return { error: `Error: ${rawCount} item(s) given but ${rawCount - items.length} were blank, leaving ${items.length}. Each item must be a non-empty string.` };
69
+ }
38
70
  if (items.length === 0 && resumeMap.length === 0) {
39
71
  return { error: 'Error: provide at least 2 items, or a resume_agent_ids map.' };
40
72
  }
@@ -73,7 +105,7 @@ Use enough subagents to keep the work focused and parallel. AgentSwarm supports
73
105
  When NOT to use AgentSwarm:
74
106
  - 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.
75
107
  - 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.
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.
108
+ - Do not use it for two or three differently-shaped tasks: use separate \`Agent\` calls instead. Note that several \`Agent\` calls in one message run ONE AT A TIME — only read-only tools batch — so a swarm is the only way to overlap subagents, and a 3-item swarm really does run 3 at once.
77
109
  - Do not use it to fan out work you could finish yourself in a few steps.
78
110
 
79
111
  If \`AgentSwarm\` is called, that call must be the only tool call in the response.`,
@@ -122,45 +154,116 @@ If \`AgentSwarm\` is called, that call must be the only tool call in the respons
122
154
  detached: false,
123
155
  });
124
156
 
157
+ // ONE TASK PER SUBAGENT, parented to this one. A foreground subagent creates no task of
158
+ // its own, so a 7-item swarm put a single row in /tasks and there was no way to see what
159
+ // each subagent was doing — the panel is organised by task, so no task meant no window.
160
+ const subTasks = jobs.map((job, i) => {
161
+ const t = createTask(ctx, 'agent', {
162
+ description: `${description} #${i + 1}`,
163
+ subagentType: type,
164
+ model: args.model || '',
165
+ detached: true,
166
+ parentTaskId: task.taskId,
167
+ });
168
+ return t;
169
+ });
170
+
125
171
  // Run with a bounded window. Each job appends its verdict to the task output
126
172
  // as it finishes, so TaskOutput shows partial progress.
127
173
  const results = new Array(jobs.length).fill(null);
174
+ // Tool-call counts of the agents that have FINISHED, so a running agent's bar can be
175
+ // scaled against what the swarm is actually costing. A running agent does not contribute
176
+ // here — its own count is still growing and would understate the reference.
177
+ const finishedSteps = [];
128
178
  let next = 0;
129
179
  const worker = async () => {
130
180
  for (;;) {
131
181
  const i = next++;
132
182
  if (i >= jobs.length) return;
133
183
  const job = jobs[i];
184
+ const subTask = subTasks[i];
185
+ // Announce the START, not only the finish. Without this the only signal on the wire
186
+ // was "[n/m] finished", which says how many are DONE and nothing about which are
187
+ // running — so the progress block marked everything unfinished as "queued" and a
188
+ // 7-wide swarm looked like one working cell and six stuck ones.
189
+ if (typeof ctx.onOutput === 'function') ctx.onOutput(`[#${i + 1}] started\n`);
190
+ appendTaskOutput(task, `[#${i + 1}] started\n`);
191
+ // Declared OUTSIDE the try: the catch below still has to report this agent's cost,
192
+ // and the block that does so runs whether the agent succeeded or threw.
193
+ const t0 = Date.now();
194
+ let steps = 0;
134
195
  try {
135
196
  const subArgs = {
136
197
  prompt: job.prompt,
137
198
  description: `${description} #${i + 1}`,
138
199
  ...(job.kind === 'resume' ? { resume: job.agentId } : { subagent_type: type }),
200
+ ...(job.kind === 'resume' ? { resume: job.agentId } : { subagent_type: type }),
139
201
  ...(args.model ? { model: args.model } : {}),
140
202
  ...(args.fork === true && job.kind === 'new' ? { fork: true } : {}),
141
203
  };
142
- const out = await agentSpec.execute(subArgs, { ...ctx, _progressTag: `#${i + 1}` });
204
+ // `steps` counts this agent's tool calls so far. There is no "x of y" to show — a
205
+ // subagent runs with an unbounded step budget — so the bar is paced against the
206
+ // swarm's OWN observed speed: the median number of steps the agents that have
207
+ // already FINISHED needed. That is the only estimate available, and it sharpens as
208
+ // more of them land.
209
+ //
210
+ // The reference is never allowed to be 0. An agent that answers straight from the
211
+ // prompt runs no tools at all, so it finishes with 0 steps — and a median of 0 would
212
+ // send EVERY other agent down the elapsed-time branch below, where the bar needs a
213
+ // full minute to reach 40% and a fast agent reports 0% the whole way. One tool-less
214
+ // agent must not disable the scale for the rest of the swarm.
215
+ const progressLine = () => {
216
+ if (typeof ctx.onOutput !== 'function') return;
217
+ const reference = Math.max(1, medianSteps(finishedSteps));
218
+ const ratio = finishedSteps.length
219
+ ? Math.min(0.95, steps / (reference + 1))
220
+ // Nothing has finished yet, so there is no pace to compare against. Creep
221
+ // rather than assert a figure: `steps` alone would read as a tiny percentage
222
+ // next to an unknown total.
223
+ : Math.min(0.4, steps / 40, 0.1 + ((Date.now() - t0) / 60000) * 0.3);
224
+ ctx.onOutput(`[#${i + 1}] progress ${Math.round(ratio * 100)}\n`);
225
+ };
226
+ const out = await agentSpec.execute(subArgs, {
227
+ ...ctx,
228
+ _progressTag: `#${i + 1}`,
229
+ _subTaskId: subTask.taskId,
230
+ _onSubProgress: () => { steps++; progressLine(); },
231
+ });
143
232
  // `agentSpec.execute` returns `agent_id: <id>\n\n<handoff>`. The swarm's
144
233
  // caller wants the handoff, and the id is only useful for a later
145
234
  // `resume`; keep it on one labelled line rather than letting it lead the
146
235
  // block, so the reported text is what each subagent actually produced.
147
236
  const raw = String(out);
148
237
  const m = /^agent_id:\s*(\S+)\s*\n+([\s\S]*)$/.exec(raw);
238
+ // A subagent that died returns `Error: subagent … failed: …` rather than throwing, so
239
+ // the catch below never sees it. Without this test the cell was marked a success and
240
+ // the failure text was buried in a body nobody reads as an error.
241
+ const died = /^Error: subagent /.test(raw);
149
242
  const body = (m ? m[2] : raw).trim();
150
243
  const head = `--- #${i + 1}${job.kind === 'resume' ? ` (resumed ${job.agentId})` : ''}${m ? ` [${m[1]}]` : ''} ---`;
151
244
  results[i] = `${head}\n${body}`;
245
+ settleTask(subTask, died ? 'failed' : 'completed', died ? { stopReason: body } : undefined);
152
246
  } catch (e) {
153
247
  results[i] = `--- #${i + 1} ---\n[error: ${e.message}]`;
248
+ settleTask(subTask, 'failed', { stopReason: e.message });
154
249
  }
250
+ // Contribute this agent's cost to the reference the running ones scale against. A
251
+ // Contribute this agent's cost to the reference the running ones scale against. A
252
+ // failed agent is recorded too: it did that much work, so it says as much about the
253
+ // pace as a successful one, and excluding it would bias the bar optimistically.
254
+ finishedSteps.push(steps);
155
255
  // Report progress down BOTH channels, and they are not interchangeable:
156
256
  // * 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.
257
+ // * `ctx.onOutput` is the LIVE sink the transcript's progress block reads.
258
+ //
259
+ // WHICH agent finished, not just how many. A bare `[n/m]` count left the reader no
260
+ // choice but to assume the first `n` agents were the finished ones — and with
261
+ // SWARM_CONCURRENCY workers pulling from a shared queue, they finish in whatever order
262
+ // they happen to run. The grid then lit up the wrong cells and only agreed with
263
+ // reality once every one of them was done. `[#i] finished` names the agent; the count
264
+ // is kept for readers that only track totals.
162
265
  const done = results.filter((r) => r !== null).length;
163
- const line = `[${done}/${jobs.length}] finished\n`;
266
+ const line = `[#${i + 1}] finished [${done}/${jobs.length}]\n`;
164
267
  appendTaskOutput(task, line);
165
268
  if (typeof ctx.onOutput === 'function') ctx.onOutput(line);
166
269
  }