reasonix 0.28.0 → 0.29.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.
package/dist/cli/index.js CHANGED
@@ -2742,6 +2742,10 @@ var ToolRegistry = class {
2742
2742
  wasFlattened(name) {
2743
2743
  return Boolean(this._tools.get(name)?.flatSchema);
2744
2744
  }
2745
+ /** Unknown / unannotated tools default to false — third-party MCP tools must opt in. */
2746
+ isParallelSafe(name) {
2747
+ return this._tools.get(name)?.parallelSafe === true;
2748
+ }
2745
2749
  specs() {
2746
2750
  return [...this._tools.values()].map((t3) => ({
2747
2751
  type: "function",
@@ -4433,6 +4437,48 @@ var CacheFirstLoop = class {
4433
4437
  this._escalateThisTurn = true;
4434
4438
  return true;
4435
4439
  }
4440
+ async runOneToolCall(call, signal) {
4441
+ const name = call.function?.name ?? "";
4442
+ const args = call.function?.arguments ?? "{}";
4443
+ const parsedArgs = safeParseToolArgs(args);
4444
+ const preReport = await runHooks({
4445
+ hooks: this.hooks,
4446
+ payload: {
4447
+ event: "PreToolUse",
4448
+ cwd: this.hookCwd,
4449
+ toolName: name,
4450
+ toolArgs: parsedArgs
4451
+ }
4452
+ });
4453
+ const preWarnings = [...hookWarnings(preReport.outcomes, this._turn)];
4454
+ if (preReport.blocked) {
4455
+ const blocking = preReport.outcomes[preReport.outcomes.length - 1];
4456
+ const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
4457
+ return {
4458
+ preWarnings,
4459
+ postWarnings: [],
4460
+ result: `[hook block] ${blocking?.hook.command ?? "<unknown>"}
4461
+ ${reason}`
4462
+ };
4463
+ }
4464
+ const result = await this.tools.dispatch(name, args, {
4465
+ signal,
4466
+ maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
4467
+ confirmationGate: this.confirmationGate
4468
+ });
4469
+ const postReport = await runHooks({
4470
+ hooks: this.hooks,
4471
+ payload: {
4472
+ event: "PostToolUse",
4473
+ cwd: this.hookCwd,
4474
+ toolName: name,
4475
+ toolArgs: parsedArgs,
4476
+ toolResult: result
4477
+ }
4478
+ });
4479
+ const postWarnings = [...hookWarnings(postReport.outcomes, this._turn)];
4480
+ return { preWarnings, postWarnings, result };
4481
+ }
4436
4482
  buildMessages(pendingUser) {
4437
4483
  const healed = healLoadedMessages(this.log.toMessages(), DEFAULT_MAX_RESULT_CHARS);
4438
4484
  const msgs = [...this.prefix.toMessages(), ...healed.messages];
@@ -4924,71 +4970,69 @@ var CacheFirstLoop = class {
4924
4970
  yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "context-guard" });
4925
4971
  return;
4926
4972
  }
4927
- for (const call of repairedCalls) {
4928
- const name = call.function?.name ?? "";
4929
- const args = call.function?.arguments ?? "{}";
4930
- yield {
4931
- turn: this._turn,
4932
- role: "tool_start",
4933
- content: "",
4934
- toolName: name,
4935
- toolArgs: args
4936
- };
4937
- const parsedArgs = safeParseToolArgs(args);
4938
- const preReport = await runHooks({
4939
- hooks: this.hooks,
4940
- payload: {
4941
- event: "PreToolUse",
4942
- cwd: this.hookCwd,
4943
- toolName: name,
4944
- toolArgs: parsedArgs
4973
+ const dispatchSerial = (process.env.REASONIX_TOOL_DISPATCH ?? "auto").toLowerCase() === "serial";
4974
+ const parallelMaxParsed = Number.parseInt(process.env.REASONIX_PARALLEL_MAX ?? "", 10);
4975
+ const parallelMax = Number.isFinite(parallelMaxParsed) && parallelMaxParsed >= 1 ? Math.min(parallelMaxParsed, 16) : 3;
4976
+ let callIdx = 0;
4977
+ while (callIdx < repairedCalls.length) {
4978
+ const chunk = [];
4979
+ if (!dispatchSerial) {
4980
+ while (callIdx < repairedCalls.length && chunk.length < parallelMax && this.tools.isParallelSafe(repairedCalls[callIdx]?.function?.name ?? "")) {
4981
+ chunk.push(repairedCalls[callIdx++]);
4945
4982
  }
4946
- });
4947
- for (const w of hookWarnings(preReport.outcomes, this._turn)) yield w;
4948
- let result;
4949
- if (preReport.blocked) {
4950
- const blocking = preReport.outcomes[preReport.outcomes.length - 1];
4951
- const reason = (blocking?.stderr || blocking?.stdout || "blocked by PreToolUse hook").trim();
4952
- result = `[hook block] ${blocking?.hook.command ?? "<unknown>"}
4953
- ${reason}`;
4954
- } else {
4955
- result = await this.tools.dispatch(name, args, {
4956
- signal,
4957
- maxResultTokens: DEFAULT_MAX_RESULT_TOKENS,
4958
- confirmationGate: this.confirmationGate
4959
- });
4960
- const postReport = await runHooks({
4961
- hooks: this.hooks,
4962
- payload: {
4963
- event: "PostToolUse",
4964
- cwd: this.hookCwd,
4965
- toolName: name,
4966
- toolArgs: parsedArgs,
4967
- toolResult: result
4968
- }
4969
- });
4970
- for (const w of hookWarnings(postReport.outcomes, this._turn)) yield w;
4971
4983
  }
4972
- this.appendAndPersist({
4973
- role: "tool",
4974
- tool_call_id: call.id ?? "",
4975
- name,
4976
- content: result
4977
- });
4978
- if (this.noteToolFailureSignal(result)) {
4984
+ if (chunk.length === 0) {
4985
+ chunk.push(repairedCalls[callIdx++]);
4986
+ }
4987
+ for (const call of chunk) {
4979
4988
  yield {
4980
4989
  turn: this._turn,
4981
- role: "warning",
4982
- content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
4990
+ role: "tool_start",
4991
+ content: "",
4992
+ toolName: call.function?.name ?? "",
4993
+ toolArgs: call.function?.arguments ?? "{}"
4994
+ };
4995
+ }
4996
+ const settled = await Promise.allSettled(chunk.map((c) => this.runOneToolCall(c, signal)));
4997
+ for (let k = 0; k < chunk.length; k++) {
4998
+ const call = chunk[k];
4999
+ const name = call.function?.name ?? "";
5000
+ const args = call.function?.arguments ?? "{}";
5001
+ const s = settled[k];
5002
+ let result;
5003
+ let preWarnings = [];
5004
+ let postWarnings = [];
5005
+ if (s.status === "fulfilled") {
5006
+ preWarnings = s.value.preWarnings;
5007
+ postWarnings = s.value.postWarnings;
5008
+ result = s.value.result;
5009
+ } else {
5010
+ const err = s.reason instanceof Error ? s.reason : new Error(String(s.reason));
5011
+ result = JSON.stringify({ error: `${err.name}: ${err.message}` });
5012
+ }
5013
+ for (const w of preWarnings) yield w;
5014
+ for (const w of postWarnings) yield w;
5015
+ this.appendAndPersist({
5016
+ role: "tool",
5017
+ tool_call_id: call.id ?? "",
5018
+ name,
5019
+ content: result
5020
+ });
5021
+ if (this.noteToolFailureSignal(result)) {
5022
+ yield {
5023
+ turn: this._turn,
5024
+ role: "warning",
5025
+ content: `\u21E7 auto-escalating to ${ESCALATION_MODEL} for the rest of this turn \u2014 flash hit ${this._turnFailures.formatBreakdown()}. Next turn falls back to ${this.model} unless /pro is armed.`
5026
+ };
5027
+ }
5028
+ yield {
5029
+ turn: this._turn,
5030
+ role: "tool",
5031
+ content: result,
5032
+ toolName: name,
5033
+ toolArgs: args
4983
5034
  };
4984
5035
  }
4985
- yield {
4986
- turn: this._turn,
4987
- role: "tool",
4988
- content: result,
4989
- toolName: name,
4990
- toolArgs: args
4991
- };
4992
5036
  }
4993
5037
  }
4994
5038
  yield* forceSummaryAfterIterLimit(this.summaryContext(), { reason: "budget" });
@@ -5633,6 +5677,7 @@ function registerFilesystemTools(registry, opts) {
5633
5677
  };
5634
5678
  registry.register({
5635
5679
  name: "read_file",
5680
+ parallelSafe: true,
5636
5681
  description: `Read a file under the sandbox root. To save context, PREFER to scope the read instead of pulling the whole file:
5637
5682
  - head: N \u2192 first N lines (imports, public API, small configs)
5638
5683
  - tail: N \u2192 last N lines (recently-added code, log tails)
@@ -5716,6 +5761,7 @@ ${slice.join("\n")}`;
5716
5761
  });
5717
5762
  registry.register({
5718
5763
  name: "list_directory",
5764
+ parallelSafe: true,
5719
5765
  description: "List entries in a directory under the sandbox root. Returns one line per entry, marking directories with a trailing slash. Not recursive \u2014 use directory_tree for that.",
5720
5766
  readOnly: true,
5721
5767
  parameters: {
@@ -5736,6 +5782,7 @@ ${slice.join("\n")}`;
5736
5782
  });
5737
5783
  registry.register({
5738
5784
  name: "directory_tree",
5785
+ parallelSafe: true,
5739
5786
  description: `Recursively list entries in a directory. Shows indented tree structure with directories marked '/'. Budget-aware by default:
5740
5787
  - maxDepth defaults to 2 (root + one level). A depth-4 tree on a real repo blew ~5K tokens in one call. If you truly need deeper, pass maxDepth:N explicitly.
5741
5788
  - Skips ${[...SKIP_DIR_NAMES].sort().join(", ")} unless include_deps:true. Traversing into node_modules / .git / dist is almost always token-waste.
@@ -5814,6 +5861,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
5814
5861
  });
5815
5862
  registry.register({
5816
5863
  name: "search_files",
5864
+ parallelSafe: true,
5817
5865
  description: "Find files whose NAME matches a substring or regex. Case-insensitive. Walks the directory recursively under the sandbox root. Returns one path per line. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) by default.",
5818
5866
  readOnly: true,
5819
5867
  parameters: {
@@ -5839,6 +5887,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
5839
5887
  });
5840
5888
  registry.register({
5841
5889
  name: "search_content",
5890
+ parallelSafe: true,
5842
5891
  description: "Recursively grep file CONTENTS for a substring or regex. This is the right tool for 'find all places that call X', 'where is Y referenced', 'what files contain Z'. Different from search_files (which matches FILE NAMES). Returns one match per line in 'path:line: text' format. Skips dependency / VCS / build directories (node_modules, .git, dist, build, .next, target, .venv) and binary files by default.",
5843
5892
  readOnly: true,
5844
5893
  parameters: {
@@ -5881,6 +5930,7 @@ Prefer \`list_directory\` for a single-level view, \`search_files\` to find spec
5881
5930
  });
5882
5931
  registry.register({
5883
5932
  name: "get_file_info",
5933
+ parallelSafe: true,
5884
5934
  description: "Stat a path under the sandbox root. Returns type (file|directory|symlink), size in bytes, mtime in ISO-8601.",
5885
5935
  readOnly: true,
5886
5936
  parameters: {
@@ -6061,6 +6111,7 @@ function registerMemoryTools(registry, opts = {}) {
6061
6111
  name: "recall_memory",
6062
6112
  description: "Read the full body of a memory file when its MEMORY.md one-liner (already in the system prompt) isn't enough detail. Most of the time the index suffices \u2014 only call this when the user's question genuinely requires the full context.",
6063
6113
  readOnly: true,
6114
+ parallelSafe: true,
6064
6115
  parameters: {
6065
6116
  type: "object",
6066
6117
  properties: {
@@ -6413,6 +6464,11 @@ var SUBAGENT_TYPE_NAMES = Object.freeze(
6413
6464
  );
6414
6465
 
6415
6466
  // src/tools/subagent.ts
6467
+ var runIdCounter = 0;
6468
+ function nextRunId() {
6469
+ runIdCounter++;
6470
+ return `sub-${runIdCounter.toString(36)}`;
6471
+ }
6416
6472
  var DEFAULT_SUBAGENT_SYSTEM = `You are a Reasonix subagent. The parent agent spawned you to handle one focused subtask, then return.
6417
6473
 
6418
6474
  Rules:
@@ -6439,9 +6495,11 @@ async function spawnSubagent(opts) {
6439
6495
  const sink = opts.sink;
6440
6496
  const skillName = opts.skillName;
6441
6497
  const startedAt = Date.now();
6498
+ const runId = nextRunId();
6442
6499
  const taskPreview = opts.task.length > 30 ? `${opts.task.slice(0, 30)}\u2026` : opts.task;
6443
6500
  sink?.current?.({
6444
6501
  kind: "start",
6502
+ runId,
6445
6503
  task: taskPreview,
6446
6504
  skillName,
6447
6505
  model: model2,
@@ -6454,6 +6512,7 @@ async function spawnSubagent(opts) {
6454
6512
  const errorMessage2 = `subagent allow-list names tool(s) not registered in the parent: ${missing.join(", ")}. Fix the skill's \`allowed-tools\` frontmatter or check spelling.`;
6455
6513
  sink?.current?.({
6456
6514
  kind: "end",
6515
+ runId,
6457
6516
  task: taskPreview,
6458
6517
  skillName,
6459
6518
  model: model2,
@@ -6515,12 +6574,13 @@ async function spawnSubagent(opts) {
6515
6574
  let summarisingEmitted = false;
6516
6575
  try {
6517
6576
  for await (const ev of childLoop.step(opts.task)) {
6518
- sink?.current?.({ kind: "inner", task: taskPreview, skillName, model: model2, inner: ev });
6577
+ sink?.current?.({ kind: "inner", runId, task: taskPreview, skillName, model: model2, inner: ev });
6519
6578
  if (ev.role === "tool") {
6520
6579
  toolIter++;
6521
6580
  summarisingEmitted = false;
6522
6581
  sink?.current?.({
6523
6582
  kind: "progress",
6583
+ runId,
6524
6584
  task: taskPreview,
6525
6585
  skillName,
6526
6586
  model: model2,
@@ -6532,6 +6592,7 @@ async function spawnSubagent(opts) {
6532
6592
  summarisingEmitted = true;
6533
6593
  sink?.current?.({
6534
6594
  kind: "phase",
6595
+ runId,
6535
6596
  task: taskPreview,
6536
6597
  skillName,
6537
6598
  model: model2,
@@ -6568,6 +6629,7 @@ async function spawnSubagent(opts) {
6568
6629
  [\u2026truncated ${final.length - maxResultChars} chars; ask the subagent for a tighter summary if you need more.]` : final;
6569
6630
  sink?.current?.({
6570
6631
  kind: "end",
6632
+ runId,
6571
6633
  task: taskPreview,
6572
6634
  skillName,
6573
6635
  model: model2,
@@ -7918,6 +7980,7 @@ function registerShellTools(registry, opts) {
7918
7980
  name: "job_output",
7919
7981
  description: "Read the latest output of a background job started with `run_background`. By default returns the tail of the buffer (last 80 lines). Pass `since` (the `byteLength` from a previous call) to stream only new content incrementally. Tells you whether the job is still running, so you can stop polling when it's done.",
7920
7982
  readOnly: true,
7983
+ parallelSafe: true,
7921
7984
  parameters: {
7922
7985
  type: "object",
7923
7986
  properties: {
@@ -7962,6 +8025,7 @@ function registerShellTools(registry, opts) {
7962
8025
  name: "list_jobs",
7963
8026
  description: "List every background job started this session \u2014 running and exited \u2014 with id, command, pid, status. Use when you've lost track of which job_id corresponds to which process, or to see what's still alive.",
7964
8027
  readOnly: true,
8028
+ parallelSafe: true,
7965
8029
  parameters: { type: "object", properties: {} },
7966
8030
  fn: async () => {
7967
8031
  const all = jobs2.list();
@@ -8219,6 +8283,7 @@ function registerWebTools(registry, opts = {}) {
8219
8283
  name: "web_search",
8220
8284
  description: "Search the public web. Returns ranked results with title, url, and snippet. Call this when the answer's correctness depends on current state \u2014 anything that changes over time (events, prices, releases, status of a thing in the real world). Composing such answers from training memory invents stale numbers; search first, then ground the answer in the results. For evergreen / definitional questions you don't need this.",
8221
8285
  readOnly: true,
8286
+ parallelSafe: true,
8222
8287
  parameters: {
8223
8288
  type: "object",
8224
8289
  properties: {
@@ -8242,6 +8307,7 @@ function registerWebTools(registry, opts = {}) {
8242
8307
  name: "web_fetch",
8243
8308
  description: "Download a URL and return its visible text content (HTML pages get scripts/styles/nav stripped). Truncated at the tool-result cap. Use after web_search when a snippet isn't enough.",
8244
8309
  readOnly: true,
8310
+ parallelSafe: true,
8245
8311
  parameters: {
8246
8312
  type: "object",
8247
8313
  properties: {
@@ -15539,6 +15605,7 @@ async function registerSemanticSearchTool(registry, opts) {
15539
15605
  name: "semantic_search",
15540
15606
  description: "FIRST CHOICE for descriptive queries. Use this BEFORE search_content (grep) when the user describes WHAT code does ('where do we handle X', 'which file owns Y', 'how does Z work', 'find the logic that \u2026'). Returns ranked snippets ordered by semantic relevance \u2014 finds the right file even when your description shares no words with the code. Falls back to search_content / search_files only for: exact identifiers, regex patterns, or counting occurrences of a known token. If your first instinct is grep on a paraphrased question, you are wrong \u2014 try semantic_search first.",
15541
15607
  readOnly: true,
15608
+ parallelSafe: true,
15542
15609
  parameters: {
15543
15610
  type: "object",
15544
15611
  properties: {
@@ -16917,6 +16984,7 @@ function registerSkillTools(registry, opts = {}) {
16917
16984
  name: "run_skill",
16918
16985
  description: "Invoke a playbook from the Skills index pinned in the system prompt. Each entry is a self-contained instruction block. Pass `name` as the BARE skill identifier (e.g. 'explore'), NOT the `[\u{1F9EC} subagent]` tag that appears after it in the index. Entries tagged `[\u{1F9EC} subagent]` spawn an isolated subagent \u2014 only the final distilled answer comes back, the model's tool calls + reasoning during the run never enter your context. Plain skills are inlined: the body becomes a tool result you read and follow. For subagent skills, supply 'arguments' describing the concrete task \u2014 they'll be the only context the subagent has.",
16919
16986
  readOnly: true,
16987
+ parallelSafe: true,
16920
16988
  parameters: {
16921
16989
  type: "object",
16922
16990
  properties: {
@@ -25085,9 +25153,7 @@ function subagentPhaseLabel(phase, iter, elapsedMs) {
25085
25153
  if (iter === 0) return "thinking\u2026";
25086
25154
  return "working through tools\u2026";
25087
25155
  }
25088
- function SubagentRow({
25089
- activity
25090
- }) {
25156
+ function SubagentRow({ activity }) {
25091
25157
  useTick();
25092
25158
  const seconds = (activity.elapsedMs / 1e3).toFixed(1);
25093
25159
  const phase = subagentPhaseLabel(activity.phase, activity.iter, activity.elapsedMs);
@@ -25108,6 +25174,46 @@ function SubagentRow({
25108
25174
  }
25109
25175
  ), /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, "task ", /* @__PURE__ */ React58.createElement(Text2, { color: FG.sub }, activity.task)), /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, "last ", last ? /* @__PURE__ */ React58.createElement(React58.Fragment, null, /* @__PURE__ */ React58.createElement(Text2, { color: last.color }, `${last.glyph} `), /* @__PURE__ */ React58.createElement(Text2, { color: FG.body }, last.label), last.meta ? /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, ` ${last.meta}`) : null) : /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, "queued\u2026")), /* @__PURE__ */ React58.createElement(Text2, { color: TONE.brand }, "\u25B6 ", phase));
25110
25176
  }
25177
+ function SubagentLiveStack({
25178
+ activities,
25179
+ max = 3
25180
+ }) {
25181
+ const tick = useTick();
25182
+ if (activities.length === 0) return null;
25183
+ if (activities.length === 1) return /* @__PURE__ */ React58.createElement(SubagentRow, { activity: activities[0] });
25184
+ const visible = activities.slice(0, max);
25185
+ const overflow = activities.length - visible.length;
25186
+ const summarising = activities.filter((a) => a.phase === "summarising").length;
25187
+ const metaParts = [`${activities.length} running`];
25188
+ if (summarising > 0) metaParts.push(`${summarising} summarising`);
25189
+ return /* @__PURE__ */ React58.createElement(Card, { tone: CARD.subagent.color }, /* @__PURE__ */ React58.createElement(
25190
+ CardHeader,
25191
+ {
25192
+ glyph: "\u232C",
25193
+ tone: CARD.subagent.color,
25194
+ title: "subagents",
25195
+ titleColor: PILL_SECTION.plan.fg,
25196
+ titleBg: PILL_SECTION.plan.bg,
25197
+ subtitle: metaParts.join(" \xB7 "),
25198
+ right: /* @__PURE__ */ React58.createElement(Spinner, { kind: "braille", color: CARD.subagent.color })
25199
+ }
25200
+ ), visible.map((a, i) => /* @__PURE__ */ React58.createElement(CompactSubagentLine, { key: a.runId, activity: a, tick, index: i })), overflow > 0 ? /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, ` +${overflow} more running\u2026`) : null);
25201
+ }
25202
+ function CompactSubagentLine({
25203
+ activity,
25204
+ tick,
25205
+ index
25206
+ }) {
25207
+ const summarising = activity.phase === "summarising";
25208
+ const spinnerFrame = SPINNER_FRAMES[(tick + index) % SPINNER_FRAMES.length] ?? "\xB7";
25209
+ const glyph = summarising ? "\u25B6" : spinnerFrame;
25210
+ const glyphColor = summarising ? TONE.brand : CARD.subagent.color;
25211
+ const seconds = (activity.elapsedMs / 1e3).toFixed(1).padStart(5);
25212
+ const title = activity.skillName ?? truncate3(activity.task, 28);
25213
+ const titlePadded = title.padEnd(28);
25214
+ const last = activity.lastInner;
25215
+ return /* @__PURE__ */ React58.createElement(Box2, { flexDirection: "row" }, /* @__PURE__ */ React58.createElement(Text2, { color: glyphColor, bold: true }, ` ${glyph} `), /* @__PURE__ */ React58.createElement(Text2, { color: FG.body }, titlePadded), /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, ` iter ${String(activity.iter).padStart(2)} \xB7 ${seconds}s \xB7 `), last ? /* @__PURE__ */ React58.createElement(React58.Fragment, null, /* @__PURE__ */ React58.createElement(Text2, { color: last.color }, `${last.glyph} `), /* @__PURE__ */ React58.createElement(Text2, { color: FG.body }, truncate3(last.label, 18)), last.meta ? /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, ` ${last.meta}`) : null) : /* @__PURE__ */ React58.createElement(Text2, { color: FG.faint }, "queued\u2026"));
25216
+ }
25111
25217
  function truncate3(text, max) {
25112
25218
  return text.length > max ? `${text.slice(0, max)}\u2026` : text;
25113
25219
  }
@@ -28960,7 +29066,7 @@ function useSubagent({
28960
29066
  log,
28961
29067
  getWalletCurrency
28962
29068
  }) {
28963
- const [activity, setActivity] = useState17(null);
29069
+ const [activities, setActivities] = useState17([]);
28964
29070
  const sinkRef = useRef8({ current: null });
28965
29071
  const getWalletCurrencyRef = useRef8(getWalletCurrency);
28966
29072
  useEffect13(() => {
@@ -28969,24 +29075,11 @@ function useSubagent({
28969
29075
  useEffect13(() => {
28970
29076
  sinkRef.current.current = (ev) => {
28971
29077
  if (ev.kind === "start") {
28972
- setActivity({
28973
- task: ev.task,
28974
- iter: ev.iter ?? 0,
28975
- elapsedMs: ev.elapsedMs ?? 0,
28976
- skillName: ev.skillName,
28977
- model: ev.model,
28978
- phase: "exploring",
28979
- lastInner: null
28980
- });
28981
- return;
28982
- }
28983
- if (ev.kind === "progress") {
28984
- setActivity(
28985
- (prev) => prev ? {
28986
- ...prev,
28987
- iter: ev.iter ?? prev.iter,
28988
- elapsedMs: ev.elapsedMs ?? prev.elapsedMs
28989
- } : {
29078
+ setActivities((prev) => {
29079
+ if (prev.some((a) => a.runId === ev.runId)) return prev;
29080
+ const next = {
29081
+ runId: ev.runId,
29082
+ startedAt: Date.now() - (ev.elapsedMs ?? 0),
28990
29083
  task: ev.task,
28991
29084
  iter: ev.iter ?? 0,
28992
29085
  elapsedMs: ev.elapsedMs ?? 0,
@@ -28994,45 +29087,59 @@ function useSubagent({
28994
29087
  model: ev.model,
28995
29088
  phase: "exploring",
28996
29089
  lastInner: null
28997
- }
28998
- );
28999
- return;
29000
- }
29001
- if (ev.kind === "phase") {
29002
- setActivity((prev) => prev ? { ...prev, phase: ev.phase } : prev);
29090
+ };
29091
+ return [...prev, next];
29092
+ });
29003
29093
  return;
29004
29094
  }
29005
- if (ev.kind === "inner" && ev.inner) {
29006
- const summary2 = summariseInner(ev.inner);
29007
- if (!summary2) return;
29008
- setActivity((prev) => prev ? { ...prev, lastInner: summary2 } : prev);
29095
+ if (ev.kind === "end") {
29096
+ setActivities((prev) => prev.filter((a) => a.runId !== ev.runId));
29097
+ const seconds = ((ev.elapsedMs ?? 0) / 1e3).toFixed(1);
29098
+ const costTail = ev.costUsd !== void 0 && ev.costUsd > 0 ? ` \xB7 ${formatCost(ev.costUsd, getWalletCurrencyRef.current?.())}` : "";
29099
+ const summary = ev.error ? `\u232C subagent "${ev.task}" failed after ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \u2014 ${ev.error}` : `\u232C subagent "${ev.task}" done in ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \xB7 ${ev.turns ?? 0} turn(s)${costTail}`;
29100
+ log.pushInfo(summary);
29101
+ if (!ev.error && ev.usage && ev.model) {
29102
+ appendUsage({
29103
+ session: session ?? null,
29104
+ model: ev.model,
29105
+ usage: ev.usage,
29106
+ kind: "subagent",
29107
+ subagent: {
29108
+ skillName: ev.skillName,
29109
+ taskPreview: ev.task.slice(0, 60),
29110
+ toolIters: ev.iter ?? 0,
29111
+ durationMs: ev.elapsedMs ?? 0
29112
+ }
29113
+ });
29114
+ }
29009
29115
  return;
29010
29116
  }
29011
- setActivity(null);
29012
- const seconds = ((ev.elapsedMs ?? 0) / 1e3).toFixed(1);
29013
- const costTail = ev.costUsd !== void 0 && ev.costUsd > 0 ? ` \xB7 ${formatCost(ev.costUsd, getWalletCurrencyRef.current?.())}` : "";
29014
- const summary = ev.error ? `\u232C subagent "${ev.task}" failed after ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \u2014 ${ev.error}` : `\u232C subagent "${ev.task}" done in ${seconds}s \xB7 ${ev.iter ?? 0} tool call(s) \xB7 ${ev.turns ?? 0} turn(s)${costTail}`;
29015
- log.pushInfo(summary);
29016
- if (!ev.error && ev.usage && ev.model) {
29017
- appendUsage({
29018
- session: session ?? null,
29019
- model: ev.model,
29020
- usage: ev.usage,
29021
- kind: "subagent",
29022
- subagent: {
29023
- skillName: ev.skillName,
29024
- taskPreview: ev.task.slice(0, 60),
29025
- toolIters: ev.iter ?? 0,
29026
- durationMs: ev.elapsedMs ?? 0
29117
+ setActivities(
29118
+ (prev) => prev.map((a) => {
29119
+ if (a.runId !== ev.runId) return a;
29120
+ if (ev.kind === "progress") {
29121
+ return {
29122
+ ...a,
29123
+ iter: ev.iter ?? a.iter,
29124
+ elapsedMs: ev.elapsedMs ?? a.elapsedMs
29125
+ };
29027
29126
  }
29028
- });
29029
- }
29127
+ if (ev.kind === "phase") {
29128
+ return { ...a, phase: ev.phase ?? a.phase };
29129
+ }
29130
+ if (ev.kind === "inner" && ev.inner) {
29131
+ const summary = summariseInner(ev.inner);
29132
+ return summary ? { ...a, lastInner: summary } : a;
29133
+ }
29134
+ return a;
29135
+ })
29136
+ );
29030
29137
  };
29031
29138
  return () => {
29032
29139
  sinkRef.current.current = null;
29033
29140
  };
29034
29141
  }, [session, log]);
29035
- return { activity, sinkRef };
29142
+ return { activities, sinkRef };
29036
29143
  }
29037
29144
 
29038
29145
  // src/cli/ui/App.tsx
@@ -29130,7 +29237,7 @@ function AppInner({
29130
29237
  };
29131
29238
  }, [stdout4]);
29132
29239
  const walletCurrencyRef = useRef9(void 0);
29133
- const { activity: subagentActivity, sinkRef: subagentSinkRef } = useSubagent({
29240
+ const { activities: subagentActivities, sinkRef: subagentSinkRef } = useSubagent({
29134
29241
  session,
29135
29242
  log,
29136
29243
  getWalletCurrency: () => walletCurrencyRef.current
@@ -30900,7 +31007,7 @@ Stay in plan mode \u2014 address the feedback (explore more if needed), then sub
30900
31007
  dashboardUrl,
30901
31008
  languageVersion
30902
31009
  }
30903
- ) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && ongoingTool ? /* @__PURE__ */ React65.createElement(OngoingToolRow, { tool: ongoingTool, progress: toolProgress }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && subagentActivity ? /* @__PURE__ */ React65.createElement(SubagentRow, { activity: subagentActivity }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && !ongoingTool && statusLine ? /* @__PURE__ */ React65.createElement(ThinkingRow, { text: statusLine }) : null, !PLAIN_UI && undoBanner && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && !pendingChoice && !stagedChoiceCustom && !pendingRevision && !stagedCheckpointRevise && !pendingCheckpoint ? /* @__PURE__ */ React65.createElement(UndoBanner, { banner: undoBanner }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && busy && !isStreaming && !ongoingTool && !statusLine ? /* @__PURE__ */ React65.createElement(ThinkingRow, { text: "processing\u2026" }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview ? /* @__PURE__ */ React65.createElement(PlanLiveRow, null) : null, /* @__PURE__ */ React65.createElement(ToastRail, null)), stagedInput ? /* @__PURE__ */ React65.createElement(
31010
+ ) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && ongoingTool ? /* @__PURE__ */ React65.createElement(OngoingToolRow, { tool: ongoingTool, progress: toolProgress }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && subagentActivities.length > 0 ? /* @__PURE__ */ React65.createElement(SubagentLiveStack, { activities: subagentActivities, max: 3 }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && !ongoingTool && statusLine ? /* @__PURE__ */ React65.createElement(ThinkingRow, { text: statusLine }) : null, !PLAIN_UI && undoBanner && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && !pendingChoice && !stagedChoiceCustom && !pendingRevision && !stagedCheckpointRevise && !pendingCheckpoint ? /* @__PURE__ */ React65.createElement(UndoBanner, { banner: undoBanner }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview && busy && !isStreaming && !ongoingTool && !statusLine ? /* @__PURE__ */ React65.createElement(ThinkingRow, { text: "processing\u2026" }) : null, !PLAIN_UI && !pendingShell && !pendingPlan && !pendingReviseEditor && !pendingSessionsPicker && !pendingMcpHub && !stagedInput && !pendingEditReview ? /* @__PURE__ */ React65.createElement(PlanLiveRow, null) : null, /* @__PURE__ */ React65.createElement(ToastRail, null)), stagedInput ? /* @__PURE__ */ React65.createElement(
30904
31011
  PlanRefineInput,
30905
31012
  {
30906
31013
  mode: stagedInput.mode,